Skip to main content

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}