heddle_object_model/
name_encoding.rs1use std::path::{Path, PathBuf};
5
6pub 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
14pub 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
32pub 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
52pub const NAME_PATH_BUDGET: usize = 128;
57
58pub 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 path.push("git");
70 }
71 for chunk in encoded.as_bytes()[2..].chunks(180) {
72 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
87pub 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
102pub 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
129pub 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
139pub 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}