1use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use sha2::{Digest, Sha256};
14use unicode_normalization::UnicodeNormalization;
15
16pub const MAX_PATH_BYTES: usize = 1_024;
17pub const MAX_COMPONENT_BYTES: usize = 255;
18pub const CONTENT_TREE_HASH_DOMAIN: &str = "v2/content-tree-node";
19pub const ASSET_TREE_HASH_DOMAIN: &str = "v2/asset-tree-node";
20
21#[derive(Debug, thiserror::Error)]
22pub enum V2Error {
23 #[error("invalid portable path: {0}")]
24 InvalidPath(String),
25 #[error("invalid v2 tree: {0}")]
26 InvalidTree(String),
27 #[error("missing v2 tree object {0}")]
28 MissingNode(String),
29}
30
31pub type V2Result<T> = Result<T, V2Error>;
32
33pub fn sha256_hex(bytes: &[u8]) -> String {
34 format!("{:x}", Sha256::digest(bytes))
35}
36
37pub fn canonical_bytes(value: &Value) -> V2Result<Vec<u8>> {
38 let mut bytes = serde_json::to_vec(value)
39 .map_err(|error| V2Error::InvalidTree(format!("canonical JSON failed: {error}")))?;
40 bytes.push(b'\n');
41 Ok(bytes)
42}
43
44pub fn domain_hash_bytes(domain: &str, bytes: &[u8]) -> V2Result<String> {
45 if domain.is_empty()
46 || domain.len() > 128
47 || !domain.bytes().enumerate().all(|(index, byte)| {
48 byte.is_ascii_lowercase()
49 || byte.is_ascii_digit()
50 || (index > 0 && matches!(byte, b'.' | b'_' | b'/' | b'-'))
51 })
52 {
53 return Err(V2Error::InvalidTree("invalid hash domain".to_string()));
54 }
55 let mut hasher = Sha256::new();
56 hasher.update(b"link.md\0");
57 hasher.update(domain.as_bytes());
58 hasher.update(b"\0");
59 hasher.update(bytes);
60 Ok(format!("{:x}", hasher.finalize()))
61}
62
63pub fn domain_hash(domain: &str, value: &Value) -> V2Result<String> {
64 if domain.is_empty()
65 || domain.len() > 128
66 || !domain.bytes().enumerate().all(|(index, byte)| {
67 byte.is_ascii_lowercase()
68 || byte.is_ascii_digit()
69 || (index > 0 && matches!(byte, b'.' | b'_' | b'/' | b'-'))
70 })
71 {
72 return Err(V2Error::InvalidTree("invalid hash domain".to_string()));
73 }
74 let mut hasher = Sha256::new();
75 hasher.update(b"link.md\0");
76 hasher.update(domain.as_bytes());
77 hasher.update(b"\0");
78 hasher.update(canonical_bytes(value)?);
79 Ok(format!("{:x}", hasher.finalize()))
80}
81
82fn portable_alias(component: &str) -> String {
83 component
84 .nfc()
85 .flat_map(char::to_lowercase)
86 .collect::<String>()
87 .nfc()
88 .collect()
89}
90
91fn windows_device(component: &str) -> bool {
92 let stem = component
93 .split('.')
94 .next()
95 .unwrap_or(component)
96 .to_ascii_lowercase();
97 matches!(stem.as_str(), "con" | "prn" | "aux" | "nul")
98 || stem
99 .strip_prefix("com")
100 .or_else(|| stem.strip_prefix("lpt"))
101 .is_some_and(|digit| digit.len() == 1 && matches!(digit.as_bytes()[0], b'1'..=b'9'))
102}
103
104pub fn normalize_path(input: &str) -> V2Result<String> {
105 if input.is_empty()
106 || input.len() > MAX_PATH_BYTES
107 || input.starts_with('/')
108 || input.contains(['\\', ':', '\0'])
109 || input.nfc().collect::<String>() != input
110 {
111 return Err(V2Error::InvalidPath(input.to_string()));
112 }
113 for component in input.split('/') {
114 let alias = portable_alias(component);
115 if component.is_empty()
116 || matches!(component, "." | "..")
117 || component.ends_with(['.', ' '])
118 || component.bytes().any(|byte| byte < 0x20 || byte == 0x7f)
119 || component.len() > MAX_COMPONENT_BYTES
120 || alias.len() > MAX_COMPONENT_BYTES
121 || windows_device(component)
122 {
123 return Err(V2Error::InvalidPath(input.to_string()));
124 }
125 }
126 Ok(input.to_string())
127}
128
129pub fn validate_path_set<'a>(paths: impl IntoIterator<Item = &'a str>) -> V2Result<Vec<String>> {
130 let normalized = paths
131 .into_iter()
132 .map(normalize_path)
133 .collect::<V2Result<Vec<_>>>()?;
134 let mut exact = BTreeSet::new();
135 let mut aliases = BTreeMap::new();
136 for path in &normalized {
137 if !exact.insert(path.clone()) {
138 return Err(V2Error::InvalidPath(format!("duplicate path: {path}")));
139 }
140 let alias = path
141 .split('/')
142 .map(portable_alias)
143 .collect::<Vec<_>>()
144 .join("/");
145 if let Some(prior) = aliases.insert(alias, path.clone()) {
146 if prior != *path {
147 return Err(V2Error::InvalidPath(format!(
148 "portable alias collision: {prior} and {path}"
149 )));
150 }
151 }
152 }
153 for path in &normalized {
154 let components = path.split('/').collect::<Vec<_>>();
155 for index in 1..components.len() {
156 let prefix = components[..index].join("/");
157 if exact.contains(&prefix) {
158 return Err(V2Error::InvalidPath(format!(
159 "file/directory prefix collision: {prefix}"
160 )));
161 }
162 }
163 }
164 Ok(normalized)
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
168#[serde(rename_all = "lowercase")]
169pub enum EntryKind {
170 Blob,
171 Tree,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
175pub struct TreeEntry {
176 pub name: String,
177 pub kind: EntryKind,
178 pub child_hash: String,
179 pub bytes: Option<u64>,
180 pub nonce: String,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum HamtNode {
185 Leaf {
186 route: String,
187 entry: TreeEntry,
188 },
189 Branch {
190 depth: usize,
191 children: Vec<(u8, String)>,
192 },
193 Compressed {
194 depth: usize,
195 run: String,
196 child: String,
197 },
198}
199
200fn node_value(node: &HamtNode) -> Value {
201 match node {
202 HamtNode::Leaf { route, entry } => json!({
203 "entry": {
204 "bytes": entry.bytes,
205 "child_hash": entry.child_hash,
206 "kind": entry.kind,
207 "name": entry.name,
208 "nonce": entry.nonce,
209 },
210 "kind": "leaf",
211 "route": route,
212 "v": 1,
213 }),
214 HamtNode::Branch { depth, children } => json!({
215 "children": children,
216 "depth": depth,
217 "kind": "branch",
218 "v": 1,
219 }),
220 HamtNode::Compressed { depth, run, child } => json!({
221 "child": child,
222 "depth": depth,
223 "kind": "compressed",
224 "run": run,
225 "v": 1,
226 }),
227 }
228}
229
230pub fn encode_node(node: &HamtNode) -> V2Result<Vec<u8>> {
231 canonical_bytes(&node_value(node))
232}
233
234pub fn hash_node(node: &HamtNode) -> V2Result<String> {
235 hash_node_with_domain(node, CONTENT_TREE_HASH_DOMAIN)
236}
237
238pub fn hash_node_with_domain(node: &HamtNode, domain: &str) -> V2Result<String> {
239 domain_hash(domain, &node_value(node))
240}
241
242pub fn decode_node(bytes: &[u8]) -> V2Result<HamtNode> {
243 let value: Value = serde_json::from_slice(bytes)
244 .map_err(|error| V2Error::InvalidTree(format!("node JSON failed: {error}")))?;
245 let object = value
246 .as_object()
247 .ok_or_else(|| V2Error::InvalidTree("node is not an object".to_string()))?;
248 if object.get("v").and_then(Value::as_u64) != Some(1) {
249 return Err(V2Error::InvalidTree("unsupported node version".to_string()));
250 }
251 let node = match object.get("kind").and_then(Value::as_str) {
252 Some("leaf") => {
253 let route = object
254 .get("route")
255 .and_then(Value::as_str)
256 .ok_or_else(|| V2Error::InvalidTree("leaf route missing".to_string()))?;
257 let entry_value = object
258 .get("entry")
259 .ok_or_else(|| V2Error::InvalidTree("leaf entry missing".to_string()))?;
260 let entry: TreeEntry = serde_json::from_value(entry_value.clone())
261 .map_err(|error| V2Error::InvalidTree(format!("leaf entry failed: {error}")))?;
262 HamtNode::Leaf {
263 route: route.to_string(),
264 entry,
265 }
266 }
267 Some("branch") => {
268 let depth = object
269 .get("depth")
270 .and_then(Value::as_u64)
271 .and_then(|value| usize::try_from(value).ok())
272 .ok_or_else(|| V2Error::InvalidTree("branch depth missing".to_string()))?;
273 let children = serde_json::from_value(
274 object
275 .get("children")
276 .cloned()
277 .ok_or_else(|| V2Error::InvalidTree("branch children missing".to_string()))?,
278 )
279 .map_err(|error| V2Error::InvalidTree(format!("branch children failed: {error}")))?;
280 HamtNode::Branch { depth, children }
281 }
282 Some("compressed") => HamtNode::Compressed {
283 depth: object
284 .get("depth")
285 .and_then(Value::as_u64)
286 .and_then(|value| usize::try_from(value).ok())
287 .ok_or_else(|| V2Error::InvalidTree("compressed depth missing".to_string()))?,
288 run: object
289 .get("run")
290 .and_then(Value::as_str)
291 .ok_or_else(|| V2Error::InvalidTree("compressed run missing".to_string()))?
292 .to_string(),
293 child: object
294 .get("child")
295 .and_then(Value::as_str)
296 .ok_or_else(|| V2Error::InvalidTree("compressed child missing".to_string()))?
297 .to_string(),
298 },
299 _ => return Err(V2Error::InvalidTree("unknown node kind".to_string())),
300 };
301 if encode_node(&node)? != bytes {
302 return Err(V2Error::InvalidTree("non-canonical node".to_string()));
303 }
304 Ok(node)
305}
306
307fn put_node(nodes: &mut BTreeMap<String, Vec<u8>>, node: HamtNode) -> V2Result<String> {
308 let hash = hash_node(&node)?;
309 let bytes = encode_node(&node)?;
310 if let Some(prior) = nodes.get(&hash) {
311 if prior != &bytes {
312 return Err(V2Error::InvalidTree("node hash collision".to_string()));
313 }
314 }
315 nodes.insert(hash.clone(), bytes);
316 Ok(hash)
317}
318
319fn common_run(routes: &[String], depth: usize) -> String {
320 if routes.len() == 1 {
321 return routes[0][depth..].to_string();
322 }
323 let mut end = depth;
324 while end < 64 {
325 let byte = routes[0].as_bytes()[end];
326 if routes.iter().any(|route| route.as_bytes()[end] != byte) {
327 break;
328 }
329 end += 1;
330 }
331 routes[0][depth..end].to_string()
332}
333
334fn build_node(
335 leaves: &[(String, TreeEntry)],
336 depth: usize,
337 nodes: &mut BTreeMap<String, Vec<u8>>,
338) -> V2Result<String> {
339 if leaves.is_empty() || depth > 64 {
340 return Err(V2Error::InvalidTree(
341 "invalid HAMT build bounds".to_string(),
342 ));
343 }
344 if leaves.len() == 1 {
345 let leaf_hash = put_node(
346 nodes,
347 HamtNode::Leaf {
348 route: leaves[0].0.clone(),
349 entry: leaves[0].1.clone(),
350 },
351 )?;
352 let run = leaves[0].0[depth..].to_string();
353 return if run.is_empty() {
354 Ok(leaf_hash)
355 } else {
356 put_node(
357 nodes,
358 HamtNode::Compressed {
359 depth,
360 run,
361 child: leaf_hash,
362 },
363 )
364 };
365 }
366 let run = common_run(
367 &leaves
368 .iter()
369 .map(|(route, _)| route.clone())
370 .collect::<Vec<_>>(),
371 depth,
372 );
373 if !run.is_empty() {
374 let child = build_node(leaves, depth + run.len(), nodes)?;
375 return put_node(nodes, HamtNode::Compressed { depth, run, child });
376 }
377 if depth >= 64 {
378 return Err(V2Error::InvalidTree(
379 "distinct names have a SHA-256 route collision".to_string(),
380 ));
381 }
382 let mut groups: BTreeMap<u8, Vec<(String, TreeEntry)>> = BTreeMap::new();
383 for leaf in leaves {
384 let slot = u8::from_str_radix(&leaf.0[depth..=depth], 16)
385 .map_err(|_| V2Error::InvalidTree("invalid route nibble".to_string()))?;
386 groups.entry(slot).or_default().push(leaf.clone());
387 }
388 let mut children = Vec::new();
389 for (slot, group) in groups {
390 children.push((slot, build_node(&group, depth + 1, nodes)?));
391 }
392 put_node(nodes, HamtNode::Branch { depth, children })
393}
394
395pub fn build_hamt(
396 entries: &[TreeEntry],
397 nodes: &mut BTreeMap<String, Vec<u8>>,
398) -> V2Result<Option<String>> {
399 if entries.is_empty() {
400 return Ok(None);
401 }
402 let mut names = BTreeSet::new();
403 let mut routes = BTreeSet::new();
404 let mut leaves = Vec::new();
405 for entry in entries {
406 if entry.name.nfc().collect::<String>() != entry.name
407 || entry.nonce.len() != 32
408 || !entry
409 .nonce
410 .bytes()
411 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
412 {
413 return Err(V2Error::InvalidTree("invalid leaf state".to_string()));
414 }
415 if !names.insert(entry.name.clone()) {
416 return Err(V2Error::InvalidTree("duplicate child name".to_string()));
417 }
418 let route = sha256_hex(entry.name.as_bytes());
419 if !routes.insert(route.clone()) {
420 return Err(V2Error::InvalidTree(
421 "child-name route collision".to_string(),
422 ));
423 }
424 leaves.push((route, entry.clone()));
425 }
426 leaves.sort_by(|left, right| left.0.cmp(&right.0));
427 build_node(&leaves, 0, nodes).map(Some)
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct ContentFile {
432 pub path: String,
433 pub blob_hash: String,
434 pub bytes: u64,
435}
436
437#[derive(Debug, Clone, PartialEq, Eq)]
438pub struct EntryState {
439 pub path: String,
440 pub entry: TreeEntry,
441}
442
443#[derive(Debug, Clone)]
444pub struct BuiltTree {
445 pub root: Option<String>,
446 pub nodes: BTreeMap<String, Vec<u8>>,
447 pub entries: BTreeMap<String, EntryState>,
448 pub files: BTreeMap<String, ContentFile>,
449}
450
451#[derive(Default)]
452struct Directory {
453 files: BTreeMap<String, ContentFile>,
454 dirs: BTreeMap<String, Directory>,
455}
456
457fn next_nonce(factory: &mut impl FnMut() -> String, prior: Option<&str>) -> V2Result<String> {
458 for _ in 0..8 {
459 let nonce = factory();
460 if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) {
461 return Err(V2Error::InvalidTree(
462 "nonce factory returned invalid bytes".to_string(),
463 ));
464 }
465 if Some(nonce.as_str()) != prior {
466 return Ok(nonce);
467 }
468 }
469 Err(V2Error::InvalidTree(
470 "nonce factory repeated the prior nonce".to_string(),
471 ))
472}
473
474pub fn build_content_tree(
475 input: &[ContentFile],
476 prior: Option<&BuiltTree>,
477 nonce_factory: &mut impl FnMut() -> String,
478) -> V2Result<BuiltTree> {
479 let paths = validate_path_set(input.iter().map(|file| file.path.as_str()))?;
480 let mut directory = Directory::default();
481 let mut files = BTreeMap::new();
482 for (index, file) in input.iter().enumerate() {
483 if file.blob_hash.len() != 64
484 || !file.blob_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
485 {
486 return Err(V2Error::InvalidTree("invalid blob hash".to_string()));
487 }
488 let normalized = ContentFile {
489 path: paths[index].clone(),
490 blob_hash: file.blob_hash.clone(),
491 bytes: file.bytes,
492 };
493 files.insert(normalized.path.clone(), normalized.clone());
494 let components = normalized.path.split('/').collect::<Vec<_>>();
495 let mut current = &mut directory;
496 for component in &components[..components.len() - 1] {
497 current = current.dirs.entry((*component).to_string()).or_default();
498 }
499 current.files.insert(
500 components.last().expect("path has a component").to_string(),
501 normalized,
502 );
503 }
504
505 fn recurse(
506 directory: &Directory,
507 prefix: &str,
508 prior: Option<&BuiltTree>,
509 nonce_factory: &mut impl FnMut() -> String,
510 nodes: &mut BTreeMap<String, Vec<u8>>,
511 states: &mut BTreeMap<String, EntryState>,
512 ) -> V2Result<Option<String>> {
513 let mut entries = Vec::new();
514 for (name, child) in &directory.dirs {
515 let path = if prefix.is_empty() {
516 name.clone()
517 } else {
518 format!("{prefix}/{name}")
519 };
520 let Some(child_hash) = recurse(child, &path, prior, nonce_factory, nodes, states)?
521 else {
522 continue;
523 };
524 let old = prior.and_then(|tree| tree.entries.get(&path));
525 let unchanged = old.is_some_and(|state| {
526 state.entry.name == *name
527 && state.entry.kind == EntryKind::Tree
528 && state.entry.child_hash == child_hash
529 && state.entry.bytes.is_none()
530 });
531 let nonce = if unchanged {
532 old.expect("checked").entry.nonce.clone()
533 } else {
534 next_nonce(nonce_factory, old.map(|state| state.entry.nonce.as_str()))?
535 };
536 let entry = TreeEntry {
537 name: name.clone(),
538 kind: EntryKind::Tree,
539 child_hash,
540 bytes: None,
541 nonce,
542 };
543 states.insert(
544 path.clone(),
545 EntryState {
546 path,
547 entry: entry.clone(),
548 },
549 );
550 entries.push(entry);
551 }
552 for (name, file) in &directory.files {
553 let path = if prefix.is_empty() {
554 name.clone()
555 } else {
556 format!("{prefix}/{name}")
557 };
558 let old = prior.and_then(|tree| tree.entries.get(&path));
559 let unchanged = old.is_some_and(|state| {
560 state.entry.name == *name
561 && state.entry.kind == EntryKind::Blob
562 && state.entry.child_hash == file.blob_hash
563 && state.entry.bytes == Some(file.bytes)
564 });
565 let nonce = if unchanged {
566 old.expect("checked").entry.nonce.clone()
567 } else {
568 next_nonce(nonce_factory, old.map(|state| state.entry.nonce.as_str()))?
569 };
570 let entry = TreeEntry {
571 name: name.clone(),
572 kind: EntryKind::Blob,
573 child_hash: file.blob_hash.clone(),
574 bytes: Some(file.bytes),
575 nonce,
576 };
577 states.insert(
578 path.clone(),
579 EntryState {
580 path,
581 entry: entry.clone(),
582 },
583 );
584 entries.push(entry);
585 }
586 build_hamt(&entries, nodes)
587 }
588
589 let mut nodes = BTreeMap::new();
590 let mut entries = BTreeMap::new();
591 let root = recurse(
592 &directory,
593 "",
594 prior,
595 nonce_factory,
596 &mut nodes,
597 &mut entries,
598 )?;
599 Ok(BuiltTree {
600 root,
601 nodes,
602 entries,
603 files,
604 })
605}
606
607#[derive(Debug, Clone, Deserialize, Serialize)]
608#[serde(tag = "kind", rename_all = "snake_case")]
609pub enum ProofFrame {
610 Branch {
611 depth: usize,
612 slot: u8,
613 siblings: Vec<(u8, String)>,
614 },
615 Compressed {
616 depth: usize,
617 run: String,
618 },
619}
620
621#[derive(Debug, Clone, Deserialize, Serialize)]
622#[serde(tag = "kind", rename_all = "snake_case")]
623pub enum NonInclusionTerminal {
624 EmptyBranch {
625 depth: usize,
626 slot: u8,
627 siblings: Vec<(u8, String)>,
628 },
629 CompressedMismatch {
630 depth: usize,
631 run: String,
632 child: String,
633 },
634}
635
636#[derive(Debug, Clone, Deserialize, Serialize)]
637#[serde(tag = "kind", rename_all = "snake_case")]
638pub enum HamtProof {
639 Inclusion {
640 entry: TreeEntry,
641 route: String,
642 frames: Vec<ProofFrame>,
643 },
644 NonInclusion {
645 name: String,
646 route: String,
647 terminal: NonInclusionTerminal,
648 frames: Vec<ProofFrame>,
649 },
650}
651
652pub fn create_proof(
653 root: &str,
654 name: &str,
655 nodes: &BTreeMap<String, Vec<u8>>,
656) -> V2Result<HamtProof> {
657 let route = sha256_hex(name.nfc().collect::<String>().as_bytes());
658 let mut frames = Vec::new();
659 let mut hash = root.to_string();
660 loop {
661 let bytes = nodes
662 .get(&hash)
663 .ok_or_else(|| V2Error::MissingNode(hash.clone()))?;
664 let node = decode_node(bytes)?;
665 if hash_node(&node)? != hash {
666 return Err(V2Error::InvalidTree("node address mismatch".to_string()));
667 }
668 match node {
669 HamtNode::Leaf {
670 route: leaf_route,
671 entry,
672 } => {
673 if leaf_route != route || entry.name != name {
674 return Err(V2Error::InvalidTree(
675 "cryptographic name-route collision".to_string(),
676 ));
677 }
678 return Ok(HamtProof::Inclusion {
679 entry,
680 route,
681 frames,
682 });
683 }
684 HamtNode::Compressed { depth, run, child } => {
685 if route[depth..depth + run.len()] != run {
686 return Ok(HamtProof::NonInclusion {
687 name: name.to_string(),
688 route,
689 terminal: NonInclusionTerminal::CompressedMismatch { depth, run, child },
690 frames,
691 });
692 }
693 frames.push(ProofFrame::Compressed {
694 depth,
695 run: run.clone(),
696 });
697 hash = child;
698 }
699 HamtNode::Branch { depth, children } => {
700 let slot = u8::from_str_radix(&route[depth..=depth], 16)
701 .map_err(|_| V2Error::InvalidTree("invalid route nibble".to_string()))?;
702 let child = children.iter().find(|(candidate, _)| *candidate == slot);
703 let siblings = children
704 .iter()
705 .filter(|(candidate, _)| *candidate != slot)
706 .cloned()
707 .collect::<Vec<_>>();
708 let Some((_, child_hash)) = child else {
709 return Ok(HamtProof::NonInclusion {
710 name: name.to_string(),
711 route,
712 terminal: NonInclusionTerminal::EmptyBranch {
713 depth,
714 slot,
715 siblings,
716 },
717 frames,
718 });
719 };
720 frames.push(ProofFrame::Branch {
721 depth,
722 slot,
723 siblings,
724 });
725 hash = child_hash.clone();
726 }
727 }
728 }
729}
730
731pub fn verify_proof(root: &str, name: &str, proof: &HamtProof) -> V2Result<bool> {
732 verify_proof_with_domain(root, name, proof, CONTENT_TREE_HASH_DOMAIN)
733}
734
735pub fn verify_proof_with_domain(
736 root: &str,
737 name: &str,
738 proof: &HamtProof,
739 domain: &str,
740) -> V2Result<bool> {
741 let normalized = name.nfc().collect::<String>();
742 let route = sha256_hex(normalized.as_bytes());
743 let (mut current, frames) = match proof {
744 HamtProof::Inclusion {
745 entry,
746 route: proof_route,
747 frames,
748 } => {
749 if proof_route != &route || entry.name != normalized {
750 return Ok(false);
751 }
752 (
753 hash_node_with_domain(
754 &HamtNode::Leaf {
755 route: route.clone(),
756 entry: entry.clone(),
757 },
758 domain,
759 )?,
760 frames,
761 )
762 }
763 HamtProof::NonInclusion {
764 route: proof_route,
765 terminal,
766 frames,
767 ..
768 } => {
769 if proof_route != &route {
770 return Ok(false);
771 }
772 let hash = match terminal {
773 NonInclusionTerminal::CompressedMismatch { depth, run, child } => {
774 if route[*depth..*depth + run.len()] == *run {
775 return Ok(false);
776 }
777 hash_node_with_domain(
778 &HamtNode::Compressed {
779 depth: *depth,
780 run: run.clone(),
781 child: child.clone(),
782 },
783 domain,
784 )?
785 }
786 NonInclusionTerminal::EmptyBranch {
787 depth,
788 slot,
789 siblings,
790 } => {
791 let wanted = u8::from_str_radix(&route[*depth..=*depth], 16)
792 .map_err(|_| V2Error::InvalidTree("invalid route nibble".to_string()))?;
793 if wanted != *slot || siblings.iter().any(|(candidate, _)| candidate == slot) {
794 return Ok(false);
795 }
796 hash_node_with_domain(
797 &HamtNode::Branch {
798 depth: *depth,
799 children: siblings.clone(),
800 },
801 domain,
802 )?
803 }
804 };
805 (hash, frames)
806 }
807 };
808 for frame in frames.iter().rev() {
809 current = match frame {
810 ProofFrame::Compressed { depth, run } => hash_node_with_domain(
811 &HamtNode::Compressed {
812 depth: *depth,
813 run: run.clone(),
814 child: current,
815 },
816 domain,
817 )?,
818 ProofFrame::Branch {
819 depth,
820 slot,
821 siblings,
822 } => {
823 let mut children = siblings.clone();
824 children.push((*slot, current));
825 children.sort_by_key(|(candidate, _)| *candidate);
826 if children.windows(2).any(|window| window[0].0 == window[1].0) {
827 return Ok(false);
828 }
829 hash_node_with_domain(
830 &HamtNode::Branch {
831 depth: *depth,
832 children,
833 },
834 domain,
835 )?
836 }
837 }
838 }
839 Ok(current == root)
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[derive(Deserialize)]
847 struct PathCorpus {
848 v: u8,
849 valid: Vec<String>,
850 invalid: Vec<String>,
851 invalid_sets: Vec<Vec<String>>,
852 }
853
854 fn nonce_sequence() -> impl FnMut() -> String {
855 let mut value = 0u128;
856 move || {
857 let nonce = format!("{value:032x}");
858 value += 1;
859 nonce
860 }
861 }
862
863 fn file(path: &str, bytes: &[u8]) -> ContentFile {
864 ContentFile {
865 path: path.to_string(),
866 blob_hash: sha256_hex(bytes),
867 bytes: bytes.len() as u64,
868 }
869 }
870
871 #[test]
872 fn canonical_tree_and_proofs() {
873 let mut nonces = nonce_sequence();
874 let tree = build_content_tree(
875 &[
876 file("DB.md", b"db"),
877 file("secret.md", b"secret"),
878 file("visible.md", b"visible"),
879 ],
880 None,
881 &mut nonces,
882 )
883 .unwrap();
884 let root = tree.root.as_deref().unwrap();
885 assert_eq!(
886 root,
887 "82bcc02847453aac64310ad0ab83a2cce3f79ec7bac7664f0e6760cb38bc3d53"
888 );
889 let proof = create_proof(root, "visible.md", &tree.nodes).unwrap();
890 assert!(verify_proof(root, "visible.md", &proof).unwrap());
891 let encoded = serde_json::to_string(&proof).unwrap();
892 assert!(!encoded.contains("secret.md"));
893 assert!(!encoded.contains(&tree.entries["secret.md"].entry.nonce));
894 let missing = create_proof(root, "missing.md", &tree.nodes).unwrap();
895 assert!(verify_proof(root, "missing.md", &missing).unwrap());
896 }
897
898 #[test]
899 fn changed_sibling_rotates_the_nonce_hidden_from_retained_readers() {
900 let vector: serde_json::Value =
901 serde_json::from_str(include_str!("../tests/vectors/linkmd-v2-content-tree.json"))
902 .unwrap();
903 let privacy = &vector["changed_sibling"];
904 let mut before_nonces = nonce_sequence();
905 let before = build_content_tree(
906 &[
907 file("DB.md", b"db"),
908 file("secret.md", b"old secret"),
909 file("visible.md", b"visible"),
910 ],
911 None,
912 &mut before_nonces,
913 )
914 .unwrap();
915 assert_eq!(before.root.as_deref().unwrap(), privacy["before_root"]);
916 let changed_path = privacy["changed_path"].as_str().unwrap();
917 let old_nonce = before.entries[changed_path].entry.nonce.clone();
918 let mut value =
919 u128::from_str_radix(privacy["after_nonce_start_hex"].as_str().unwrap(), 16).unwrap();
920 let mut after_nonces = move || {
921 let nonce = format!("{value:032x}");
922 value += 1;
923 nonce
924 };
925 let after = build_content_tree(
926 &[
927 file("DB.md", b"db"),
928 file("secret.md", b"new secret"),
929 file("visible.md", b"visible"),
930 ],
931 Some(&before),
932 &mut after_nonces,
933 )
934 .unwrap();
935 assert_eq!(after.root.as_deref().unwrap(), privacy["after_root"]);
936 let new_nonce = &after.entries[changed_path].entry.nonce;
937 assert_eq!(old_nonce, privacy["old_nonce"].as_str().unwrap());
938 assert_eq!(new_nonce, privacy["new_nonce"].as_str().unwrap());
939 let root = after.root.as_deref().unwrap();
940 let proof_target = privacy["proof_target"].as_str().unwrap();
941 let proof = create_proof(root, proof_target, &after.nodes).unwrap();
942 assert!(verify_proof(root, proof_target, &proof).unwrap());
943 let encoded = serde_json::to_string(&proof).unwrap();
944 for forbidden in privacy["privacy_forbidden_strings"].as_array().unwrap() {
945 assert!(!encoded.contains(forbidden.as_str().unwrap()));
946 }
947 }
948
949 #[test]
950 fn rejects_portability_collisions() {
951 assert!(validate_path_set(["Records/a.md", "records/a.md"]).is_err());
952 assert!(validate_path_set(["records", "records/a.md"]).is_err());
953 assert!(normalize_path("CON").is_err());
954 assert!(validate_path_set(["Å.md", "å.md"]).is_err());
955 for path in [
956 "/absolute",
957 "a/../b",
958 "a\\b",
959 "a:b",
960 "nul.txt",
961 "folder/COM9.log",
962 "folder/LPT1",
963 "a.",
964 "a ",
965 "e\u{301}.md",
966 "control\u{1}.md",
967 ] {
968 assert!(normalize_path(path).is_err(), "accepted {path:?}");
969 }
970 for path in [
971 "é.md",
972 ".hidden.md",
973 "records/COM0.md",
974 "records/LPT10.md",
975 "records/emoji-🦓.md",
976 ] {
977 assert_eq!(normalize_path(path).unwrap(), path);
978 }
979 assert!(normalize_path(&format!("{}.md", "a".repeat(256))).is_err());
980 assert!(normalize_path(&format!("{}x.md", "a/".repeat(512))).is_err());
981 }
982
983 #[test]
984 fn shared_portable_path_corpus_matches_rust() {
985 let corpus: PathCorpus = serde_json::from_str(include_str!(
986 "../tests/vectors/linkmd-v2-portable-paths.json"
987 ))
988 .unwrap();
989 assert_eq!(corpus.v, 1);
990 for path in corpus.valid {
991 assert_eq!(normalize_path(&path).unwrap(), path);
992 }
993 for path in corpus.invalid {
994 assert!(normalize_path(&path).is_err(), "accepted {path:?}");
995 }
996 for paths in corpus.invalid_sets {
997 assert!(validate_path_set(paths.iter().map(String::as_str)).is_err());
998 }
999 }
1000
1001 #[test]
1002 fn randomized_map_build_is_order_independent() {
1003 let mut random_state = 0x51e7_9b3d_u32;
1004 let mut random = || {
1005 random_state = random_state
1006 .wrapping_mul(1_664_525)
1007 .wrapping_add(1_013_904_223);
1008 random_state
1009 };
1010 let mut model = BTreeMap::from([("DB.md".to_string(), b"contract".to_vec())]);
1011 for step in 0..400 {
1012 let path = format!("records/property/{:02}.md", random() % 64);
1013 if model.contains_key(&path) && random() % 4 == 0 {
1014 model.remove(&path);
1015 } else {
1016 model.insert(path, format!("value:{step}:{}", random()).into_bytes());
1017 }
1018 let files = model
1019 .iter()
1020 .map(|(path, bytes)| file(path, bytes))
1021 .collect::<Vec<_>>();
1022 let mut forward_nonces = nonce_sequence();
1023 let forward = build_content_tree(&files, None, &mut forward_nonces).unwrap();
1024 let mut reverse_files = files.clone();
1025 reverse_files.reverse();
1026 let mut reverse_nonces = nonce_sequence();
1027 let reverse = build_content_tree(&reverse_files, None, &mut reverse_nonces).unwrap();
1028 assert_eq!(forward.root, reverse.root);
1029 let root = forward.root.as_deref().unwrap();
1030 let proof = create_proof(root, "records", &forward.nodes).unwrap();
1031 assert!(verify_proof(root, "records", &proof).unwrap());
1032 }
1033 }
1034}