morphir_core/naming/stem.rs
1//! Document-tree file-stem escaping and length truncation.
2//!
3//! A [`Name`] projects onto a filename stem via [`Name::to_file_stem`]; [`file_stem`]
4//! is the free-function form of that projection. [`escaped_path`] extends the
5//! projection across a [`Path`], joining each segment's escaped stem with `/`.
6//!
7//! A path that exceeds the filesystem's length budget is shortened by
8//! [`truncate_stem`]: it keeps a prefix of the escaped stem, trims a trailing
9//! separator that the cut may have exposed, and appends a short content hash so
10//! two names that share a long common prefix still truncate to distinct stems.
11//! A budget too small to hold that hash and a character of the name is refused
12//! rather than overrun — see [`MIN_TRUNCATED_STEM_BUDGET`].
13
14use sha2::{Digest, Sha256};
15
16use super::{Name, Path};
17
18/// The filename stem `name` projects onto in a document tree.
19///
20/// See [`Name::to_file_stem`] for the escaping rule: an initialism segment
21/// carries a `_` prefix, and a stem that collides with a Windows reserved
22/// device name carries a `_` suffix.
23pub fn file_stem(name: &Name) -> String {
24 name.to_file_stem()
25}
26
27/// The escaped filesystem path `path` projects onto, joining each segment's
28/// [`file_stem`] with `/`.
29pub fn escaped_path(path: &Path) -> String {
30 path.segments
31 .iter()
32 .map(file_stem)
33 .collect::<Vec<_>>()
34 .join("/")
35}
36
37/// The smallest `available` budget [`truncate_stem`] can shorten a stem into.
38///
39/// A truncated stem is `__` plus eight hex digits of the content hash, which is
40/// ten characters that carry nothing of the name, plus at least one character of
41/// the name itself. Anything smaller has no truncation to offer: the answer
42/// would be the hash alone, which no longer reads as the name it stands for.
43pub const MIN_TRUNCATED_STEM_BUDGET: usize = 11;
44
45/// Shorten an escaped stem to fit within `available` characters, or `None` when
46/// `available` is below [`MIN_TRUNCATED_STEM_BUDGET`].
47///
48/// Keeps the first `available - 10` characters of `escaped`, trims a trailing
49/// run of `-` or `_` that the cut exposed, and appends `__` followed by the
50/// first 8 hex characters of the SHA-256 digest of the untruncated `escaped`
51/// stem. The hash keeps stems that share a long common prefix distinct after
52/// truncation.
53///
54/// The result always fits in `available` characters. A budget that cannot hold
55/// the hash and a character of the name is refused rather than answered with a
56/// stem that overruns it, so a caller holding a real path budget has to say what
57/// it does about a name it has no room for.
58pub fn truncate_stem(escaped: &str, available: usize) -> Option<String> {
59 if available < MIN_TRUNCATED_STEM_BUDGET {
60 return None;
61 }
62 let keep = available - 10;
63 let prefix: String = escaped.chars().take(keep).collect();
64 let trimmed = prefix.trim_end_matches(['-', '_']);
65
66 Some(format!("{trimmed}__{}", short_hash(escaped)))
67}
68
69/// Whether `stem` is the file stem of the name that escapes to `escaped`: the escaped stem
70/// itself, or a cut [`truncate_stem`] could have made of it under some budget.
71///
72/// A reader that finds a cut stem cannot recover the name from it, but it can check a name the
73/// file states against the stem the file is under.
74pub fn is_stem_of(stem: &str, escaped: &str) -> bool {
75 if stem == escaped {
76 return true;
77 }
78 let Some((kept, hash)) = stem.rsplit_once("__") else {
79 return false;
80 };
81 !kept.is_empty() && escaped.starts_with(kept) && hash == short_hash(escaped)
82}
83
84/// The first eight hex digits of the SHA-256 digest of `escaped`.
85fn short_hash(escaped: &str) -> String {
86 let mut hasher = Sha256::new();
87 hasher.update(escaped.as_bytes());
88 let digest = hasher.finalize();
89 digest
90 .iter()
91 .take(4)
92 .map(|byte| format!("{byte:02x}"))
93 .collect()
94}