Skip to main content

heddle_object_model/
name_encoding.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Reversible UTF-8 names for portable, case-sensitive storage.
3
4use std::path::{Path, PathBuf};
5
6/// Remove only one LF or CRLF framing terminator, preserving name bytes.
7pub fn strip_ref_line_ending(value: &str) -> &str {
8    value
9        .strip_suffix("\r\n")
10        .or_else(|| value.strip_suffix('\n'))
11        .unwrap_or(value)
12}
13
14/// Percent escaping shared by ref storage and managed checkouts. Uppercase
15/// ASCII is escaped too: distinct names stay distinct on case-folding hosts.
16/// The `n-` prefix excludes Windows device names; dots and hostile bytes escape.
17pub fn encode_name(value: &str) -> String {
18    const HEX: &[u8; 16] = b"0123456789ABCDEF";
19    let mut out = String::from("n-");
20    for &byte in value.as_bytes() {
21        if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') {
22            out.push(byte as char);
23        } else {
24            out.push('%');
25            out.push(HEX[(byte >> 4) as usize] as char);
26            out.push(HEX[(byte & 15) as usize] as char);
27        }
28    }
29    out
30}
31
32/// Strict inverse; reject aliases and malformed/non-UTF-8 encodings.
33pub fn decode_name(encoded: &str) -> Option<String> {
34    let bytes = encoded.strip_prefix("n-")?.as_bytes();
35    let mut out = Vec::with_capacity(bytes.len());
36    let mut index = 0;
37    while index < bytes.len() {
38        if bytes[index] == b'%' {
39            let hi = (*bytes.get(index + 1)? as char).to_digit(16)?;
40            let lo = (*bytes.get(index + 2)? as char).to_digit(16)?;
41            out.push((hi * 16 + lo) as u8);
42            index += 3;
43        } else {
44            out.push(bytes[index]);
45            index += 1;
46        }
47    }
48    let value = String::from_utf8(out).ok()?;
49    (encode_name(&value) == encoded).then_some(value)
50}
51
52/// Maximum relative name path in bytes. With a 512-byte repository root,
53/// `.heddle/threads/` (17), a 255-byte checkout leaf plus separator (256),
54/// and 111 bytes for checkout-local Heddle metadata, the absolute path is
55/// at most 1024 bytes. Two encoded names in remote refs also fit this budget.
56pub const NAME_PATH_BUDGET: usize = 128;
57
58/// Bounded components and a terminal directory keep even long names disjoint.
59/// Each chunk is ASCII, at most 182 bytes; `entry` can never be another chunk.
60/// Long names use the full BLAKE3 digest of the exact native UTF-8 identity.
61/// Their entry must carry a `name` file, verified by the filesystem reader.
62pub fn name_path(value: &str) -> PathBuf {
63    let source = git_name(value);
64    let encoded = encode_name(&source);
65    let mut path = PathBuf::new();
66    if matches!(source, std::borrow::Cow::Owned(_)) {
67        // Encode the original only once, keeping maximum-length imported
68        // names below total path limits as well as component limits.
69        path.push("git");
70    }
71    for chunk in encoded.as_bytes()[2..].chunks(180) {
72        // The encoding is ASCII; collecting bytes avoids unchecked decoding.
73        let chunk: String = chunk.iter().map(|byte| *byte as char).collect();
74        path.push(format!("n-{chunk}"));
75    }
76    path.push("entry");
77    if path.as_os_str().len() > NAME_PATH_BUDGET {
78        PathBuf::from(format!(
79            "h-{}/entry",
80            blake3::hash(value.as_bytes()).to_hex()
81        ))
82    } else {
83        path
84    }
85}
86
87/// Recognize the complete digest path; never accept a digest prefix or alias.
88pub fn is_digest_name_path(path: &Path) -> bool {
89    let mut parts = path.components();
90    let Some(digest) = parts.next().and_then(|part| part.as_os_str().to_str()) else {
91        return false;
92    };
93    digest.strip_prefix("h-").is_some_and(|hex| {
94        hex.len() == 64
95            && hex
96                .bytes()
97                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
98    }) && parts.next().is_some_and(|part| part.as_os_str() == "entry")
99        && parts.next().is_none()
100}
101
102/// Decode only canonical storage paths, including exact UTF-8 identity.
103pub fn decode_name_path(path: &Path) -> Option<String> {
104    let mut parts = path.components().peekable();
105    let imported = parts.peek().is_some_and(|part| part.as_os_str() == "git");
106    if imported {
107        parts.next();
108    }
109    let mut encoded = String::from("n-");
110    while let Some(part) = parts.next() {
111        let part = part.as_os_str().to_str()?;
112        if parts.peek().is_none() {
113            if part != "entry" {
114                return None;
115            }
116        } else {
117            encoded.push_str(part.strip_prefix("n-")?);
118        }
119    }
120    let source = decode_name(&encoded)?;
121    let value = if imported {
122        native_git_name(&source)
123    } else {
124        source
125    };
126    (name_path(&value) == path).then_some(value)
127}
128
129/// Map Git names that collide with native reservation (or the escape prefix)
130/// into a distinct native identity. Signed source refs always keep their bytes.
131pub fn native_git_name(name: &str) -> String {
132    if crate::object::is_reserved_heddle_namespace(name) || name.starts_with("git%") {
133        format!("git%{}", encode_name(name))
134    } else {
135        name.to_owned()
136    }
137}
138
139/// Undo the reserved import mapping when projecting a native name back to Git.
140pub fn git_name(native: &str) -> std::borrow::Cow<'_, str> {
141    match native.strip_prefix("git%").and_then(decode_name) {
142        Some(name) if native_git_name(&name) == native => std::borrow::Cow::Owned(name),
143        _ => std::borrow::Cow::Borrowed(native),
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn portable_names_are_exact_bounded_and_case_distinct() {
153        for name in [
154            "CON",
155            "con",
156            "a,b",
157            "x'$(true)",
158            "a%2F",
159            "ünicode/ブランチ",
160            "trailing\u{a0}",
161            "literal\u{fffd}",
162            &"界".repeat(337),
163            &native_git_name(&format!("heddle/{}", "界".repeat(333))),
164        ] {
165            let path = name_path(name);
166            if is_digest_name_path(&path) {
167                assert!(decode_name_path(&path).is_none());
168            } else {
169                assert_eq!(decode_name_path(&path).as_deref(), Some(name));
170            }
171            assert!(path.components().all(|part| part.as_os_str().len() <= 182));
172            assert!(path.to_str().expect("ASCII path").is_ascii());
173            assert!(path.as_os_str().len() <= NAME_PATH_BUDGET);
174        }
175        assert_ne!(
176            name_path("CON").to_string_lossy().to_lowercase(),
177            name_path("con").to_string_lossy().to_lowercase()
178        );
179        let short = "x".repeat(178);
180        assert!(!name_path(&format!("{short}y")).starts_with(name_path(&short)));
181    }
182
183    #[test]
184    fn reserved_git_mapping_does_not_alias_literal_escape_names() {
185        for name in ["heddle/foo", "Heddle/foo", "git%n-heddle%2Ffoo", "a%2Fb"] {
186            let native = native_git_name(name);
187            assert!(!crate::object::is_reserved_heddle_namespace(&native));
188            assert_eq!(git_name(&native), name);
189        }
190        assert_ne!(
191            native_git_name("heddle/foo"),
192            native_git_name("git%n-heddle%2Ffoo")
193        );
194    }
195}