Skip to main content

dbmd_core/
linkmd_v2.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Pure link.md wire-profile-v2 primitives.
4//!
5//! This module is deterministic protocol plumbing: portable paths, canonical
6//! domain-separated hashing, the bounded 16-way content HAMT, and hiding-proof
7//! verification. It performs no network I/O and contains no model dependency.
8
9use 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}