mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
Improve Rust code clarity across the workspace
Document public APIs and non-obvious control flow so the protocol, simulator, and macro crates are easier to follow. Tighten a few helper paths and feature gates while preserving behavior and keeping the workspace warning-free.
This commit is contained in:
+41
-41
@@ -3,71 +3,72 @@ use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
|
|||||||
use cbc::cipher::block_padding::Pkcs7;
|
use cbc::cipher::block_padding::Pkcs7;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
|
/// Returns the next AES block-sized buffer length for PKCS#7 padding.
|
||||||
fn pkcs7_padded_length(input_len: usize) -> usize {
|
fn pkcs7_padded_length(input_len: usize) -> usize {
|
||||||
let block_size = 16;
|
let block_size = 16;
|
||||||
((input_len / block_size) + 1) * block_size
|
((input_len / block_size) + 1) * block_size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn xor_fold(bytes: &[u8]) -> u8 {
|
||||||
|
bytes.iter().fold(0, |salt, &byte| salt ^ byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xor_key_with_salt(key: &mut [u8; 32], salt: u8) {
|
||||||
|
for byte in key.iter_mut() {
|
||||||
|
*byte ^= salt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts `plaintext` with AES-256-CBC and base62-encodes the result.
|
||||||
pub fn encrypt_aes(plaintext: &str, key_str: &str, iv: [u8; 16]) -> String {
|
pub fn encrypt_aes(plaintext: &str, key_str: &str, iv: [u8; 16]) -> String {
|
||||||
let plaintext = plaintext.as_bytes();
|
let plaintext = plaintext.as_bytes();
|
||||||
|
|
||||||
// Hash the env key to get a 32-byte (256-bit) AES key
|
// Hash the environment key into the fixed-width AES-256 key material expected below.
|
||||||
let key = hash(key_str.as_bytes());
|
let key = hash(key_str.as_bytes());
|
||||||
|
|
||||||
// Generate a pseudo-random salt byte based on the plaintext
|
// This crate intentionally perturbs the key with a single plaintext-derived byte so
|
||||||
// I hope this does not break the encryption.
|
// similar inputs diverge quickly after encryption while remaining reversible.
|
||||||
let mut salt = 0;
|
let salt = xor_fold(plaintext);
|
||||||
|
|
||||||
for byte in plaintext {
|
let mut salted_key = key;
|
||||||
salt ^= byte;
|
xor_key_with_salt(&mut salted_key, salt);
|
||||||
}
|
|
||||||
|
|
||||||
let mut key_salted = key.clone();
|
|
||||||
|
|
||||||
// Salt the key by XORing the salt byte with all the key bytes.
|
|
||||||
// This ensures that the "hash" generated from the plaintext will
|
|
||||||
// make the encrypted result extremely different.
|
|
||||||
for i in 0..32 {
|
|
||||||
key_salted[i] ^= salt;
|
|
||||||
}
|
|
||||||
|
|
||||||
let buf_len = pkcs7_padded_length(plaintext.len());
|
let buf_len = pkcs7_padded_length(plaintext.len());
|
||||||
|
|
||||||
let mut buf = vec![0u8; buf_len];
|
let mut buf = vec![0u8; buf_len];
|
||||||
let pt_len = plaintext.len();
|
let pt_len = plaintext.len();
|
||||||
buf[..pt_len].copy_from_slice(&plaintext);
|
buf[..pt_len].copy_from_slice(plaintext);
|
||||||
|
|
||||||
let mut ct = cbc::Encryptor::<aes::Aes256>::new(&key_salted.into(), &iv.into())
|
let mut ciphertext = cbc::Encryptor::<aes::Aes256>::new(&salted_key.into(), &iv.into())
|
||||||
.encrypt_padded::<Pkcs7>(&mut buf, pt_len)
|
.encrypt_padded::<Pkcs7>(&mut buf, pt_len)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.to_vec();
|
.to_vec();
|
||||||
|
|
||||||
// Add the salt byte to the key byte,
|
// Prefix the salt so decryption can rebuild the same salted key.
|
||||||
ct.insert(0, salt);
|
ciphertext.insert(0, salt);
|
||||||
|
|
||||||
// Encode result in base62
|
Base62::encode_full(&ciphertext, &key)
|
||||||
Base62::encode_full(&ct, &key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps [`encrypt_aes`] output in `_..._` markers for line-wise replacement.
|
||||||
pub fn encrypt_aes_lines(plaintext: &str, key_str: &str, iv: [u8; 16]) -> String {
|
pub fn encrypt_aes_lines(plaintext: &str, key_str: &str, iv: [u8; 16]) -> String {
|
||||||
format!("_{}_", encrypt_aes(plaintext, key_str, iv))
|
format!("_{}_", encrypt_aes(plaintext, key_str, iv))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes base62 input and decrypts the payload with AES-256-CBC.
|
||||||
pub fn decrypt_aes(input: &str, key_str: &str, iv: [u8; 16]) -> Result<String, String> {
|
pub fn decrypt_aes(input: &str, key_str: &str, iv: [u8; 16]) -> Result<String, String> {
|
||||||
// Hash the env key to get a 32-byte (256-bit) AES key
|
|
||||||
let mut key = hash(key_str.as_bytes());
|
let mut key = hash(key_str.as_bytes());
|
||||||
|
|
||||||
let mut cipher_bytes = Base62::decode_full(input, &key).unwrap();
|
let mut cipher_bytes = Base62::decode_full(input, &key)?;
|
||||||
|
|
||||||
|
if cipher_bytes.is_empty() {
|
||||||
|
return Err("decryption failed".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let salt = cipher_bytes.remove(0);
|
let salt = cipher_bytes.remove(0);
|
||||||
|
|
||||||
// XOR the salt bytes with the key bytes
|
xor_key_with_salt(&mut key, salt);
|
||||||
// This replicates
|
|
||||||
for i in 0..32 {
|
|
||||||
key[i] ^= salt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create buffer for result
|
|
||||||
let buf_len = cipher_bytes.len();
|
let buf_len = cipher_bytes.len();
|
||||||
let mut buf: Vec<u8> = vec![0; buf_len];
|
let mut buf: Vec<u8> = vec![0; buf_len];
|
||||||
buf[..cipher_bytes.len()].copy_from_slice(&cipher_bytes);
|
buf[..cipher_bytes.len()].copy_from_slice(&cipher_bytes);
|
||||||
@@ -79,26 +80,25 @@ pub fn decrypt_aes(input: &str, key_str: &str, iv: [u8; 16]) -> Result<String, S
|
|||||||
Ok(String::from_utf8_lossy(pt).to_string())
|
Ok(String::from_utf8_lossy(pt).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces every `_..._` AES marker in `input` that decrypts successfully.
|
||||||
pub fn decrypt_aes_lines(input: &str, key_str: &str, iv: [u8; 16]) -> String {
|
pub fn decrypt_aes_lines(input: &str, key_str: &str, iv: [u8; 16]) -> String {
|
||||||
let mut decrypted_result = input.to_string();
|
let mut decrypted_result = input.to_string();
|
||||||
let mut total_offset = 0;
|
let mut replacement_offset: isize = 0;
|
||||||
|
|
||||||
// Split input by segments of base62 chars, denoted by two _'s, and attempt to decode
|
// Walk the original input and patch into a separate mutable buffer. The offset keeps the
|
||||||
for aes_block in Regex::new(r"_([0-9a-zA-Z]*?)_").unwrap().find_iter(&input) {
|
// current match aligned after prior replacements change the string length.
|
||||||
|
for aes_block in Regex::new(r"_([0-9a-zA-Z]*?)_").unwrap().find_iter(input) {
|
||||||
let range = aes_block.range();
|
let range = aes_block.range();
|
||||||
let aes_block = aes_block.as_str()[1..(aes_block.len() - 1)].to_string();
|
let aes_block = aes_block.as_str()[1..(aes_block.len() - 1)].to_string();
|
||||||
|
|
||||||
// If the decryption is successful, offset the current offset position
|
|
||||||
if let Ok(decrypted_block) = decrypt_aes(&aes_block, key_str, iv) {
|
if let Ok(decrypted_block) = decrypt_aes(&aes_block, key_str, iv) {
|
||||||
let range = (range.start + total_offset as usize)..(range.end + total_offset as usize);
|
let adjusted_start = (range.start as isize + replacement_offset) as usize;
|
||||||
|
let adjusted_end = (range.end as isize + replacement_offset) as usize;
|
||||||
|
let adjusted_range = adjusted_start..adjusted_end;
|
||||||
|
|
||||||
// Offset range by the difference between the decrypted block length and the original range length
|
replacement_offset += decrypted_block.len() as isize - adjusted_range.len() as isize;
|
||||||
total_offset += decrypted_block.len().clone() - (range.end - range.start);
|
|
||||||
|
|
||||||
decrypted_result.replace_range(range, &decrypted_block);
|
decrypted_result.replace_range(adjusted_range, &decrypted_block);
|
||||||
} else {
|
|
||||||
// If the decode is unsuccessful, leave the underscore-denoted region as is
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-46
@@ -1,6 +1,6 @@
|
|||||||
use crate::{STATIC_BYTE_MAP, hash};
|
use crate::{STATIC_BYTE_MAP, hash};
|
||||||
|
|
||||||
// Randomly mapped Base62 characters
|
/// Base-62 encoder/decoder with a deterministic per-key character permutation.
|
||||||
pub struct Base62 {
|
pub struct Base62 {
|
||||||
charset: [char; 62],
|
charset: [char; 62],
|
||||||
}
|
}
|
||||||
@@ -12,34 +12,32 @@ pub const BASE62_CHARS: [char; 62] = [
|
|||||||
'v', 'w', 'x', 'y', 'z',
|
'v', 'w', 'x', 'y', 'z',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Const for ratio
|
/// `8.0 / log2(62.0)`, used to estimate encoded length from a byte length.
|
||||||
const ENCODING_RATIO: f64 = 8.0 / 5.954196310386875; // 8.0 / log2(62.0)
|
const ENCODING_RATIO: f64 = 8.0 / 5.954196310386875; // 8.0 / log2(62.0)
|
||||||
|
|
||||||
impl Base62 {
|
impl Base62 {
|
||||||
|
/// Builds the charset permutation for `key` and `nonce`.
|
||||||
pub fn new(key: &[u8], nonce: usize) -> Self {
|
pub fn new(key: &[u8], nonce: usize) -> Self {
|
||||||
// Hash key again, for the chance that this random function can be used to derive the key
|
// Re-hash the caller-provided key so charset generation always runs on a fixed-width input.
|
||||||
// My solution to not being good at cryptography lol
|
|
||||||
let key = hash(key);
|
let key = hash(key);
|
||||||
|
|
||||||
let mut charset: [char; 62] = [0 as char; 62];
|
let mut charset: [char; 62] = [0 as char; 62];
|
||||||
|
|
||||||
// Create a vector of indices from 0 to 61
|
let mut available_positions = (0..62).collect::<Vec<usize>>();
|
||||||
let mut current_indices = (0..62).map(|i| i as usize).collect::<Vec<usize>>();
|
|
||||||
|
|
||||||
// Loop through each byte in the key until all chars are filled
|
for (char_index, ch) in BASE62_CHARS.iter().copied().enumerate() {
|
||||||
for i in 0..62 as usize {
|
let random_byte = STATIC_BYTE_MAP[(key[char_index % key.len()] as usize + nonce) % 255];
|
||||||
let rand = STATIC_BYTE_MAP[(key[i as usize % key.len()] as usize + nonce) % 255];
|
|
||||||
|
|
||||||
let index_index = rand as usize % current_indices.len();
|
let choice_index = random_byte as usize % available_positions.len();
|
||||||
let put_index = current_indices.remove(index_index);
|
let charset_index = available_positions.remove(choice_index);
|
||||||
|
|
||||||
charset[put_index] = BASE62_CHARS[i];
|
charset[charset_index] = ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Self { charset };
|
Self { charset }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert character to base-62 value using custom charset
|
/// Converts a character to its base-62 value in this instance's charset.
|
||||||
fn char_to_value(&self, ch: char) -> Result<u8, String> {
|
fn char_to_value(&self, ch: char) -> Result<u8, String> {
|
||||||
self.charset
|
self.charset
|
||||||
.iter()
|
.iter()
|
||||||
@@ -49,7 +47,7 @@ impl Base62 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encodes a byte slice into a base-62 string using a custom character set
|
/// Encodes a byte slice into a base-62 string using a custom character set
|
||||||
/// Supports arbitrary length input by using big integer arithmetic
|
/// while preserving leading zero bytes.
|
||||||
pub fn encode(&self, data: &[u8]) -> String {
|
pub fn encode(&self, data: &[u8]) -> String {
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
@@ -68,7 +66,7 @@ impl Base62 {
|
|||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut num = data.to_vec();
|
let mut num = data.to_vec();
|
||||||
|
|
||||||
// Convert to base-62 using division
|
// Repeated division keeps the implementation independent from bigint crates.
|
||||||
while !is_zero(&num) {
|
while !is_zero(&num) {
|
||||||
let remainder = div_mod_62(&mut num);
|
let remainder = div_mod_62(&mut num);
|
||||||
result.push(self.charset[remainder]);
|
result.push(self.charset[remainder]);
|
||||||
@@ -85,7 +83,7 @@ impl Base62 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes a base-62 string back into bytes using a custom character set
|
/// Decodes a base-62 string back into bytes using a custom character set
|
||||||
/// Supports arbitrary length output
|
/// while preserving leading zero bytes.
|
||||||
pub fn decode(&self, encoded: &str) -> Result<Vec<u8>, String> {
|
pub fn decode(&self, encoded: &str) -> Result<Vec<u8>, String> {
|
||||||
if encoded.is_empty() {
|
if encoded.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -102,7 +100,7 @@ impl Base62 {
|
|||||||
return Ok(vec![0; leading_zeros]);
|
return Ok(vec![0; leading_zeros]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert base-62 string to bytes using multiplication
|
// Rebuild the big-endian integer via repeated multiply-add.
|
||||||
let mut num = vec![0u8];
|
let mut num = vec![0u8];
|
||||||
|
|
||||||
for ch in encoded.chars() {
|
for ch in encoded.chars() {
|
||||||
@@ -117,42 +115,41 @@ impl Base62 {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encodes `data` using the nonce convention shared with [`decode_full`].
|
||||||
pub fn encode_full(data: &[u8], key: &[u8]) -> String {
|
pub fn encode_full(data: &[u8], key: &[u8]) -> String {
|
||||||
// Predict the length of the encoded data
|
let predicted_len = predict_base62_len(data);
|
||||||
let length = predict_base62_len(data);
|
|
||||||
|
|
||||||
let base = Base62::new(&key, length % 255);
|
let base = Base62::new(key, predicted_len % 255);
|
||||||
let encoded = base.encode(data);
|
let encoded = base.encode(data);
|
||||||
|
|
||||||
// For the case that the encoded length is not equal to the predicted length
|
// The charset nonce is derived from the final encoded length, so a misprediction must
|
||||||
// The nonce must be derived from this length, so this needs to be ensured
|
// trigger one more pass with the actual length-derived nonce.
|
||||||
//
|
if encoded.len() != predicted_len {
|
||||||
// Re-encode with the correct length
|
let actual_len = encoded.len();
|
||||||
if encoded.len() != length {
|
let base = Base62::new(key, actual_len % 255);
|
||||||
let len = encoded.len();
|
|
||||||
let base = Base62::new(&key, len % 255);
|
|
||||||
let encoded = base.encode(data);
|
let encoded = base.encode(data);
|
||||||
|
|
||||||
assert_eq!(encoded.len(), len);
|
assert_eq!(encoded.len(), actual_len);
|
||||||
|
|
||||||
encoded
|
encoded
|
||||||
} else {
|
} else {
|
||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes a string previously produced by [`encode_full`].
|
||||||
pub fn decode_full(data: &str, key: &[u8]) -> Result<Vec<u8>, String> {
|
pub fn decode_full(data: &str, key: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
let base = Base62::new(&key, data.len() % 255);
|
let base = Base62::new(key, data.len() % 255);
|
||||||
base.decode(data)
|
base.decode(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Check if big integer (as bytes) is zero
|
/// Returns whether the big-endian integer represented by `num` is zero.
|
||||||
fn is_zero(num: &[u8]) -> bool {
|
fn is_zero(num: &[u8]) -> bool {
|
||||||
num.iter().all(|&b| b == 0)
|
num.iter().all(|&b| b == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Divide big integer by 62 and return remainder
|
/// Divides an in-place big-endian integer by `62`, returning the remainder.
|
||||||
// Modifies num in place to be the quotient
|
|
||||||
fn div_mod_62(num: &mut Vec<u8>) -> usize {
|
fn div_mod_62(num: &mut Vec<u8>) -> usize {
|
||||||
let mut remainder = 0u16;
|
let mut remainder = 0u16;
|
||||||
let mut all_zero = true;
|
let mut all_zero = true;
|
||||||
@@ -166,7 +163,7 @@ fn div_mod_62(num: &mut Vec<u8>) -> usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove leading zeros from quotient
|
// Keep a canonical representation so the next loop iteration can stop at `[0]`.
|
||||||
if all_zero {
|
if all_zero {
|
||||||
num.clear();
|
num.clear();
|
||||||
num.push(0);
|
num.push(0);
|
||||||
@@ -180,8 +177,7 @@ fn div_mod_62(num: &mut Vec<u8>) -> usize {
|
|||||||
remainder as usize
|
remainder as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Multiply big integer by 62 and add a value
|
/// Multiplies an in-place big-endian integer by `multiplier` and adds `add`.
|
||||||
// Modifies num in place
|
|
||||||
fn mul_add(num: &mut Vec<u8>, multiplier: u16, add: u8) {
|
fn mul_add(num: &mut Vec<u8>, multiplier: u16, add: u8) {
|
||||||
let mut carry = add as u16;
|
let mut carry = add as u16;
|
||||||
|
|
||||||
@@ -191,7 +187,6 @@ fn mul_add(num: &mut Vec<u8>, multiplier: u16, add: u8) {
|
|||||||
carry = product >> 8;
|
carry = product >> 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining carry bytes
|
|
||||||
while carry > 0 {
|
while carry > 0 {
|
||||||
num.insert(0, (carry & 0xFF) as u8);
|
num.insert(0, (carry & 0xFF) as u8);
|
||||||
carry >>= 8;
|
carry >>= 8;
|
||||||
@@ -205,22 +200,13 @@ pub fn predict_base62_len(input_bytes: &[u8]) -> usize {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Count leading zero bytes.
|
|
||||||
let num_leading_zeros = input_bytes.iter().take_while(|&&b| b == 0).count();
|
let num_leading_zeros = input_bytes.iter().take_while(|&&b| b == 0).count();
|
||||||
|
|
||||||
// 2. Calculate length of the rest of the bytes.
|
|
||||||
let num_rest_bytes = input_bytes.len() - num_leading_zeros;
|
let num_rest_bytes = input_bytes.len() - num_leading_zeros;
|
||||||
|
|
||||||
if num_rest_bytes == 0 {
|
if num_rest_bytes == 0 {
|
||||||
// If all bytes were zeros, the length is just the number of zeros.
|
|
||||||
num_leading_zeros
|
num_leading_zeros
|
||||||
} else {
|
} else {
|
||||||
// 3. Calculate the mathematical upper bound for the non-zero part.
|
|
||||||
// This is ceil(num_rest_bytes * 8_bits / log2(62))
|
|
||||||
// which is ceil(num_rest_bytes * log_62(256))
|
|
||||||
let rest_len = (num_rest_bytes as f64 * ENCODING_RATIO).ceil();
|
let rest_len = (num_rest_bytes as f64 * ENCODING_RATIO).ceil();
|
||||||
|
|
||||||
// 4. Total length is zeros + rest_len
|
|
||||||
num_leading_zeros + rest_len as usize
|
num_leading_zeros + rest_len as usize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -1,10 +1,12 @@
|
|||||||
|
//! Base62 encoding helpers plus the AES wrapper used by `ush-obfuscate`.
|
||||||
|
|
||||||
mod aes;
|
mod aes;
|
||||||
mod base62;
|
mod base62;
|
||||||
|
|
||||||
// Exports
|
|
||||||
pub use aes::{decrypt_aes, decrypt_aes_lines, encrypt_aes, encrypt_aes_lines};
|
pub use aes::{decrypt_aes, decrypt_aes_lines, encrypt_aes, encrypt_aes_lines};
|
||||||
pub use base62::Base62;
|
pub use base62::Base62;
|
||||||
|
|
||||||
|
/// Static IV shared by the proc-macro crate and the runtime decoder.
|
||||||
pub const STATIC_IV: [u8; 16] = [
|
pub const STATIC_IV: [u8; 16] = [
|
||||||
0x6d, 0x79, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, 0x5f, 0x69, 0x76, 0x5f, 0x30, 0x31, 0x32,
|
0x6d, 0x79, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, 0x5f, 0x69, 0x76, 0x5f, 0x30, 0x31, 0x32,
|
||||||
];
|
];
|
||||||
@@ -27,12 +29,14 @@ pub const STATIC_BYTE_MAP: [u8; 256] = [
|
|||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Returns the SHA-256 digest of `input`.
|
||||||
pub fn hash(input: &[u8]) -> [u8; 32] {
|
pub fn hash(input: &[u8]) -> [u8; 32] {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(input);
|
hasher.update(input);
|
||||||
hasher.finalize().into()
|
hasher.finalize().into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encodes a `usize` as a big-endian byte slice without leading zero bytes.
|
||||||
pub fn encode_usize(value: usize) -> Vec<u8> {
|
pub fn encode_usize(value: usize) -> Vec<u8> {
|
||||||
if value == 0 {
|
if value == 0 {
|
||||||
return vec![0];
|
return vec![0];
|
||||||
@@ -42,6 +46,9 @@ pub fn encode_usize(value: usize) -> Vec<u8> {
|
|||||||
bytes[leading_zeros..].to_vec()
|
bytes[leading_zeros..].to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes a big-endian `usize` previously produced by [`encode_usize`].
|
||||||
|
///
|
||||||
|
/// The caller must ensure `bytes.len() <= size_of::<usize>()`.
|
||||||
pub fn decode_usize(bytes: &[u8]) -> usize {
|
pub fn decode_usize(bytes: &[u8]) -> usize {
|
||||||
let mut buf = [0u8; size_of::<usize>()];
|
let mut buf = [0u8; size_of::<usize>()];
|
||||||
let offset = buf.len() - bytes.len();
|
let offset = buf.len() - bytes.len();
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ pub fn set_logger(logger: &'static dyn Logger) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the currently installed global logger.
|
/// Returns the currently installed global logger.
|
||||||
|
///
|
||||||
|
/// Until [`set_logger`] runs, this returns the internal null sink.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn global_logger() -> &'static dyn Logger {
|
pub fn global_logger() -> &'static dyn Logger {
|
||||||
GLOBAL_LOGGER.get()
|
GLOBAL_LOGGER.get()
|
||||||
@@ -66,6 +68,8 @@ pub fn global_logger() -> &'static dyn Logger {
|
|||||||
/// Sends a single record through the installed global logger.
|
/// Sends a single record through the installed global logger.
|
||||||
///
|
///
|
||||||
/// Most code should prefer the exported logging macros.
|
/// Most code should prefer the exported logging macros.
|
||||||
|
/// This helper exists for integrations that already have a preformatted message
|
||||||
|
/// and explicit source context.
|
||||||
pub fn log(level: LogLevel, message: &str, file: Option<&'static str>, line: Option<u32>) {
|
pub fn log(level: LogLevel, message: &str, file: Option<&'static str>, line: Option<u32>) {
|
||||||
global_logger().log(&Record::new(level, message, file, line));
|
global_logger().log(&Record::new(level, message, file, line));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ impl CompatibilityLogger {
|
|||||||
Self { min_level }
|
Self { min_level }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the minimum severity that passes the filter.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn min_level(&self) -> LogLevel {
|
||||||
|
self.min_level
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether a record at `level` would be accepted.
|
/// Returns whether a record at `level` would be accepted.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
|
|||||||
+17
-4
@@ -51,30 +51,44 @@ pub struct ParsedFrame<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ParsedFrame<'a> {
|
impl<'a> ParsedFrame<'a> {
|
||||||
|
/// Returns the deserialized packet header.
|
||||||
|
///
|
||||||
|
/// The header is owned by `ParsedFrame` because decoding must validate it
|
||||||
|
/// before any routing decision is made.
|
||||||
|
#[must_use]
|
||||||
pub fn header(&self) -> &PacketHeader {
|
pub fn header(&self) -> &PacketHeader {
|
||||||
&self.header
|
&self.header
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the header packet type for quick dispatch.
|
||||||
|
#[must_use]
|
||||||
pub fn packet_type(&self) -> PacketType {
|
pub fn packet_type(&self) -> PacketType {
|
||||||
self.header.packet_type
|
self.header.packet_type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the raw archived payload section.
|
||||||
|
#[must_use]
|
||||||
pub fn payload_bytes(&self) -> &'a [u8] {
|
pub fn payload_bytes(&self) -> &'a [u8] {
|
||||||
self.payload_bytes
|
self.payload_bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clones the decoded header out of the parsed frame.
|
||||||
|
#[must_use]
|
||||||
pub fn deserialize_header(&self) -> PacketHeader {
|
pub fn deserialize_header(&self) -> PacketHeader {
|
||||||
self.header.clone()
|
self.header.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deserializes the payload as a [`CallMessage`].
|
||||||
pub fn deserialize_call(&self) -> Result<CallMessage, FrameError> {
|
pub fn deserialize_call(&self) -> Result<CallMessage, FrameError> {
|
||||||
deserialize_archived_bytes::<ArchivedCallMessage, CallMessage>(self.payload_bytes)
|
deserialize_archived_bytes::<ArchivedCallMessage, CallMessage>(self.payload_bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deserializes the payload as a [`DataMessage`].
|
||||||
pub fn deserialize_data(&self) -> Result<DataMessage, FrameError> {
|
pub fn deserialize_data(&self) -> Result<DataMessage, FrameError> {
|
||||||
deserialize_archived_bytes::<ArchivedDataMessage, DataMessage>(self.payload_bytes)
|
deserialize_archived_bytes::<ArchivedDataMessage, DataMessage>(self.payload_bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deserializes the payload as a [`FaultMessage`].
|
||||||
pub fn deserialize_fault(&self) -> Result<FaultMessage, FrameError> {
|
pub fn deserialize_fault(&self) -> Result<FaultMessage, FrameError> {
|
||||||
deserialize_archived_bytes::<ArchivedFaultMessage, FaultMessage>(self.payload_bytes)
|
deserialize_archived_bytes::<ArchivedFaultMessage, FaultMessage>(self.payload_bytes)
|
||||||
}
|
}
|
||||||
@@ -211,10 +225,9 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn align_section(bytes: &[u8]) -> AlignedVec {
|
fn align_section(bytes: &[u8]) -> AlignedVec {
|
||||||
if bytes.as_ptr().align_offset(16) == 0 {
|
// The framed wire format prefixes each archived section with a 4-byte length,
|
||||||
// Still need to return AlignedVec for the API, but maybe we can avoid
|
// so callers cannot rely on the borrowed slice meeting rkyv's alignment.
|
||||||
// some overhead. Actually, AlignedVec is just a wrapper around Vec.
|
// Copying into `AlignedVec` keeps the alignment fix local and predictable.
|
||||||
}
|
|
||||||
let mut aligned = AlignedVec::with_capacity(bytes.len());
|
let mut aligned = AlignedVec::with_capacity(bytes.len());
|
||||||
aligned.extend_from_slice(bytes);
|
aligned.extend_from_slice(bytes);
|
||||||
aligned
|
aligned
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ pub use types::{
|
|||||||
};
|
};
|
||||||
pub use validation::{ValidationError, validate_call, validate_header, validate_procedure_id};
|
pub use validation::{ValidationError, validate_call, validate_header, validate_procedure_id};
|
||||||
|
|
||||||
|
/// Encodes a header and payload with the crate's default frame codec.
|
||||||
|
///
|
||||||
|
/// This is a convenience wrapper around [`RkyvCodec`] for callers that do not
|
||||||
|
/// need to choose a codec explicitly.
|
||||||
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
|
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
|
||||||
where
|
where
|
||||||
P: for<'a> rkyv::Serialize<
|
P: for<'a> rkyv::Serialize<
|
||||||
@@ -35,6 +39,7 @@ where
|
|||||||
codec::encode_packet(header, payload)
|
codec::encode_packet(header, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes a framed packet with the crate's default frame codec.
|
||||||
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
|
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
|
||||||
codec::decode_frame(bytes)
|
codec::decode_frame(bytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,31 @@ impl<T> RouteResolution for T where T: RouteProvider + ?Sized {}
|
|||||||
|
|
||||||
/// Hook storage contract for pending and active protocol flows.
|
/// Hook storage contract for pending and active protocol flows.
|
||||||
pub trait HookStore {
|
pub trait HookStore {
|
||||||
|
/// Allocates a hook identifier scoped to `return_path`.
|
||||||
fn allocate_hook_id(&mut self, return_path: &[String]) -> u64;
|
fn allocate_hook_id(&mut self, return_path: &[String]) -> u64;
|
||||||
|
|
||||||
|
/// Inserts a hook created by an incoming call before the peer is confirmed.
|
||||||
fn insert_pending(&mut self, pending: PendingHook) -> Result<(), HookConflict>;
|
fn insert_pending(&mut self, pending: PendingHook) -> Result<(), HookConflict>;
|
||||||
|
|
||||||
|
/// Inserts an already-established hook flow.
|
||||||
fn insert_active(&mut self, active: ActiveHook) -> Result<(), HookConflict>;
|
fn insert_active(&mut self, active: ActiveHook) -> Result<(), HookConflict>;
|
||||||
|
|
||||||
|
/// Promotes a pending hook once the responding peer is known.
|
||||||
fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()>;
|
fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()>;
|
||||||
|
|
||||||
|
/// Removes a pending hook.
|
||||||
fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook>;
|
fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook>;
|
||||||
|
|
||||||
|
/// Removes an active hook.
|
||||||
fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook>;
|
fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook>;
|
||||||
|
|
||||||
|
/// Returns immutable access to a pending hook.
|
||||||
fn pending(&self, key: &HookKey) -> Option<&PendingHook>;
|
fn pending(&self, key: &HookKey) -> Option<&PendingHook>;
|
||||||
|
|
||||||
|
/// Returns immutable access to an active hook.
|
||||||
fn active(&self, key: &HookKey) -> Option<&ActiveHook>;
|
fn active(&self, key: &HookKey) -> Option<&ActiveHook>;
|
||||||
|
|
||||||
|
/// Returns mutable access to an active hook.
|
||||||
fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook>;
|
fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +93,13 @@ impl HookStore for HookTable {
|
|||||||
|
|
||||||
/// Leaf metadata contract used for protocol discovery payloads.
|
/// Leaf metadata contract used for protocol discovery payloads.
|
||||||
pub trait LeafMetadata {
|
pub trait LeafMetadata {
|
||||||
|
/// Returns the leaf name exposed in routing and introspection.
|
||||||
fn leaf_name(&self) -> &str;
|
fn leaf_name(&self) -> &str;
|
||||||
|
|
||||||
|
/// Returns the supported canonical procedure identifiers.
|
||||||
fn procedures(&self) -> &[String];
|
fn procedures(&self) -> &[String];
|
||||||
|
|
||||||
|
/// Builds the compact endpoint-wide discovery record for this leaf.
|
||||||
fn summary(&self) -> LeafIntrospectionSummary {
|
fn summary(&self) -> LeafIntrospectionSummary {
|
||||||
LeafIntrospectionSummary {
|
LeafIntrospectionSummary {
|
||||||
leaf_name: self.leaf_name().into(),
|
leaf_name: self.leaf_name().into(),
|
||||||
@@ -86,6 +107,7 @@ pub trait LeafMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the full leaf-specific discovery payload.
|
||||||
fn introspection(&self) -> LeafIntrospection {
|
fn introspection(&self) -> LeafIntrospection {
|
||||||
LeafIntrospection {
|
LeafIntrospection {
|
||||||
leaf_name: self.leaf_name().into(),
|
leaf_name: self.leaf_name().into(),
|
||||||
@@ -116,7 +138,10 @@ impl LeafMetadata for LeafNode {
|
|||||||
|
|
||||||
/// Packet processor and local runtime contract for framed protocol traffic.
|
/// Packet processor and local runtime contract for framed protocol traffic.
|
||||||
pub trait PacketProcessor {
|
pub trait PacketProcessor {
|
||||||
|
/// Returns the endpoint path that owns this processor.
|
||||||
fn path(&self) -> &[String];
|
fn path(&self) -> &[String];
|
||||||
|
|
||||||
|
/// Receives one framed packet from the given ingress side.
|
||||||
fn receive(
|
fn receive(
|
||||||
&mut self,
|
&mut self,
|
||||||
ingress: &Ingress,
|
ingress: &Ingress,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ impl ProtocolEndpoint {
|
|||||||
/// let endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
|
/// let endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
|
||||||
/// assert!(endpoint.path().is_empty());
|
/// assert!(endpoint.path().is_empty());
|
||||||
/// ```
|
/// ```
|
||||||
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
path: Vec<String>,
|
path: Vec<String>,
|
||||||
parent_path: Option<Vec<String>>,
|
parent_path: Option<Vec<String>>,
|
||||||
@@ -54,6 +55,7 @@ impl ProtocolEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Allocates a locally unique hook id.
|
/// Allocates a locally unique hook id.
|
||||||
|
#[must_use]
|
||||||
pub fn allocate_hook_id(&mut self) -> u64 {
|
pub fn allocate_hook_id(&mut self) -> u64 {
|
||||||
self.hooks.allocate_hook_id(&self.path)
|
self.hooks.allocate_hook_id(&self.path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,19 +21,24 @@ use super::super::{HookTable, RouteDecision};
|
|||||||
/// Local connection state used for child route eligibility.
|
/// Local connection state used for child route eligibility.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ConnectionState {
|
pub enum ConnectionState {
|
||||||
|
/// The child exists in the static topology but is not currently routable.
|
||||||
Unregistered,
|
Unregistered,
|
||||||
|
/// The child may receive routed traffic.
|
||||||
Registered,
|
Registered,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Child path plus current registration state.
|
/// Child path plus current registration state.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ChildRoute {
|
pub struct ChildRoute {
|
||||||
|
/// Absolute child endpoint path.
|
||||||
pub path: Vec<String>,
|
pub path: Vec<String>,
|
||||||
|
/// Whether the child currently participates in routing.
|
||||||
pub state: ConnectionState,
|
pub state: ConnectionState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChildRoute {
|
impl ChildRoute {
|
||||||
/// Convenience constructor for the common registered-child case.
|
/// Convenience constructor for the common registered-child case.
|
||||||
|
#[must_use]
|
||||||
pub fn registered(path: Vec<String>) -> Self {
|
pub fn registered(path: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
path,
|
path,
|
||||||
@@ -45,14 +50,18 @@ impl ChildRoute {
|
|||||||
/// Test leaf behavior implemented by the endpoint runtime.
|
/// Test leaf behavior implemented by the endpoint runtime.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum LeafBehavior {
|
pub enum LeafBehavior {
|
||||||
|
/// Mirrors the incoming payload back over the declared response hook.
|
||||||
Echo,
|
Echo,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static leaf metadata used for procedure dispatch and introspection.
|
/// Static leaf metadata used for procedure dispatch and introspection.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct LeafSpec {
|
pub struct LeafSpec {
|
||||||
|
/// Stable local leaf name.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Procedures supported by the leaf.
|
||||||
pub procedures: Vec<String>,
|
pub procedures: Vec<String>,
|
||||||
|
/// Built-in behavior used by the lightweight test runtime.
|
||||||
pub behavior: LeafBehavior,
|
pub behavior: LeafBehavior,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,22 +71,28 @@ pub struct LeafSpec {
|
|||||||
/// `PROTOCOL.md` routing and call sections.
|
/// `PROTOCOL.md` routing and call sections.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Ingress {
|
pub enum Ingress {
|
||||||
|
/// Received from the parent link.
|
||||||
Parent,
|
Parent,
|
||||||
|
/// Received from the child at the given absolute path.
|
||||||
Child(Vec<String>),
|
Child(Vec<String>),
|
||||||
|
/// Injected locally by code running on this endpoint.
|
||||||
Local,
|
Local,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locally delivered protocol events.
|
/// Locally delivered protocol events.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum LocalEvent {
|
pub enum LocalEvent {
|
||||||
|
/// A call reached this endpoint runtime.
|
||||||
Call {
|
Call {
|
||||||
header: PacketHeader,
|
header: PacketHeader,
|
||||||
message: CallMessage,
|
message: CallMessage,
|
||||||
},
|
},
|
||||||
|
/// Hook data reached this endpoint runtime.
|
||||||
Data {
|
Data {
|
||||||
header: PacketHeader,
|
header: PacketHeader,
|
||||||
message: DataMessage,
|
message: DataMessage,
|
||||||
},
|
},
|
||||||
|
/// A protocol fault reached this endpoint runtime.
|
||||||
Fault {
|
Fault {
|
||||||
header: PacketHeader,
|
header: PacketHeader,
|
||||||
message: FaultMessage,
|
message: FaultMessage,
|
||||||
@@ -87,15 +102,20 @@ pub enum LocalEvent {
|
|||||||
/// Result of processing one framed packet.
|
/// Result of processing one framed packet.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct EndpointOutcome {
|
pub struct EndpointOutcome {
|
||||||
|
/// Forwarding actions to perform after local processing.
|
||||||
pub forwards: Vec<(RouteDecision, FrameBytes)>,
|
pub forwards: Vec<(RouteDecision, FrameBytes)>,
|
||||||
|
/// Events delivered to local runtime consumers.
|
||||||
pub events: Vec<LocalEvent>,
|
pub events: Vec<LocalEvent>,
|
||||||
|
/// Whether the packet was intentionally dropped with no other side effects.
|
||||||
pub dropped: bool,
|
pub dropped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors returned while decoding or validating a packet.
|
/// Errors returned while decoding or validating a packet.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum EndpointError {
|
pub enum EndpointError {
|
||||||
|
/// The frame could not be decoded.
|
||||||
Frame(FrameError),
|
Frame(FrameError),
|
||||||
|
/// The decoded packet violated protocol invariants.
|
||||||
Validation(ValidationError),
|
Validation(ValidationError),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +144,10 @@ impl From<ValidationError> for EndpointError {
|
|||||||
|
|
||||||
/// Public packet-processing trait exposed by the tree runtime.
|
/// Public packet-processing trait exposed by the tree runtime.
|
||||||
pub trait Endpoint {
|
pub trait Endpoint {
|
||||||
|
/// Returns the absolute endpoint path.
|
||||||
fn path(&self) -> &[String];
|
fn path(&self) -> &[String];
|
||||||
|
|
||||||
|
/// Processes one incoming frame from the given ingress side.
|
||||||
fn receive(
|
fn receive(
|
||||||
&mut self,
|
&mut self,
|
||||||
ingress: &Ingress,
|
ingress: &Ingress,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ impl ProtocolEndpoint {
|
|||||||
message: DataMessage,
|
message: DataMessage,
|
||||||
) -> Result<EndpointOutcome, EndpointError> {
|
) -> Result<EndpointOutcome, EndpointError> {
|
||||||
let hook_id = header.hook_id.expect("validated");
|
let hook_id = header.hook_id.expect("validated");
|
||||||
|
// The hook host can address its hook directly with `self.path + hook_id`.
|
||||||
|
// A non-host peer only knows the hook id it was given earlier, so it must
|
||||||
|
// recover the host-scoped key from active state using its validated path.
|
||||||
let key = self
|
let key = self
|
||||||
.hooks
|
.hooks
|
||||||
.active(&HookKey::new(self.path.clone(), hook_id))
|
.active(&HookKey::new(self.path.clone(), hook_id))
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub struct HookKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HookKey {
|
impl HookKey {
|
||||||
|
/// Creates a host-scoped key from the return path and hook identifier.
|
||||||
|
#[must_use]
|
||||||
pub fn new(return_path: Vec<String>, hook_id: u64) -> Self {
|
pub fn new(return_path: Vec<String>, hook_id: u64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
return_path,
|
return_path,
|
||||||
@@ -64,12 +66,18 @@ impl Default for HookTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HookTable {
|
impl HookTable {
|
||||||
|
/// Allocates the next locally unique hook identifier.
|
||||||
|
///
|
||||||
|
/// Hook IDs are scoped by return path, so this counter only needs to be
|
||||||
|
/// unique within one endpoint runtime.
|
||||||
|
#[must_use]
|
||||||
pub fn allocate_hook_id(&mut self, _return_path: &[String]) -> u64 {
|
pub fn allocate_hook_id(&mut self, _return_path: &[String]) -> u64 {
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id = self.next_id.wrapping_add(1);
|
self.next_id = self.next_id.wrapping_add(1);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserts a pending hook created by an inbound call.
|
||||||
pub fn insert_pending(&mut self, pending: PendingHook) -> Result<(), HookConflict> {
|
pub fn insert_pending(&mut self, pending: PendingHook) -> Result<(), HookConflict> {
|
||||||
let key = HookKey::new(pending.return_path.clone(), pending.hook_id);
|
let key = HookKey::new(pending.return_path.clone(), pending.hook_id);
|
||||||
if self.pending.contains_key(&key) || self.active.contains_key(&key) {
|
if self.pending.contains_key(&key) || self.active.contains_key(&key) {
|
||||||
@@ -79,6 +87,7 @@ impl HookTable {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserts an already-active hook flow.
|
||||||
pub fn insert_active(&mut self, active: ActiveHook) -> Result<(), HookConflict> {
|
pub fn insert_active(&mut self, active: ActiveHook) -> Result<(), HookConflict> {
|
||||||
let key = HookKey::new(active.return_path.clone(), active.hook_id);
|
let key = HookKey::new(active.return_path.clone(), active.hook_id);
|
||||||
if self.pending.contains_key(&key) || self.active.contains_key(&key) {
|
if self.pending.contains_key(&key) || self.active.contains_key(&key) {
|
||||||
@@ -88,6 +97,7 @@ impl HookTable {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Promotes a pending hook into the active table once its peer is known.
|
||||||
pub fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()> {
|
pub fn activate_pending(&mut self, key: &HookKey, peer_path: Vec<String>) -> Option<()> {
|
||||||
let pending = self.pending.remove(key)?;
|
let pending = self.pending.remove(key)?;
|
||||||
self.active.insert(
|
self.active.insert(
|
||||||
@@ -104,22 +114,29 @@ impl HookTable {
|
|||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes a pending hook entry.
|
||||||
pub fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook> {
|
pub fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook> {
|
||||||
self.pending.remove(key)
|
self.pending.remove(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes an active hook entry.
|
||||||
pub fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook> {
|
pub fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook> {
|
||||||
self.active.remove(key)
|
self.active.remove(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a pending hook by its host-scoped key.
|
||||||
|
#[must_use]
|
||||||
pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
|
pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
|
||||||
self.pending.get(key)
|
self.pending.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns an active hook by its host-scoped key.
|
||||||
|
#[must_use]
|
||||||
pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
|
pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
|
||||||
self.active.get(key)
|
self.active.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns mutable access to an active hook by its host-scoped key.
|
||||||
pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
|
pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
|
||||||
self.active.get_mut(key)
|
self.active.get_mut(key)
|
||||||
}
|
}
|
||||||
@@ -130,23 +147,27 @@ impl HookTable {
|
|||||||
/// cannot derive the full key from the packet header alone. The peer uses
|
/// cannot derive the full key from the packet header alone. The peer uses
|
||||||
/// its already-validated active state to recover the host-scoped key.
|
/// its already-validated active state to recover the host-scoped key.
|
||||||
pub fn find_active_key_by_peer(&self, hook_id: u64, peer_path: &[String]) -> Option<HookKey> {
|
pub fn find_active_key_by_peer(&self, hook_id: u64, peer_path: &[String]) -> Option<HookKey> {
|
||||||
let mut matches = self
|
let mut matching_keys = self
|
||||||
.active
|
.active
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_key, active)| active.hook_id == hook_id && active.peer_path == peer_path)
|
.filter(|(_key, active)| active.hook_id == hook_id && active.peer_path == peer_path)
|
||||||
.map(|(key, _)| key.clone());
|
.map(|(key, _)| key.clone());
|
||||||
|
|
||||||
let first = matches.next()?;
|
let key = matching_keys.next()?;
|
||||||
if matches.next().is_some() {
|
if matching_keys.next().is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(first)
|
Some(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the number of pending hooks.
|
||||||
|
#[must_use]
|
||||||
pub fn pending_len(&self) -> usize {
|
pub fn pending_len(&self) -> usize {
|
||||||
self.pending.len()
|
self.pending.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the number of active hooks.
|
||||||
|
#[must_use]
|
||||||
pub fn active_len(&self) -> usize {
|
pub fn active_len(&self) -> usize {
|
||||||
self.active.len()
|
self.active.len()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ impl fmt::Display for ValidationError {
|
|||||||
impl core::error::Error for ValidationError {}
|
impl core::error::Error for ValidationError {}
|
||||||
|
|
||||||
/// Validates packet header invariants from the protocol.
|
/// Validates packet header invariants from the protocol.
|
||||||
|
///
|
||||||
|
/// This checks only the header fields themselves. Payload-dependent rules belong
|
||||||
|
/// in helpers such as [`validate_call`].
|
||||||
pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
|
pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
|
||||||
match header.packet_type {
|
match header.packet_type {
|
||||||
PacketType::Call => {
|
PacketType::Call => {
|
||||||
|
|||||||
+123
-120
@@ -8,6 +8,19 @@
|
|||||||
use super::{App, AppError, NodeId, Selection};
|
use super::{App, AppError, NodeId, Selection};
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
/// Drains queued simulator work, refreshes the visible selection list, and
|
||||||
|
/// reports the action result in the footer.
|
||||||
|
fn finish_action(
|
||||||
|
&mut self,
|
||||||
|
preferred_node: Option<NodeId>,
|
||||||
|
label: impl Into<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let steps = self.simulation.drain()?;
|
||||||
|
self.refresh_selections(preferred_node);
|
||||||
|
self.status = format!("{} ({steps} steps)", label.into());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs protocol introspection for the current selection.
|
/// Performs protocol introspection for the current selection.
|
||||||
///
|
///
|
||||||
/// Rationale: node and leaf introspection share one key because the protocol
|
/// Rationale: node and leaf introspection share one key because the protocol
|
||||||
@@ -18,18 +31,14 @@ impl App {
|
|||||||
// Route the blank procedure to endpoint-wide introspection.
|
// Route the blank procedure to endpoint-wide introspection.
|
||||||
let result = self.simulation.call_endpoint_introspection(node_id)?;
|
let result = self.simulation.call_endpoint_introspection(node_id)?;
|
||||||
// Drain immediately so the inspector reflects the learned state.
|
// Drain immediately so the inspector reflects the learned state.
|
||||||
let steps = self.simulation.drain()?;
|
self.finish_action(Some(node_id), result.label)?;
|
||||||
self.refresh_selections(Some(node_id));
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
}
|
}
|
||||||
Selection::Leaf { node_id, leaf_name } => {
|
Selection::Leaf { node_id, leaf_name } => {
|
||||||
// Route the blank procedure to one specific leaf.
|
// Route the blank procedure to one specific leaf.
|
||||||
let result = self
|
let result = self
|
||||||
.simulation
|
.simulation
|
||||||
.call_leaf_introspection(node_id, &leaf_name)?;
|
.call_leaf_introspection(node_id, &leaf_name)?;
|
||||||
let steps = self.simulation.drain()?;
|
self.finish_action(Some(node_id), result.label)?;
|
||||||
self.refresh_selections(Some(node_id));
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -40,102 +49,99 @@ impl App {
|
|||||||
/// Rationale: the payload is fixed so the demo highlights packet flow rather
|
/// Rationale: the payload is fixed so the demo highlights packet flow rather
|
||||||
/// than turning the TUI into a line editor.
|
/// than turning the TUI into a line editor.
|
||||||
pub(super) fn perform_echo(&mut self) -> Result<(), AppError> {
|
pub(super) fn perform_echo(&mut self) -> Result<(), AppError> {
|
||||||
if let Selection::Leaf { node_id, leaf_name } = self.selected().clone() {
|
let Selection::Leaf { node_id, leaf_name } = self.selected().clone() else {
|
||||||
let result =
|
|
||||||
self.simulation
|
|
||||||
.call_echo_leaf(node_id, &leaf_name, "demo echo from root")?;
|
|
||||||
let steps = self.simulation.drain()?;
|
|
||||||
self.refresh_selections(Some(node_id));
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
} else {
|
|
||||||
self.status = "Select a leaf first, then press e.".to_owned();
|
self.status = "Select a leaf first, then press e.".to_owned();
|
||||||
}
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.simulation
|
||||||
|
.call_echo_leaf(node_id, &leaf_name, "demo echo from root")?;
|
||||||
|
self.finish_action(Some(node_id), result.label)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calls the first endpoint-level procedure on the selected node.
|
/// Calls the first endpoint-level procedure on the selected node.
|
||||||
pub(super) fn perform_ping(&mut self) -> Result<(), AppError> {
|
pub(super) fn perform_ping(&mut self) -> Result<(), AppError> {
|
||||||
if let Selection::Node(node_id) = self.selected().clone() {
|
let Selection::Node(node_id) = self.selected().clone() else {
|
||||||
if let Some(procedure_id) = self
|
|
||||||
.simulation
|
|
||||||
.node(node_id)
|
|
||||||
.endpoint_procedures
|
|
||||||
.first()
|
|
||||||
.map(|procedure| procedure.procedure_id.clone())
|
|
||||||
{
|
|
||||||
let result = self.simulation.call_endpoint_procedure(
|
|
||||||
node_id,
|
|
||||||
&procedure_id,
|
|
||||||
b"ping".to_vec(),
|
|
||||||
)?;
|
|
||||||
let steps = self.simulation.drain()?;
|
|
||||||
self.refresh_selections(Some(node_id));
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
} else {
|
|
||||||
self.status = "Selected node has no endpoint procedures.".to_owned();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.status = "Select a node first, then press p.".to_owned();
|
self.status = "Select a node first, then press p.".to_owned();
|
||||||
}
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(procedure_id) = self
|
||||||
|
.simulation
|
||||||
|
.node(node_id)
|
||||||
|
.endpoint_procedures
|
||||||
|
.first()
|
||||||
|
.map(|procedure| procedure.procedure_id.clone())
|
||||||
|
else {
|
||||||
|
self.status = "Selected node has no endpoint procedures.".to_owned();
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
self.simulation
|
||||||
|
.call_endpoint_procedure(node_id, &procedure_id, b"ping".to_vec())?;
|
||||||
|
self.finish_action(Some(node_id), result.label)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calls the chunked-response procedure on the selected node.
|
/// Calls the chunked-response procedure on the selected node.
|
||||||
pub(super) fn perform_chunked(&mut self) -> Result<(), AppError> {
|
pub(super) fn perform_chunked(&mut self) -> Result<(), AppError> {
|
||||||
if let Selection::Node(node_id) = self.selected().clone() {
|
let Selection::Node(node_id) = self.selected().clone() else {
|
||||||
if let Some(procedure_id) = self
|
|
||||||
.simulation
|
|
||||||
.node(node_id)
|
|
||||||
.endpoint_procedures
|
|
||||||
.iter()
|
|
||||||
.find(|procedure| {
|
|
||||||
procedure.description.contains("chunk")
|
|
||||||
|| procedure.procedure_id.contains("chunked")
|
|
||||||
})
|
|
||||||
.map(|procedure| procedure.procedure_id.clone())
|
|
||||||
{
|
|
||||||
let result = self.simulation.call_endpoint_procedure(
|
|
||||||
node_id,
|
|
||||||
&procedure_id,
|
|
||||||
b"chunk please".to_vec(),
|
|
||||||
)?;
|
|
||||||
let steps = self.simulation.drain()?;
|
|
||||||
self.refresh_selections(Some(node_id));
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
} else {
|
|
||||||
self.status = "Selected node has no chunked procedure.".to_owned();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.status = "Select a node first, then press c.".to_owned();
|
self.status = "Select a node first, then press c.".to_owned();
|
||||||
}
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(procedure_id) = self
|
||||||
|
.simulation
|
||||||
|
.node(node_id)
|
||||||
|
.endpoint_procedures
|
||||||
|
.iter()
|
||||||
|
.find(|procedure| {
|
||||||
|
procedure.description.contains("chunk")
|
||||||
|
|| procedure.procedure_id.contains("chunked")
|
||||||
|
})
|
||||||
|
.map(|procedure| procedure.procedure_id.clone())
|
||||||
|
else {
|
||||||
|
self.status = "Selected node has no chunked procedure.".to_owned();
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = self.simulation.call_endpoint_procedure(
|
||||||
|
node_id,
|
||||||
|
&procedure_id,
|
||||||
|
b"chunk please".to_vec(),
|
||||||
|
)?;
|
||||||
|
self.finish_action(Some(node_id), result.label)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a long-lived chat hook on the selected node.
|
/// Opens a long-lived chat hook on the selected node.
|
||||||
pub(super) fn perform_chat_call(&mut self) -> Result<(), AppError> {
|
pub(super) fn perform_chat_call(&mut self) -> Result<(), AppError> {
|
||||||
if let Selection::Node(node_id) = self.selected().clone() {
|
let Selection::Node(node_id) = self.selected().clone() else {
|
||||||
if let Some(procedure_id) = self
|
|
||||||
.simulation
|
|
||||||
.node(node_id)
|
|
||||||
.endpoint_procedures
|
|
||||||
.iter()
|
|
||||||
.find(|procedure| procedure.procedure_id.contains("chat"))
|
|
||||||
.map(|procedure| procedure.procedure_id.clone())
|
|
||||||
{
|
|
||||||
let result = self.simulation.call_endpoint_procedure(
|
|
||||||
node_id,
|
|
||||||
&procedure_id,
|
|
||||||
b"open chat".to_vec(),
|
|
||||||
)?;
|
|
||||||
let steps = self.simulation.drain()?;
|
|
||||||
self.refresh_selections(Some(node_id));
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
} else {
|
|
||||||
self.status = "Selected node has no chat procedure.".to_owned();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.status = "Select a node first, then press h.".to_owned();
|
self.status = "Select a node first, then press h.".to_owned();
|
||||||
}
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(procedure_id) = self
|
||||||
|
.simulation
|
||||||
|
.node(node_id)
|
||||||
|
.endpoint_procedures
|
||||||
|
.iter()
|
||||||
|
.find(|procedure| procedure.procedure_id.contains("chat"))
|
||||||
|
.map(|procedure| procedure.procedure_id.clone())
|
||||||
|
else {
|
||||||
|
self.status = "Selected node has no chat procedure.".to_owned();
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = self.simulation.call_endpoint_procedure(
|
||||||
|
node_id,
|
||||||
|
&procedure_id,
|
||||||
|
b"open chat".to_vec(),
|
||||||
|
)?;
|
||||||
|
self.finish_action(Some(node_id), result.label)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,57 +150,54 @@ impl App {
|
|||||||
/// Rationale: using the latest hook keeps the demo simple while still
|
/// Rationale: using the latest hook keeps the demo simple while still
|
||||||
/// exposing bidirectional hook behavior.
|
/// exposing bidirectional hook behavior.
|
||||||
pub(super) fn perform_chat_data(&mut self) -> Result<(), AppError> {
|
pub(super) fn perform_chat_data(&mut self) -> Result<(), AppError> {
|
||||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
let Some(hook_id) = self.simulation.hook_ids().last().copied() else {
|
||||||
let result =
|
|
||||||
self.simulation
|
|
||||||
.send_root_hook_data(hook_id, "hello from the root", false)?;
|
|
||||||
let steps = self.simulation.drain()?;
|
|
||||||
self.refresh_selections(None);
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
} else {
|
|
||||||
self.status = "No known hook yet. Press h to open chat first.".to_owned();
|
self.status = "No known hook yet. Press h to open chat first.".to_owned();
|
||||||
}
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.simulation
|
||||||
|
.send_root_hook_data(hook_id, "hello from the root", false)?;
|
||||||
|
self.finish_action(None, result.label)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the newest known chat hook from the root side.
|
/// Ends the newest known chat hook from the root side.
|
||||||
pub(super) fn perform_chat_bye(&mut self) -> Result<(), AppError> {
|
pub(super) fn perform_chat_bye(&mut self) -> Result<(), AppError> {
|
||||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
let Some(hook_id) = self.simulation.hook_ids().last().copied() else {
|
||||||
let result = self.simulation.send_root_hook_data(hook_id, "bye", true)?;
|
|
||||||
let steps = self.simulation.drain()?;
|
|
||||||
self.refresh_selections(None);
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
} else {
|
|
||||||
self.status = "No known hook yet. Press h to open chat first.".to_owned();
|
self.status = "No known hook yet. Press h to open chat first.".to_owned();
|
||||||
}
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = self.simulation.send_root_hook_data(hook_id, "bye", true)?;
|
||||||
|
self.finish_action(None, result.label)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Injects intentionally invalid hook data to exercise fault handling.
|
/// Injects intentionally invalid hook data to exercise fault handling.
|
||||||
pub(super) fn perform_invalid_fault_demo(&mut self) -> Result<(), AppError> {
|
pub(super) fn perform_invalid_fault_demo(&mut self) -> Result<(), AppError> {
|
||||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
let Some(hook_id) = self.simulation.hook_ids().last().copied() else {
|
||||||
// The root is always node zero in every built-in scenario.
|
|
||||||
let root_id = NodeId(0);
|
|
||||||
if self.simulation.tree.nodes.len() > 1 {
|
|
||||||
// The first child is enough to spoof a wrong peer path.
|
|
||||||
let attacker = NodeId(1);
|
|
||||||
let result = self.simulation.inject_invalid_peer_data(
|
|
||||||
attacker,
|
|
||||||
root_id,
|
|
||||||
hook_id,
|
|
||||||
"demo.endpoint.v1.chat.session",
|
|
||||||
"spoofed data",
|
|
||||||
)?;
|
|
||||||
let steps = self.simulation.drain()?;
|
|
||||||
self.refresh_selections(None);
|
|
||||||
self.status = format!("{} ({steps} steps)", result.label);
|
|
||||||
} else {
|
|
||||||
self.status =
|
|
||||||
"This scenario has no second node for invalid-peer traffic.".to_owned();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.status = "Open a hook first before injecting invalid traffic.".to_owned();
|
self.status = "Open a hook first before injecting invalid traffic.".to_owned();
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.simulation.tree.nodes.len() <= 1 {
|
||||||
|
self.status = "This scenario has no second node for invalid-peer traffic.".to_owned();
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The root is always node zero in every built-in scenario.
|
||||||
|
let root_id = NodeId(0);
|
||||||
|
// The first child is enough to spoof a wrong peer path.
|
||||||
|
let attacker = NodeId(1);
|
||||||
|
let result = self.simulation.inject_invalid_peer_data(
|
||||||
|
attacker,
|
||||||
|
root_id,
|
||||||
|
hook_id,
|
||||||
|
"demo.endpoint.v1.chat.session",
|
||||||
|
"spoofed data",
|
||||||
|
)?;
|
||||||
|
self.finish_action(None, result.label)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ mod ui;
|
|||||||
use ratatui::DefaultTerminal;
|
use ratatui::DefaultTerminal;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{NodeId, Selection},
|
model::{NodeId, ScenarioDefinition, Selection},
|
||||||
scenarios::built_in_scenarios,
|
scenarios::built_in_scenarios,
|
||||||
sim::Simulation,
|
sim::Simulation,
|
||||||
};
|
};
|
||||||
@@ -19,8 +19,10 @@ use crate::{
|
|||||||
/// Errors returned by the TUI application.
|
/// Errors returned by the TUI application.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
|
/// Terminal setup, teardown, or input/output failed.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
/// The simulator rejected an operation or could not advance.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Sim(#[from] crate::sim::SimError),
|
Sim(#[from] crate::sim::SimError),
|
||||||
}
|
}
|
||||||
@@ -32,7 +34,7 @@ pub fn run() -> Result<(), AppError> {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct App {
|
struct App {
|
||||||
scenarios: Vec<crate::model::ScenarioDefinition>,
|
scenarios: Vec<ScenarioDefinition>,
|
||||||
scenario_index: usize,
|
scenario_index: usize,
|
||||||
simulation: Simulation,
|
simulation: Simulation,
|
||||||
selection_index: usize,
|
selection_index: usize,
|
||||||
|
|||||||
@@ -68,11 +68,19 @@ impl App {
|
|||||||
terminal.draw(|frame| self.render(frame))?;
|
terminal.draw(|frame| self.render(frame))?;
|
||||||
|
|
||||||
// Poll with a timeout so redraws stay responsive without busy-spinning.
|
// Poll with a timeout so redraws stay responsive without busy-spinning.
|
||||||
if event::poll(Duration::from_millis(100))?
|
if !event::poll(Duration::from_millis(100))? {
|
||||||
&& let Event::Key(key) = event::read()?
|
continue;
|
||||||
&& key.kind == KeyEventKind::Press
|
}
|
||||||
&& !self.handle_key(key.code)?
|
|
||||||
{
|
let Event::Key(key) = event::read()? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.handle_key(key.code)? {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,14 +184,14 @@ impl App {
|
|||||||
/// so selection repair needs to happen in one dedicated place.
|
/// so selection repair needs to happen in one dedicated place.
|
||||||
pub(super) fn refresh_selections(&mut self, preferred_node: Option<NodeId>) {
|
pub(super) fn refresh_selections(&mut self, preferred_node: Option<NodeId>) {
|
||||||
// Prefer an explicit node if the caller knows what should stay selected.
|
// Prefer an explicit node if the caller knows what should stay selected.
|
||||||
let current = preferred_node.unwrap_or_else(|| self.selected().node_id());
|
let selected_node_id = preferred_node.unwrap_or_else(|| self.selected().node_id());
|
||||||
self.selections = ui::build_selections(&self.simulation);
|
self.selections = ui::build_selections(&self.simulation);
|
||||||
|
|
||||||
// Fall back to the first row when the previous node disappeared.
|
// Fall back to the first row when the previous node disappeared.
|
||||||
self.selection_index = self
|
self.selection_index = self
|
||||||
.selections
|
.selections
|
||||||
.iter()
|
.iter()
|
||||||
.position(|selection| selection.node_id() == current)
|
.position(|selection| selection.node_id() == selected_node_id)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ impl App {
|
|||||||
pub(crate) fn render(&self, frame: &mut Frame<'_>) {
|
pub(crate) fn render(&self, frame: &mut Frame<'_>) {
|
||||||
// Split the screen into a small header, a large working area, and a
|
// Split the screen into a small header, a large working area, and a
|
||||||
// persistent status/footer region.
|
// persistent status/footer region.
|
||||||
let chunks = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
@@ -29,9 +29,9 @@ impl App {
|
|||||||
])
|
])
|
||||||
.split(frame.area());
|
.split(frame.area());
|
||||||
|
|
||||||
self.render_header(frame, chunks[0]);
|
self.render_header(frame, rows[0]);
|
||||||
self.render_body(frame, chunks[1]);
|
self.render_body(frame, rows[1]);
|
||||||
self.render_footer(frame, chunks[2]);
|
self.render_footer(frame, rows[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the scenario header bar.
|
/// Renders the scenario header bar.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ impl App {
|
|||||||
/// while ground-truth mode intentionally exposes the entire scenario tree.
|
/// while ground-truth mode intentionally exposes the entire scenario tree.
|
||||||
pub(crate) fn build_selections(simulation: &Simulation) -> Vec<Selection> {
|
pub(crate) fn build_selections(simulation: &Simulation) -> Vec<Selection> {
|
||||||
let mut selections = Vec::new();
|
let mut selections = Vec::new();
|
||||||
let node_ids: Vec<_> = match simulation.inspector_mode {
|
let visible_node_ids: Vec<_> = match simulation.inspector_mode {
|
||||||
InspectorMode::GroundTruth => simulation.tree.nodes.iter().map(|node| node.id).collect(),
|
InspectorMode::GroundTruth => simulation.tree.nodes.iter().map(|node| node.id).collect(),
|
||||||
InspectorMode::Realistic => simulation
|
InspectorMode::Realistic => simulation
|
||||||
.root_knowledge
|
.root_knowledge
|
||||||
@@ -70,7 +70,7 @@ pub(crate) fn build_selections(simulation: &Simulation) -> Vec<Selection> {
|
|||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for node_id in node_ids {
|
for node_id in visible_node_ids {
|
||||||
let node = simulation.node(node_id);
|
let node = simulation.node(node_id);
|
||||||
selections.push(Selection::Node(node.id));
|
selections.push(Selection::Node(node.id));
|
||||||
match simulation.inspector_mode {
|
match simulation.inspector_mode {
|
||||||
|
|||||||
@@ -13,40 +13,56 @@ use crate::model::EndpointProcedureSpec;
|
|||||||
/// Root inspector mode.
|
/// Root inspector mode.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum InspectorMode {
|
pub enum InspectorMode {
|
||||||
|
/// Render the full scenario definition, including information the root has
|
||||||
|
/// not yet learned through traffic or introspection.
|
||||||
GroundTruth,
|
GroundTruth,
|
||||||
|
/// Render only the subset of state the root host could plausibly know.
|
||||||
Realistic,
|
Realistic,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Learned procedure metadata stored by the root host.
|
/// Learned procedure metadata stored by the root host.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct LearnedProcedure {
|
pub struct LearnedProcedure {
|
||||||
|
/// Stable protocol identifier for the learned procedure.
|
||||||
pub procedure_id: String,
|
pub procedure_id: String,
|
||||||
|
/// Optional human-readable description learned from config or introspection.
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Learned leaf metadata stored by the root host.
|
/// Learned leaf metadata stored by the root host.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct LearnedLeaf {
|
pub struct LearnedLeaf {
|
||||||
|
/// Leaf name relative to the endpoint path.
|
||||||
pub leaf_name: String,
|
pub leaf_name: String,
|
||||||
|
/// Optional human-readable description for the leaf.
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Procedures currently known on the leaf.
|
||||||
pub procedures: Vec<LearnedProcedure>,
|
pub procedures: Vec<LearnedProcedure>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Learned endpoint metadata stored by the root host.
|
/// Learned endpoint metadata stored by the root host.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct LearnedNode {
|
pub struct LearnedNode {
|
||||||
|
/// Absolute node path from the root.
|
||||||
pub path: Vec<String>,
|
pub path: Vec<String>,
|
||||||
|
/// Optional display title shown in the inspector.
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
/// Optional endpoint description shown in the inspector.
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Whether the node is a direct child of the root.
|
||||||
pub direct_child: bool,
|
pub direct_child: bool,
|
||||||
|
/// Endpoint-level procedures known on the node itself.
|
||||||
pub endpoint_procedures: Vec<LearnedProcedure>,
|
pub endpoint_procedures: Vec<LearnedProcedure>,
|
||||||
|
/// Leaf metadata currently known for the node.
|
||||||
pub leaves: Vec<LearnedLeaf>,
|
pub leaves: Vec<LearnedLeaf>,
|
||||||
|
/// Whether endpoint introspection definitely ran against this node.
|
||||||
pub endpoint_introspected: bool,
|
pub endpoint_introspected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Root-host knowledge accumulated from local configuration and observed traffic.
|
/// Root-host knowledge accumulated from local configuration and observed traffic.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RootKnowledge {
|
pub struct RootKnowledge {
|
||||||
|
/// Learned nodes keyed by their absolute path.
|
||||||
pub nodes: BTreeMap<Vec<String>, LearnedNode>,
|
pub nodes: BTreeMap<Vec<String>, LearnedNode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,49 +74,9 @@ impl RootKnowledge {
|
|||||||
};
|
};
|
||||||
for node in &tree.nodes {
|
for node in &tree.nodes {
|
||||||
if node.path.is_empty() || node.path.len() == 1 {
|
if node.path.is_empty() || node.path.len() == 1 {
|
||||||
// Realistic mode intentionally starts with root plus direct children,
|
knowledge
|
||||||
// not the full transitive tree.
|
.nodes
|
||||||
let direct_child = node.path.len() == 1;
|
.insert(node.path.clone(), initial_learned_node(node));
|
||||||
let mut learned = LearnedNode {
|
|
||||||
path: node.path.clone(),
|
|
||||||
title: Some(node.title.clone()),
|
|
||||||
description: Some(node.description.clone()),
|
|
||||||
direct_child,
|
|
||||||
endpoint_procedures: Vec::new(),
|
|
||||||
leaves: Vec::new(),
|
|
||||||
endpoint_introspected: node.path.is_empty(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if node.path.is_empty() {
|
|
||||||
// The root always knows its own procedures and leaves because
|
|
||||||
// those are locally configured, not discovered remotely.
|
|
||||||
learned.endpoint_procedures = node
|
|
||||||
.endpoint_procedures
|
|
||||||
.iter()
|
|
||||||
.map(|procedure| LearnedProcedure {
|
|
||||||
procedure_id: procedure.procedure_id.clone(),
|
|
||||||
description: Some(procedure.description.clone()),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
learned.leaves = node
|
|
||||||
.leaves
|
|
||||||
.iter()
|
|
||||||
.map(|leaf| LearnedLeaf {
|
|
||||||
leaf_name: leaf.name.clone(),
|
|
||||||
description: Some(leaf.description.clone()),
|
|
||||||
procedures: leaf
|
|
||||||
.procedures
|
|
||||||
.iter()
|
|
||||||
.map(|procedure_id| LearnedProcedure {
|
|
||||||
procedure_id: procedure_id.clone(),
|
|
||||||
description: Some(leaf.description.clone()),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
knowledge.nodes.insert(node.path.clone(), learned);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
knowledge
|
knowledge
|
||||||
@@ -223,12 +199,56 @@ impl RootKnowledge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the root's initial record for one statically known node.
|
||||||
|
fn initial_learned_node(node: &crate::model::DemoNode) -> LearnedNode {
|
||||||
|
let mut learned = LearnedNode {
|
||||||
|
path: node.path.clone(),
|
||||||
|
title: Some(node.title.clone()),
|
||||||
|
description: Some(node.description.clone()),
|
||||||
|
direct_child: node.path.len() == 1,
|
||||||
|
endpoint_procedures: Vec::new(),
|
||||||
|
leaves: Vec::new(),
|
||||||
|
endpoint_introspected: node.path.is_empty(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if node.path.is_empty() {
|
||||||
|
// The root always knows its own procedures and leaves because those are
|
||||||
|
// locally configured, not discovered remotely.
|
||||||
|
learned.endpoint_procedures = node
|
||||||
|
.endpoint_procedures
|
||||||
|
.iter()
|
||||||
|
.map(|procedure| LearnedProcedure {
|
||||||
|
procedure_id: procedure.procedure_id.clone(),
|
||||||
|
description: Some(procedure.description.clone()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
learned.leaves = node
|
||||||
|
.leaves
|
||||||
|
.iter()
|
||||||
|
.map(|leaf| LearnedLeaf {
|
||||||
|
leaf_name: leaf.name.clone(),
|
||||||
|
description: Some(leaf.description.clone()),
|
||||||
|
procedures: leaf
|
||||||
|
.procedures
|
||||||
|
.iter()
|
||||||
|
.map(|procedure_id| LearnedProcedure {
|
||||||
|
procedure_id: procedure_id.clone(),
|
||||||
|
description: Some(leaf.description.clone()),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
learned
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns one learned leaf entry, creating it if necessary.
|
/// Returns one learned leaf entry, creating it if necessary.
|
||||||
fn ensure_leaf<'a>(
|
fn ensure_leaf(
|
||||||
leaves: &'a mut Vec<LearnedLeaf>,
|
leaves: &mut Vec<LearnedLeaf>,
|
||||||
leaf_name: String,
|
leaf_name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
) -> &'a mut LearnedLeaf {
|
) -> &mut LearnedLeaf {
|
||||||
if let Some(index) = leaves.iter().position(|leaf| leaf.leaf_name == leaf_name) {
|
if let Some(index) = leaves.iter().position(|leaf| leaf.leaf_name == leaf_name) {
|
||||||
if leaves[index].description.is_none() {
|
if leaves[index].description.is_none() {
|
||||||
leaves[index].description = description;
|
leaves[index].description = description;
|
||||||
|
|||||||
@@ -32,16 +32,14 @@ impl Simulation {
|
|||||||
match procedure.kind {
|
match procedure.kind {
|
||||||
EndpointProcedureKind::Ping => {
|
EndpointProcedureKind::Ping => {
|
||||||
let reply = format!("pong from {}", self.node(node_id).display_path());
|
let reply = format!("pong from {}", self.node(node_id).display_path());
|
||||||
let frame = self.nodes[node_id.0]
|
let frame = self.make_endpoint_data_frame(
|
||||||
.endpoint
|
node_id,
|
||||||
.make_data(
|
hook.return_path.clone(),
|
||||||
hook.return_path.clone(),
|
hook.hook_id,
|
||||||
hook.hook_id,
|
procedure.procedure_id.clone(),
|
||||||
procedure.procedure_id.clone(),
|
reply.clone().into_bytes(),
|
||||||
reply.clone().into_bytes(),
|
true,
|
||||||
true,
|
)?;
|
||||||
)
|
|
||||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
|
||||||
self.record_trace(node_id, format!("endpoint sent ping reply: {reply}"));
|
self.record_trace(node_id, format!("endpoint sent ping reply: {reply}"));
|
||||||
self.process_local_frame(node_id, frame)?;
|
self.process_local_frame(node_id, frame)?;
|
||||||
}
|
}
|
||||||
@@ -54,16 +52,14 @@ impl Simulation {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
let frame = self.nodes[node_id.0]
|
let frame = self.make_endpoint_data_frame(
|
||||||
.endpoint
|
node_id,
|
||||||
.make_data(
|
hook.return_path.clone(),
|
||||||
hook.return_path.clone(),
|
hook.hook_id,
|
||||||
hook.hook_id,
|
procedure.procedure_id.clone(),
|
||||||
procedure.procedure_id.clone(),
|
text.as_bytes().to_vec(),
|
||||||
text.as_bytes().to_vec(),
|
index == 2,
|
||||||
index == 2,
|
)?;
|
||||||
)
|
|
||||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
|
||||||
self.record_trace(node_id, format!("endpoint sent chunk {}", index + 1));
|
self.record_trace(node_id, format!("endpoint sent chunk {}", index + 1));
|
||||||
self.process_local_frame(node_id, frame)?;
|
self.process_local_frame(node_id, frame)?;
|
||||||
}
|
}
|
||||||
@@ -80,16 +76,14 @@ impl Simulation {
|
|||||||
procedure_id: procedure.procedure_id.clone(),
|
procedure_id: procedure.procedure_id.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let frame = self.nodes[node_id.0]
|
let frame = self.make_endpoint_data_frame(
|
||||||
.endpoint
|
node_id,
|
||||||
.make_data(
|
hook.return_path.clone(),
|
||||||
hook.return_path.clone(),
|
hook.hook_id,
|
||||||
hook.hook_id,
|
procedure.procedure_id.clone(),
|
||||||
procedure.procedure_id.clone(),
|
b"chat ready".to_vec(),
|
||||||
b"chat ready".to_vec(),
|
false,
|
||||||
false,
|
)?;
|
||||||
)
|
|
||||||
.map_err(|error| SimError::Protocol(error.to_string()))?;
|
|
||||||
self.record_trace(node_id, "chat handler opened session".to_owned());
|
self.record_trace(node_id, "chat handler opened session".to_owned());
|
||||||
self.process_local_frame(node_id, frame)?;
|
self.process_local_frame(node_id, frame)?;
|
||||||
}
|
}
|
||||||
@@ -97,6 +91,23 @@ impl Simulation {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds one endpoint-originated data frame after application logic decides
|
||||||
|
/// what to send back on an already-validated hook.
|
||||||
|
fn make_endpoint_data_frame(
|
||||||
|
&mut self,
|
||||||
|
node_id: NodeId,
|
||||||
|
return_path: Vec<String>,
|
||||||
|
hook_id: u64,
|
||||||
|
procedure_id: String,
|
||||||
|
data: Vec<u8>,
|
||||||
|
end_hook: bool,
|
||||||
|
) -> Result<unshell::protocol::FrameBytes, SimError> {
|
||||||
|
self.nodes[node_id.0]
|
||||||
|
.endpoint
|
||||||
|
.make_data(return_path, hook_id, procedure_id, data, end_hook)
|
||||||
|
.map_err(|error| SimError::Protocol(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves one endpoint procedure from the ground-truth node metadata.
|
/// Resolves one endpoint procedure from the ground-truth node metadata.
|
||||||
pub(super) fn lookup_endpoint_procedure(
|
pub(super) fn lookup_endpoint_procedure(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -20,51 +20,24 @@ impl Simulation {
|
|||||||
match event {
|
match event {
|
||||||
LocalEvent::Data { header, message } => {
|
LocalEvent::Data { header, message } => {
|
||||||
let text = String::from_utf8_lossy(&message.data).to_string();
|
let text = String::from_utf8_lossy(&message.data).to_string();
|
||||||
self.record_trace(
|
let hook_ref = format_hook_ref(
|
||||||
node_id,
|
self.node(node_id).path.as_slice(),
|
||||||
format!(
|
header.hook_id.unwrap_or(0),
|
||||||
"local Data on {}: {text}",
|
|
||||||
format_hook_ref(
|
|
||||||
self.node(node_id).path.as_slice(),
|
|
||||||
header.hook_id.unwrap_or(0)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
self.record_trace(node_id, format!("local Data on {hook_ref}: {text}"));
|
||||||
|
|
||||||
if let Some(hook_id) = header.hook_id {
|
if let Some(hook_id) = header.hook_id {
|
||||||
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
self.update_hook_snapshot(hook_id, &text, message.data.len(), message.end_hook);
|
||||||
// Keep the most recent human-readable payload in the UI.
|
|
||||||
snapshot.last_message = if text.is_empty() {
|
|
||||||
format!("binary payload ({} bytes)", message.data.len())
|
|
||||||
} else {
|
|
||||||
text.clone()
|
|
||||||
};
|
|
||||||
if message.end_hook {
|
|
||||||
snapshot.closed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if node_id == self.root_id {
|
if node_id == self.root_id {
|
||||||
self.learn_from_root_data(hook_id, &message);
|
self.learn_from_root_data(hook_id, &message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(session) = self
|
if let Some(session) = self.chat_session_for_event(node_id, header.hook_id) {
|
||||||
.chat_sessions
|
|
||||||
.get(&header.hook_id.unwrap_or(0))
|
|
||||||
.cloned()
|
|
||||||
.filter(|session| session.node_id == node_id)
|
|
||||||
{
|
|
||||||
// Rationale: chat responses are implemented here instead of in the
|
// Rationale: chat responses are implemented here instead of in the
|
||||||
// core endpoint so the protocol crate stays generic. The simulator
|
// core endpoint so the protocol crate stays generic. The simulator
|
||||||
// acts as the application layer sitting above validated hook traffic.
|
// acts as the application layer sitting above validated hook traffic.
|
||||||
let reply = if text.eq_ignore_ascii_case("bye") {
|
let reply = chat_reply_for_text(&text);
|
||||||
Some(("chat session closed".to_owned(), true))
|
|
||||||
} else if !text.is_empty() {
|
|
||||||
Some((format!("chat ack: {}", text.to_uppercase()), false))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((reply, end_hook)) = reply {
|
if let Some((reply, end_hook)) = reply {
|
||||||
let frame = self.nodes[session.node_id.0]
|
let frame = self.nodes[session.node_id.0]
|
||||||
@@ -92,16 +65,13 @@ impl Simulation {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
LocalEvent::Fault { header, message } => {
|
LocalEvent::Fault { header, message } => {
|
||||||
|
let hook_ref = format_hook_ref(
|
||||||
|
self.node(node_id).path.as_slice(),
|
||||||
|
header.hook_id.unwrap_or(0),
|
||||||
|
);
|
||||||
self.record_trace(
|
self.record_trace(
|
||||||
node_id,
|
node_id,
|
||||||
format!(
|
format!("local Fault on {hook_ref}: 0x{:02X}", message.fault.0),
|
||||||
"local Fault on {}: 0x{:02X}",
|
|
||||||
format_hook_ref(
|
|
||||||
self.node(node_id).path.as_slice(),
|
|
||||||
header.hook_id.unwrap_or(0)
|
|
||||||
),
|
|
||||||
message.fault.0
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if let Some(hook_id) = header.hook_id {
|
if let Some(hook_id) = header.hook_id {
|
||||||
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
||||||
@@ -140,4 +110,45 @@ impl Simulation {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_hook_snapshot(
|
||||||
|
&mut self,
|
||||||
|
hook_id: u64,
|
||||||
|
text: &str,
|
||||||
|
payload_len: usize,
|
||||||
|
end_hook: bool,
|
||||||
|
) {
|
||||||
|
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
||||||
|
// Keep the most recent human-readable payload in the UI.
|
||||||
|
snapshot.last_message = if text.is_empty() {
|
||||||
|
format!("binary payload ({payload_len} bytes)")
|
||||||
|
} else {
|
||||||
|
text.to_owned()
|
||||||
|
};
|
||||||
|
if end_hook {
|
||||||
|
snapshot.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_session_for_event(
|
||||||
|
&self,
|
||||||
|
node_id: NodeId,
|
||||||
|
hook_id: Option<u64>,
|
||||||
|
) -> Option<super::super::super::types::ChatSession> {
|
||||||
|
self.chat_sessions
|
||||||
|
.get(&hook_id.unwrap_or(0))
|
||||||
|
.cloned()
|
||||||
|
.filter(|session| session.node_id == node_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_reply_for_text(text: &str) -> Option<(String, bool)> {
|
||||||
|
if text.eq_ignore_ascii_case("bye") {
|
||||||
|
return Some(("chat session closed".to_owned(), true));
|
||||||
|
}
|
||||||
|
if text.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((format!("chat ack: {}", text.to_uppercase()), false))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
//! Root-side knowledge learning from returned data.
|
//! Root-side knowledge learning from returned data.
|
||||||
|
//!
|
||||||
|
//! The simulator learns only from data that arrives back at the root on a known
|
||||||
|
//! hook. This keeps the realistic inspector aligned with what the UI-triggered
|
||||||
|
//! action actually observed.
|
||||||
|
|
||||||
use unshell::protocol::{
|
use unshell::protocol::{
|
||||||
DataMessage, EndpointIntrospection, LeafIntrospection, deserialize_archived_bytes,
|
DataMessage, EndpointIntrospection, LeafIntrospection, deserialize_archived_bytes,
|
||||||
@@ -17,23 +21,7 @@ impl Simulation {
|
|||||||
let demo_node = self.node(node_id).clone();
|
let demo_node = self.node(node_id).clone();
|
||||||
|
|
||||||
if snapshot.procedure_id.is_empty() {
|
if snapshot.procedure_id.is_empty() {
|
||||||
if snapshot.target_leaf.is_some() {
|
self.learn_from_root_introspection(&snapshot, &demo_node, message);
|
||||||
if let Ok(introspection) = deserialize_archived_bytes::<
|
|
||||||
unshell::protocol::introspection::ArchivedLeafIntrospection,
|
|
||||||
LeafIntrospection,
|
|
||||||
>(&message.data)
|
|
||||||
{
|
|
||||||
self.root_knowledge
|
|
||||||
.remember_leaf_introspection(&demo_node, &introspection);
|
|
||||||
}
|
|
||||||
} else if let Ok(introspection) = deserialize_archived_bytes::<
|
|
||||||
unshell::protocol::introspection::ArchivedEndpointIntrospection,
|
|
||||||
EndpointIntrospection,
|
|
||||||
>(&message.data)
|
|
||||||
{
|
|
||||||
self.root_knowledge
|
|
||||||
.remember_endpoint_introspection(&demo_node, &introspection);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,3 +42,33 @@ impl Simulation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Simulation {
|
||||||
|
fn learn_from_root_introspection(
|
||||||
|
&mut self,
|
||||||
|
snapshot: &super::super::types::HookSnapshot,
|
||||||
|
demo_node: &crate::model::DemoNode,
|
||||||
|
message: &DataMessage,
|
||||||
|
) {
|
||||||
|
if snapshot.target_leaf.is_some() {
|
||||||
|
if let Ok(introspection) = deserialize_archived_bytes::<
|
||||||
|
unshell::protocol::introspection::ArchivedLeafIntrospection,
|
||||||
|
LeafIntrospection,
|
||||||
|
>(&message.data)
|
||||||
|
{
|
||||||
|
self.root_knowledge
|
||||||
|
.remember_leaf_introspection(demo_node, &introspection);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(introspection) = deserialize_archived_bytes::<
|
||||||
|
unshell::protocol::introspection::ArchivedEndpointIntrospection,
|
||||||
|
EndpointIntrospection,
|
||||||
|
>(&message.data)
|
||||||
|
{
|
||||||
|
self.root_knowledge
|
||||||
|
.remember_endpoint_introspection(demo_node, &introspection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
const ENV_KEY_NAME: &str = "OBFUSCATION_KEY";
|
const ENV_KEY_NAME: &str = "OBFUSCATION_KEY";
|
||||||
const BACKUP_ENV_KEY: &str = "OBFUSCATION_KEY_DO_NOT_USE";
|
const BACKUP_ENV_KEY: &str = "OBFUSCATION_KEY_DO_NOT_USE";
|
||||||
|
|
||||||
|
/// Returns the obfuscation key used by the proc macros.
|
||||||
|
///
|
||||||
|
/// The fallback keeps macro expansion deterministic when the environment variable is absent.
|
||||||
pub fn get_encryption_key() -> String {
|
pub fn get_encryption_key() -> String {
|
||||||
if let Ok(key) = std::env::var(ENV_KEY_NAME) {
|
if let Ok(key) = std::env::var(ENV_KEY_NAME) {
|
||||||
key
|
key
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use quote::quote;
|
|||||||
use syn::parse::{Parse, ParseStream};
|
use syn::parse::{Parse, ParseStream};
|
||||||
use syn::{Expr, Lit, Token, parse_macro_input};
|
use syn::{Expr, Lit, Token, parse_macro_input};
|
||||||
|
|
||||||
|
/// Expands `sym_format!` into a string builder that obfuscates static segments only.
|
||||||
pub fn sym_format(input: TokenStream) -> TokenStream {
|
pub fn sym_format(input: TokenStream) -> TokenStream {
|
||||||
let PrintlnArgs { format_str, args } = parse_macro_input!(input as PrintlnArgs);
|
let PrintlnArgs { format_str, args } = parse_macro_input!(input as PrintlnArgs);
|
||||||
|
|
||||||
@@ -42,9 +43,6 @@ pub fn sym_format(input: TokenStream) -> TokenStream {
|
|||||||
quote! { #full_spec }
|
quote! { #full_spec }
|
||||||
};
|
};
|
||||||
|
|
||||||
// quote! {
|
|
||||||
// println!(#fmt_spec, #arg);
|
|
||||||
// }
|
|
||||||
parts.push(quote! {
|
parts.push(quote! {
|
||||||
format!(#fmt_spec, #arg)
|
format!(#fmt_spec, #arg)
|
||||||
});
|
});
|
||||||
@@ -105,9 +103,13 @@ impl Parse for PrintlnArgs {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum FormatSegment {
|
enum FormatSegment {
|
||||||
Static(String),
|
Static(String),
|
||||||
Dynamic(String, usize), // format spec, arg index
|
Dynamic(String, usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Splits a Rust formatting string into literal and replacement segments.
|
||||||
|
///
|
||||||
|
/// This only handles the subset needed by `sym_format!`: positional replacements in order,
|
||||||
|
/// plus escaped braces.
|
||||||
fn parse_format_string(fmt: &str) -> Vec<FormatSegment> {
|
fn parse_format_string(fmt: &str) -> Vec<FormatSegment> {
|
||||||
let mut segments = Vec::new();
|
let mut segments = Vec::new();
|
||||||
let mut current_static = String::new();
|
let mut current_static = String::new();
|
||||||
@@ -122,13 +124,11 @@ fn parse_format_string(fmt: &str) -> Vec<FormatSegment> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current static segment
|
|
||||||
if !current_static.is_empty() {
|
if !current_static.is_empty() {
|
||||||
segments.push(FormatSegment::Static(current_static.clone()));
|
segments.push(FormatSegment::Static(current_static.clone()));
|
||||||
current_static.clear();
|
current_static.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse format spec
|
|
||||||
let mut spec = String::new();
|
let mut spec = String::new();
|
||||||
while let Some(&next_ch) = chars.peek() {
|
while let Some(&next_ch) = chars.peek() {
|
||||||
if next_ch == '}' {
|
if next_ch == '}' {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/// Call some other function
|
/// Call some other function
|
||||||
macro_rules! passtrough {
|
macro_rules! passthrough {
|
||||||
($name:tt, $ref:expr) => {
|
($name:tt, $ref:expr) => {
|
||||||
pub fn $name(input: TokenStream) -> TokenStream {
|
pub fn $name(input: TokenStream) -> TokenStream {
|
||||||
$ref(input)
|
$ref(input)
|
||||||
@@ -42,25 +42,27 @@ pub mod proc_impl {
|
|||||||
unwrap_string!(sym_fn);
|
unwrap_string!(sym_fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "obfuscate_aes")]
|
#[cfg(all(feature = "obfuscate_aes", not(feature = "obfuscate_ref")))]
|
||||||
pub mod proc_impl {
|
pub mod proc_impl {
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
|
|
||||||
passtrough!(xor, crate::obfuscate::xor);
|
passthrough!(xor, crate::obfuscate::xor);
|
||||||
passtrough!(junk_asm, crate::obfuscate::junk_asm);
|
passthrough!(junk_asm, crate::obfuscate::junk_asm);
|
||||||
|
|
||||||
passtrough!(sym, crate::symbolic_aes::aes_str);
|
passthrough!(sym, crate::symbolic_aes::aes_str);
|
||||||
passtrough!(sym_fn, crate::symbolic_aes::aes_fn_name);
|
passthrough!(sym_fn, crate::symbolic_aes::aes_fn_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "obfuscate_ref")]
|
#[cfg(feature = "obfuscate_ref")]
|
||||||
pub mod proc_impl {
|
pub mod proc_impl {
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use syn::{LitStr, parse_macro_input};
|
|
||||||
|
|
||||||
passtrough!(xor, crate::obfuscate::xor);
|
passthrough!(xor, crate::obfuscate::xor);
|
||||||
passtrough!(junk_asm, crate::obfuscate::junk_asm);
|
passthrough!(junk_asm, crate::obfuscate::junk_asm);
|
||||||
|
|
||||||
passtrough!(sym, crate::symbolic_ref::sym_ref);
|
// `sym` and `sym_fn` need one concrete strategy. When both feature flags are enabled,
|
||||||
passtrough!(sym_fn, crate::symbolic_ref::sym_ref_fn);
|
// prefer symbolic references so `cargo clippy --all-features` still selects a single,
|
||||||
|
// deterministic implementation.
|
||||||
|
passthrough!(sym, crate::symbolic_ref::sym_ref);
|
||||||
|
passthrough!(sym_fn, crate::symbolic_ref::sym_ref_fn);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use base62::{Base62, hash};
|
use base62::{Base62, hash};
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
@@ -10,11 +8,13 @@ use crate::env::get_encryption_key;
|
|||||||
static mut SYM_COUNTER: Vec<String> = Vec::new();
|
static mut SYM_COUNTER: Vec<String> = Vec::new();
|
||||||
|
|
||||||
#[allow(static_mut_refs)]
|
#[allow(static_mut_refs)]
|
||||||
|
/// Returns how many unique symbols have been registered in this macro process.
|
||||||
pub fn get_symbol_number() -> usize {
|
pub fn get_symbol_number() -> usize {
|
||||||
unsafe { SYM_COUNTER.len() }
|
unsafe { SYM_COUNTER.len() }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(static_mut_refs)]
|
#[allow(static_mut_refs)]
|
||||||
|
/// Returns the stable numeric ID for `text`, inserting it on first use.
|
||||||
pub fn get_symbol(text: &str) -> usize {
|
pub fn get_symbol(text: &str) -> usize {
|
||||||
unsafe {
|
unsafe {
|
||||||
if let Some(n) = SYM_COUNTER.iter().position(|r| r == text) {
|
if let Some(n) = SYM_COUNTER.iter().position(|r| r == text) {
|
||||||
@@ -27,47 +27,40 @@ pub fn get_symbol(text: &str) -> usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ref_string(input: String) -> String {
|
fn encode_symbol_reference(symbol: String) -> String {
|
||||||
let n = get_symbol(&input);
|
let symbol_index = get_symbol(&symbol);
|
||||||
|
|
||||||
let data = base62::encode_usize(n);
|
let data = base62::encode_usize(symbol_index);
|
||||||
let key = hash(&get_encryption_key().as_bytes());
|
let key = hash(get_encryption_key().as_bytes());
|
||||||
|
|
||||||
let encoded = format!("_{}_", Base62::encode_full(&data, &key));
|
let encoded = format!("_{}_", Base62::encode_full(&data, &key));
|
||||||
|
|
||||||
println!("Aliased '{}' as '{encoded}'", input);
|
// Macro expansion logs make it easier to correlate exported symbols with their aliases.
|
||||||
|
println!("Aliased '{}' as '{encoded}'", symbol);
|
||||||
|
|
||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces a string literal with its symbolic reference alias.
|
||||||
pub fn sym_ref(input: TokenStream) -> TokenStream {
|
pub fn sym_ref(input: TokenStream) -> TokenStream {
|
||||||
// Parse the input as a string literal
|
|
||||||
let lit_str = parse_macro_input!(input as LitStr);
|
let lit_str = parse_macro_input!(input as LitStr);
|
||||||
let original_name = lit_str.value();
|
let original_name = lit_str.value();
|
||||||
|
|
||||||
let encoded = ref_string(original_name);
|
let encoded = encode_symbol_reference(original_name);
|
||||||
|
|
||||||
// Expand to a static string literal
|
|
||||||
TokenStream::from(quote! {
|
TokenStream::from(quote! {
|
||||||
#encoded
|
#encoded
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-exports a function under a symbolic reference alias.
|
||||||
pub fn sym_ref_fn(input: TokenStream) -> TokenStream {
|
pub fn sym_ref_fn(input: TokenStream) -> TokenStream {
|
||||||
// Parse the input function
|
|
||||||
let func = parse_macro_input!(input as ItemFn);
|
let func = parse_macro_input!(input as ItemFn);
|
||||||
|
|
||||||
// Get the original function name
|
|
||||||
let fn_name = func.sig.ident.to_string();
|
let fn_name = func.sig.ident.to_string();
|
||||||
|
|
||||||
// Generate the new, obfuscated name
|
let obfuscated_name = encode_symbol_reference(fn_name);
|
||||||
let obfuscated_name = ref_string(fn_name);
|
|
||||||
|
|
||||||
// Create a new string literal for the name
|
|
||||||
let new_name_lit = LitStr::new(&obfuscated_name, func.sig.ident.span());
|
let new_name_lit = LitStr::new(&obfuscated_name, func.sig.ident.span());
|
||||||
|
|
||||||
// Re-build the function, but add #[no_mangle]
|
|
||||||
// and rename the *exported* symbol via #[export_name]
|
|
||||||
TokenStream::from(quote! {
|
TokenStream::from(quote! {
|
||||||
#[unsafe(export_name = #new_name_lit)]
|
#[unsafe(export_name = #new_name_lit)]
|
||||||
#func
|
#func
|
||||||
|
|||||||
Reference in New Issue
Block a user