From 2589413984444f6a1c0064ccd85e2858647ec1e5 Mon Sep 17 00:00:00 2001 From: Luxian Date: Mon, 5 Oct 2026 10:58:42 +0800 Subject: [PATCH] feat(codec): preserve canonical namespace binding radix bytes --- src/namespace.rs | 2 + src/namespace/radix.rs | 203 +++++++++++++++++++ src/namespace/radix/tests.rs | 265 +++++++++++++++++++++++++ tests/fixtures/namespace-radix-v1.json | 17 ++ 4 files changed, 487 insertions(+) create mode 100644 src/namespace/radix.rs create mode 100644 src/namespace/radix/tests.rs create mode 100644 tests/fixtures/namespace-radix-v1.json diff --git a/src/namespace.rs b/src/namespace.rs index 1b260ae..1083d0a 100644 --- a/src/namespace.rs +++ b/src/namespace.rs @@ -7,6 +7,8 @@ use crate::{sha256, CodecError, CodecResult}; +pub mod radix; + pub const MANIFEST_MAX_BYTES: usize = 16_384; const PATH_MAX_BYTES: usize = 4096; const SOURCE_DOMAIN: &[u8] = b"mega.source-snapshot.v1\0"; diff --git a/src/namespace/radix.rs b/src/namespace/radix.rs new file mode 100644 index 0000000..74b0908 --- /dev/null +++ b/src/namespace/radix.rs @@ -0,0 +1,203 @@ +//! Local node and component-key codec from Mega draft c488b78's +//! `docs/spec/namespace-index-v1.md`. This is not an index walker or store. +//! +//! Labels may split UTF-8. A walker must validate incoming child labels, +//! assembled value keys, requested digests, source membership and retention. +//! Node decoding alone establishes none of those graph relationships. + +use super::{validate_absolute_path, PATH_MAX_BYTES}; +use crate::{sha256, CodecError, CodecResult}; + +pub const NODE_MAX_BYTES: usize = 16_384; +pub const CHILD_MAX_COUNT: usize = 256; +const NODE_DOMAIN: &[u8] = b"mega.namespace-radix.v1\0"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadixChild { + pub edge: u8, + pub digest: [u8; 32], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadixNode { + label: Vec, + binding: Option<[u8; 32]>, + children: Vec, +} + +impl RadixNode { + /// Validate only the local canonical shape, not the assembled value path. + pub fn new( + label: Vec, + binding: Option<[u8; 32]>, + children: Vec, + ) -> CodecResult { + if label.len() > PATH_MAX_BYTES { + return Err(CodecError::BadLength("radix label")); + } + if children.len() > CHILD_MAX_COUNT { + return Err(CodecError::BadLength("radix fanout")); + } + for pair in children.windows(2) { + if pair[0].edge >= pair[1].edge { + return Err(CodecError::BadOrdering("radix child edges")); + } + } + if binding.is_none() && (children.len() == 1 || (children.is_empty() && !label.is_empty())) + { + return Err(CodecError::BadOrdering("noncanonical radix compression")); + } + Ok(Self { + label, + binding, + children, + }) + } + + /// Canonical implicit empty index; it need not have a stored row. + pub fn empty() -> Self { + Self { + label: Vec::new(), + binding: None, + children: Vec::new(), + } + } + + pub fn label(&self) -> &[u8] { + &self.label + } + + pub fn binding(&self) -> Option<&[u8; 32]> { + self.binding.as_ref() + } + + pub fn children(&self) -> &[RadixChild] { + &self.children + } + + pub fn encode(&self) -> Vec { + let mut out = NODE_DOMAIN.to_vec(); + out.extend_from_slice(&(self.label.len() as u16).to_be_bytes()); + out.extend_from_slice(&self.label); + out.push(u8::from(self.binding.is_some())); + if let Some(binding) = self.binding { + out.extend_from_slice(&binding); + } + out.extend_from_slice(&(self.children.len() as u16).to_be_bytes()); + for child in &self.children { + out.push(child.edge); + out.extend_from_slice(&child.digest); + } + out + } + + pub fn decode(bytes: &[u8]) -> CodecResult { + if bytes.len() > NODE_MAX_BYTES { + return Err(CodecError::BadLength("radix node")); + } + let mut input = bytes + .strip_prefix(NODE_DOMAIN) + .ok_or(CodecError::BadConstant("radix domain"))?; + let label_len = read_u16(&mut input)?; + if label_len > PATH_MAX_BYTES { + return Err(CodecError::BadLength("radix label")); + } + let label = take(&mut input, label_len)?.to_vec(); + let binding = match take(&mut input, 1)?[0] { + 0 => None, + 1 => Some(read_digest(&mut input)?), + _ => return Err(CodecError::BadConstant("radix binding presence")), + }; + let child_count = read_u16(&mut input)?; + if child_count > CHILD_MAX_COUNT { + return Err(CodecError::BadLength("radix fanout")); + } + let mut children = Vec::with_capacity(child_count); + for _ in 0..child_count { + children.push(RadixChild { + edge: take(&mut input, 1)?[0], + digest: read_digest(&mut input)?, + }); + } + if !input.is_empty() { + return Err(CodecError::BadLength("radix trailing bytes")); + } + Self::new(label, binding, children) + } + + /// Derive identity; the caller must separately compare a requested digest. + pub fn id(&self) -> [u8; 32] { + sha256(&[&self.encode()]) + } +} + +pub fn empty_root() -> [u8; 32] { + RadixNode::empty().id() +} + +/// Preserve v1 key encoding and structural path limits. MST/2 endpoints must +/// separately enforce their 256-component profile before serving a path. +pub fn encode_key(path: &str) -> CodecResult> { + validate_absolute_path(path)?; + if path == "/" { + return Ok(Vec::new()); + } + let mut key = path.as_bytes()[1..].to_vec(); + for byte in &mut key { + if *byte == b'/' { + *byte = 0; + } + } + key.push(0); + Ok(key) +} + +/// Decode a complete value key, never a partial compressed label. Slashes in +/// encoded keys and a NUL-only root are rejected as noncanonical encodings. +pub fn decode_key(key: &[u8]) -> CodecResult { + if key.len() > PATH_MAX_BYTES { + return Err(CodecError::BadLength("radix key")); + } + if key.is_empty() { + return Ok("/".to_string()); + } + if key.last() != Some(&0) || key.contains(&b'/') { + return Err(CodecError::BadName("radix key boundary")); + } + let mut path = Vec::with_capacity(key.len()); + path.push(b'/'); + path.extend( + key[..key.len() - 1] + .iter() + .map(|byte| if *byte == 0 { b'/' } else { *byte }), + ); + let path = String::from_utf8(path).map_err(|_| CodecError::BadName("radix key UTF-8"))?; + validate_absolute_path(&path)?; + if encode_key(&path)?.as_slice() != key { + return Err(CodecError::BadName("noncanonical radix key")); + } + Ok(path) +} + +fn take<'a>(input: &mut &'a [u8], count: usize) -> CodecResult<&'a [u8]> { + let head = input + .get(..count) + .ok_or(CodecError::Truncated("radix node field"))?; + *input = &input[count..]; + Ok(head) +} + +fn read_u16(input: &mut &[u8]) -> CodecResult { + let bytes = take(input, 2)?; + Ok(usize::from(u16::from_be_bytes([bytes[0], bytes[1]]))) +} + +fn read_digest(input: &mut &[u8]) -> CodecResult<[u8; 32]> { + let bytes = take(input, 32)?; + let mut digest = [0; 32]; + digest.copy_from_slice(bytes); + Ok(digest) +} + +#[cfg(test)] +mod tests; diff --git a/src/namespace/radix/tests.rs b/src/namespace/radix/tests.rs new file mode 100644 index 0000000..8c8c15a --- /dev/null +++ b/src/namespace/radix/tests.rs @@ -0,0 +1,265 @@ +use super::*; +use serde_json::Value; + +fn unhex(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|offset| u8::from_str_radix(&hex[offset..offset + 2], 16).unwrap()) + .collect() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn vectors() -> Value { + serde_json::from_str(include_str!( + "../../../tests/fixtures/namespace-radix-v1.json" + )) + .unwrap() +} + +fn child(edge: u8, fill: u8) -> RadixChild { + RadixChild { + edge, + digest: [fill; 32], + } +} + +#[test] +fn frozen_independent_dotnet_vectors_match_local_nodes_bytes_and_identity() { + let expected_nodes = [ + RadixNode::empty(), + RadixNode::new(b"a\0".to_vec(), Some([0x11; 32]), Vec::new()).unwrap(), + RadixNode::new(Vec::new(), None, vec![child(b'a', 0x22), child(b'b', 0x33)]).unwrap(), + ]; + let frozen_vectors = vectors(); + assert_eq!( + frozen_vectors.as_array().unwrap().len(), + expected_nodes.len() + ); + for (vector, expected_node) in frozen_vectors + .as_array() + .unwrap() + .iter() + .zip(expected_nodes) + { + let bytes = unhex(vector["canonical_hex"].as_str().unwrap()); + let decoded = RadixNode::decode(&bytes).unwrap(); + assert_eq!(decoded, expected_node); + assert_eq!(expected_node.encode(), bytes); + assert_eq!(format!("sha256:{}", hex(&decoded.id())), vector["digest"]); + } + assert_eq!( + hex(&empty_root()), + "18946486089198dfa8eeb70fa90e04b137c579dc08ae1e6f8bceafc0d35ef677" + ); +} + +#[test] +fn every_frozen_node_truncation_and_trailing_byte_is_rejected() { + for vector in vectors().as_array().unwrap() { + let bytes = unhex(vector["canonical_hex"].as_str().unwrap()); + for end in 0..bytes.len() { + assert!( + RadixNode::decode(&bytes[..end]).is_err(), + "{}: {end}", + vector["name"] + ); + } + let mut trailing = bytes; + trailing.push(0); + assert!(RadixNode::decode(&trailing).is_err()); + } + assert_eq!( + RadixNode::decode(&vec![0; NODE_MAX_BYTES + 1]).unwrap_err(), + CodecError::BadLength("radix node") + ); +} + +#[test] +fn domain_presence_tags_and_unbounded_lengths_fail_closed() { + let empty = RadixNode::empty().encode(); + let mut domain = empty.clone(); + domain[0] ^= 1; + assert!(RadixNode::decode(&domain).is_err()); + for tag in [2, 255] { + let mut bad = empty.clone(); + bad[NODE_DOMAIN.len() + 2] = tag; + assert!(RadixNode::decode(&bad).is_err()); + } + for label_length in [4097u16, u16::MAX] { + let mut bad = empty.clone(); + bad[NODE_DOMAIN.len()..NODE_DOMAIN.len() + 2].copy_from_slice(&label_length.to_be_bytes()); + assert_eq!( + RadixNode::decode(&bad).unwrap_err(), + CodecError::BadLength("radix label") + ); + } + for count in [257u16, u16::MAX] { + let mut bad = empty.clone(); + let start = NODE_DOMAIN.len() + 3; + bad[start..start + 2].copy_from_slice(&count.to_be_bytes()); + assert_eq!( + RadixNode::decode(&bad).unwrap_err(), + CodecError::BadLength("radix fanout") + ); + } +} + +#[test] +fn duplicate_and_unsorted_edges_are_not_normalized_into_canonical_nodes() { + let branch = RadixNode::new(Vec::new(), None, vec![child(b'a', 1), child(b'b', 2)]).unwrap(); + let first_edge = NODE_DOMAIN.len() + 2 + 1 + 2; + for second_edge in *b"a0" { + let mut bad = branch.encode(); + bad[first_edge + 33] = second_edge; + assert!(matches!( + RadixNode::decode(&bad), + Err(CodecError::BadOrdering(_)) + )); + } + for children in [ + vec![child(b'a', 1), child(b'a', 2)], + vec![child(b'b', 1), child(b'a', 2)], + ] { + assert!(RadixNode::new(Vec::new(), None, children).is_err()); + } +} + +#[test] +fn noncanonical_uncompressed_nodes_fail_in_constructors_and_decoder() { + assert!(RadixNode::new(Vec::new(), None, vec![child(b'a', 1)]).is_err()); + assert!(RadixNode::new(b"a".to_vec(), None, Vec::new()).is_err()); + // Handwritten encodings deliberately bypass constructors. + let mut one_child = NODE_DOMAIN.to_vec(); + one_child.extend_from_slice(&[0, 0, 0, 0, 1, b'a']); + one_child.extend_from_slice(&[1; 32]); + assert!(RadixNode::decode(&one_child).is_err()); + let mut labelled_empty = NODE_DOMAIN.to_vec(); + labelled_empty.extend_from_slice(&[0, 1, b'a', 0, 0, 0]); + assert!(RadixNode::decode(&labelled_empty).is_err()); + let root_binding = RadixNode::new(Vec::new(), Some([1; 32]), Vec::new()).unwrap(); + assert!(root_binding.binding().is_some()); + let bound_branch = + RadixNode::new(b"a\0".to_vec(), Some([1; 32]), vec![child(b'b', 2)]).unwrap(); + assert_eq!( + RadixNode::decode(&bound_branch.encode()).unwrap(), + bound_branch + ); +} + +#[test] +fn maximum_label_and_all_256_byte_edges_fit_the_frozen_node_budget() { + let children = (0..=255).map(|edge| child(edge, edge)).collect(); + let node = RadixNode::new(vec![b'a'; PATH_MAX_BYTES], Some([7; 32]), children).unwrap(); + assert_eq!(node.children().first().unwrap().edge, 0); + assert_eq!(node.children().last().unwrap().edge, 255); + assert_eq!(node.children().len(), CHILD_MAX_COUNT); + assert!(node.encode().len() <= NODE_MAX_BYTES); + assert_eq!(RadixNode::decode(&node.encode()).unwrap(), node); + assert!(RadixNode::new(vec![b'a'; PATH_MAX_BYTES + 1], Some([7; 32]), Vec::new()).is_err()); + assert!(RadixNode::new(Vec::new(), Some([7; 32]), vec![child(0, 1); 257]).is_err()); +} + +#[test] +fn local_labels_may_split_utf8_but_complete_value_keys_must_be_valid() { + let node = RadixNode::new(vec![0xa9, 0], Some([7; 32]), Vec::new()).unwrap(); + assert_eq!(node.label(), &[0xa9, 0]); + assert_eq!(RadixNode::decode(&node.encode()).unwrap(), node); + assert!( + decode_key(node.label()).is_err(), + "child label is not a complete key" + ); + let assembled = [vec![0xc3], node.label().to_vec()].concat(); + assert_eq!(decode_key(&assembled).unwrap(), "/é"); + let prefix = RadixNode::new(vec![0xc3], None, vec![child(0xa9, 1), child(0xb1, 2)]).unwrap(); + assert_eq!(RadixNode::decode(&prefix.encode()).unwrap(), prefix); + let overlong_assembled = [vec![b'a'; 3000], vec![b'b'; 2000], vec![0]].concat(); + assert!(decode_key(&overlong_assembled).is_err()); +} + +#[test] +fn component_key_bytes_match_handwritten_examples_and_do_not_match_neighbors() { + for (path, expected) in [ + ("/", b"".as_slice()), + ("/a", b"a\0".as_slice()), + ("/a/b", b"a\0b\0".as_slice()), + ("/x/lib", b"x\0lib\0".as_slice()), + ("/x/library", b"x\0library\0".as_slice()), + ] { + assert_eq!(encode_key(path).unwrap(), expected); + assert_eq!(decode_key(expected).unwrap(), path); + } + let ancestor = encode_key("/x/lib").unwrap(); + assert!(encode_key("/x/lib/file").unwrap().starts_with(&ancestor)); + assert!(!encode_key("/x/library/file") + .unwrap() + .starts_with(&ancestor)); +} + +#[test] +fn keys_use_encoded_byte_order_preserve_case_unicode_plus_and_literal_backslash() { + assert!(encode_key("/a").unwrap() < encode_key("/a/b").unwrap()); + assert!(encode_key("/a/b").unwrap() < encode_key("/a+").unwrap()); + assert!("/a/b" > "/a+", "slash-string order is a different order"); + for path in ["/Case", "/case", "/库+1/\\literal", "/é", "/e\u{301}"] { + assert_eq!(decode_key(&encode_key(path).unwrap()).unwrap(), path); + } + assert_ne!(encode_key("/Case").unwrap(), encode_key("/case").unwrap()); + assert_ne!(encode_key("/é").unwrap(), encode_key("/e\u{301}").unwrap()); +} + +#[test] +fn noncanonical_complete_keys_are_rejected_without_aliasing_paths() { + for key in [ + b"a".as_slice(), + b"\0".as_slice(), + b"\0a\0".as_slice(), + b"a\0\0".as_slice(), + b"a/b\0".as_slice(), + b".\0".as_slice(), + b"..\0".as_slice(), + &[0xff, 0], + ] { + assert!(decode_key(key).is_err(), "{key:?}"); + } + let mut overlong_component = vec![b'a'; 256]; + overlong_component.push(0); + assert!(decode_key(&overlong_component).is_err()); + assert!(decode_key(&vec![0; PATH_MAX_BYTES + 1]).is_err()); + for path in ["", "relative", "/a/", "/a//b", "/a/..", "/a\0"] { + assert!(encode_key(path).is_err(), "{path:?}"); + } +} + +#[test] +fn exact_key_byte_component_limits_and_legacy_depth_are_preserved() { + let path = format!("/{}", vec!["a".repeat(255); 16].join("/")); + assert_eq!(path.len(), PATH_MAX_BYTES); + let key = encode_key(&path).unwrap(); + assert_eq!(key.len(), PATH_MAX_BYTES); + assert_eq!(decode_key(&key).unwrap(), path); + assert!(encode_key(&format!("{path}/z")).is_err()); + assert!(encode_key(&format!("/{}a", "é".repeat(127))).is_ok()); + assert!(encode_key(&format!("/{}", "é".repeat(128))).is_err()); + let legacy_deep = format!("/{}", vec!["a"; 257].join("/")); + assert_eq!( + decode_key(&encode_key(&legacy_deep).unwrap()).unwrap(), + legacy_deep + ); +} + +#[test] +fn identity_covers_labels_binding_values_edges_and_child_digests() { + let base = RadixNode::new(b"a\0".to_vec(), Some([1; 32]), vec![child(b'b', 2)]).unwrap(); + let changed = [ + RadixNode::new(b"c\0".to_vec(), Some([1; 32]), vec![child(b'b', 2)]).unwrap(), + RadixNode::new(b"a\0".to_vec(), Some([3; 32]), vec![child(b'b', 2)]).unwrap(), + RadixNode::new(b"a\0".to_vec(), Some([1; 32]), vec![child(b'c', 2)]).unwrap(), + RadixNode::new(b"a\0".to_vec(), Some([1; 32]), vec![child(b'b', 3)]).unwrap(), + ]; + for node in changed { + assert_ne!(base.id(), node.id()); + } +} diff --git a/tests/fixtures/namespace-radix-v1.json b/tests/fixtures/namespace-radix-v1.json new file mode 100644 index 0000000..3b7b2ed --- /dev/null +++ b/tests/fixtures/namespace-radix-v1.json @@ -0,0 +1,17 @@ +[ + { + "name": "empty", + "canonical_hex": "6d6567612e6e616d6573706163652d72616469782e7631000000000000", + "digest": "sha256:18946486089198dfa8eeb70fa90e04b137c579dc08ae1e6f8bceafc0d35ef677" + }, + { + "name": "leaf-a", + "canonical_hex": "6d6567612e6e616d6573706163652d72616469782e763100000261000111111111111111111111111111111111111111111111111111111111111111110000", + "digest": "sha256:0daf3f5407c62015b13000cc10e525975b5893f08bdbbe2a919d63c8bdc18cae" + }, + { + "name": "branch-ab", + "canonical_hex": "6d6567612e6e616d6573706163652d72616469782e7631000000000002612222222222222222222222222222222222222222222222222222222222222222623333333333333333333333333333333333333333333333333333333333333333", + "digest": "sha256:6eff5588f487756adb7b0b1834929ae1f7800e0c15553cc54836b78b9501e74e" + } +]