Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/fsname.rs

17.1 KiB, 1 run

created by r2519314175:947, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! One path component, spelled so that a browser's filesystem will accept it.
2//!
3//! A workspace path is Daimond's own idea and a filesystem's name is the platform's, and the two
4//! do not agree about which characters exist. A Maildir message is called
5//! `<uid>.<uidvalidity>.daimond:2,<flags>` — the `:2,` is the standard's, not ours — and that name
6//! is refused outright by the File System Access API on any root that is not a modern browser's
7//! sandbox:
8//!
9//! ```text
10//! TypeError: Failed to execute 'getFileHandle' on 'FileSystemDirectoryHandle':
11//! Name is not allowed.
12//! ```
13//!
14//! ## Which characters, and on the authority of what
15//!
16//! Three rules are in play and only the loosest of them is the web standard's:
17//!
18//! - The **File System Standard** defines a *valid file name* as "a string that is not an empty
19//! string, is not equal to `.` or `..`, and does not contain `/` or any other character used as
20//! path separator on the underlying platform" (<https://fs.spec.whatwg.org/>). That is the
21//! floor, and it is all a conforming engine must enforce.
22//! - **Chromium's sandbox (OPFS)** enforces exactly that floor plus `\`. Its
23//! `FileSystemAccessManagerImpl::IsSafePathComponent` returns early for
24//! `storage::kFileSystemTypeTemporary` with the comment "The names of files in sandboxed file
25//! systems are obfuscated before they end up on disk … We don't need to worry about
26//! platform-specific restrictions", testing only `!= "."`, `!= ".."`, and the absence of `/` and
27//! `\`. Measured on Chrome 149 and 150 and on Firefox 151: `70074.3.daimond:2,` is accepted.
28//! - **Every other root** — a real local folder opened through `showDirectoryPicker`, and
29//! Chromium's own sandbox before that early return was added — falls through to
30//! `base::i18n::IsFilenameLegal`, whose illegal set is
31//! the ICU pattern `[["*/:<>?\\|][:Cc:][:Cf:]]` (`base/i18n/file_util_icu.cc`), plus the
32//! non-characters, plus whitespace, `.` and `~` at either end.
33//!
34//! So the characters this module escapes are the printable members of that strict set plus the C0
35//! controls and `DEL`:
36//!
37//! ```text
38//! " * : < > ? \ | and 0x00-0x1F, 0x7F
39//! ```
40//!
41//! `/` is in the strict set and is NOT in that list, for a reason set out at [`is_reserved`]: a
42//! path component cannot contain one, so escaping it is dead code, and recognising `%2F` on the
43//! way back would split a name people really have into path components they never asked for.
44//!
45//! What it deliberately does NOT escape is the position-dependent part of the strict rule — a
46//! leading or trailing space, `.` or `~`. Those are legal in every sandbox, `foo.txt~` is a name
47//! people really have, and escaping them would change a name that is already on disk for no
48//! reported gain. A real folder still refuses them; that is a separate defect and this is not it.
49//!
50//! ## The shape, and why the escape is conditional
51//!
52//! Percent escapes, because they are the one convention a reader recognises on sight, and the
53//! store is meant to be legible by eye when something goes wrong. Only the offending bytes are
54//! touched, so `crystal.json` is `crystal.json` on disk and every ordinary name is byte-identical
55//! to what it was before this module existed.
56//!
57//! The marker has to be escapable or the encoding is not reversible — but escaping every `%` would
58//! rewrite `file%20name`, which is a legal name somebody may already have, and rewriting a name
59//! that is already on disk is a migration. **No injective encoding can be the identity on every
60//! legal name**: the identity on the legal set already uses the whole legal set as its image, so
61//! there is nowhere left for an illegal name to go. Something has to move, and the choice here is
62//! to move as little as possible: `%` is escaped **only when the three characters starting at it
63//! spell an escape this codec could itself have emitted** — `%25`, or `%` followed by the
64//! upper-case hex of one of the reserved bytes above. `file%20name` is untouched (`%20` is a
65//! space, which is not reserved here); `a%3Ab` is not (it would decode to `a:b`). Lower case is
66//! never recognised, because the encoder never emits it, so `a%3ab` is untouched too.
67//!
68//! ## Length
69//!
70//! No cap is imposed. Measured, a name of 8192 characters is accepted by Chromium's and Firefox's
71//! sandbox, and imposing a limit here would newly refuse long names that work today. A real
72//! folder is bounded by the platform (255 bytes per component on Linux and macOS) and an escape
73//! costs two bytes; a Maildir name is about twenty-five characters, so the inflation that matters
74//! to the defect this module fixes is nil.
75
76use oxedyne_fe2o3_core::prelude::*;
77
78
79/// The escape marker.
80const MARK: u8 = b'%';
81
82/// Whether a byte must be escaped to survive a filesystem that applies the strict rule.
83///
84/// The C0 controls and `DEL` are Unicode category Cc; the eight punctuation marks are the printable
85/// part of ICU's `illegal_anywhere_` set. All are ASCII, so a multi-byte UTF-8 sequence can never
86/// contain one and passes through untouched.
87///
88/// `/` IS ABSENT AND THAT IS THE POINT. It is in ICU's set, but a path component cannot contain
89/// one — the splitter in [`crate::wasm::opfs`] consumes every separator before a name is formed —
90/// so escaping it would be dead code, while RECOGNISING `%2F` on the way back would not be.
91/// `https%3A%2F%2Fexample.com.html` is a name people really have, from saving a page under its
92/// URL; decoding its `%2F` would hand a caller `https://example.com.html`, which the next path
93/// join reads as three components and opens nothing. Left out, that name decodes to something
94/// harmless and encodes straight back to itself, so the file still opens.
95pub fn is_reserved(b: u8) -> bool {
96 matches!(b,
97 0x00..=0x1F
98 | 0x7F
99 | b'"'
100 | b'*'
101 | b':'
102 | b'<'
103 | b'>'
104 | b'?'
105 | b'\\'
106 | b'|')
107}
108
109/// The upper-case hexadecimal digit for a nibble.
110fn hex_digit(n: u8) -> u8 {
111 match n {
112 0..=9 => b'0' + n,
113 _ => b'A' + (n - 10),
114 }
115}
116
117/// The value of an upper-case hexadecimal digit, or `None` for anything else.
118///
119/// Lower case is refused on purpose: the encoder emits upper case only, so refusing lower case
120/// here leaves `a%3ab` alone rather than reading it as an escape somebody never wrote.
121fn hex_value(b: u8) -> Option<u8> {
122 match b {
123 b'0'..=b'9' => Some(b - b'0'),
124 b'A'..=b'F' => Some(10 + (b - b'A')),
125 _ => None,
126 }
127}
128
129/// The byte an escape at the head of `w` stands for, or `None` when `w` does not begin with one
130/// this codec could have emitted.
131///
132/// The recognised set is exactly the encoder's output alphabet: the reserved bytes, and `%25` for
133/// the marker itself. Anything else beginning with `%` is a literal percent sign.
134fn escaped(w: &[u8]) -> Option<u8> {
135 if w.len() < 3 || w[0] != MARK {
136 return None;
137 }
138 let hi = match hex_value(w[1]) { Some(v) => v, None => return None };
139 let lo = match hex_value(w[2]) { Some(v) => v, None => return None };
140 let b = (hi << 4) | lo;
141 if is_reserved(b) || b == MARK {
142 Some(b)
143 } else {
144 None
145 }
146}
147
148/// Spell `name` so a filesystem will take it.
149///
150/// The identity on every name that holds none of the reserved bytes and none of this codec's own
151/// escape sequences, which is every ordinary name.
152///
153/// # Arguments
154/// * `name` - One path component, as the workspace spells it.
155pub fn encode(name: &str) -> String {
156 let src = name.as_bytes();
157 let mut out = String::with_capacity(src.len());
158 let mut run = 0; // start of the untouched run
159 let mut i = 0;
160 while i < src.len() {
161 let b = src[i];
162 // A reserved byte becomes its escape; a marker becomes one only when leaving it alone
163 // would let the decoder read the next three characters as an escape.
164 let esc = if is_reserved(b) {
165 Some(b)
166 } else if b == MARK && escaped(&src[i..]).is_some() {
167 Some(MARK)
168 } else {
169 None
170 };
171 if let Some(v) = esc {
172 // Both ends of the slice sit on an ASCII byte, so this can never split a character.
173 out.push_str(&name[run..i]);
174 out.push(MARK as char);
175 out.push(hex_digit(v >> 4) as char);
176 out.push(hex_digit(v & 0x0F) as char);
177 run = i + 1;
178 }
179 i += 1;
180 }
181 out.push_str(&name[run..]);
182 out
183}
184
185/// Read a stored name back as the workspace spells it. The exact inverse of [`encode`].
186///
187/// # Arguments
188/// * `name` - One path component, as it is stored.
189pub fn decode(name: &str) -> String {
190 let src = name.as_bytes();
191 let mut out = String::with_capacity(src.len());
192 let mut run = 0; // start of the untouched run
193 let mut i = 0;
194 while i < src.len() {
195 match escaped(&src[i..]) {
196 Some(v) => {
197 out.push_str(&name[run..i]);
198 out.push(v as char);
199 i += 3;
200 run = i;
201 }
202 None => i += 1,
203 }
204 }
205 out.push_str(&name[run..]);
206 out
207}
208
209/// Spell a whole workspace-relative path, component by component.
210///
211/// The separators are the path's own and are not names, so they are left where they are. Used by
212/// the callers that hold a path rather than a component; the filesystem edge itself works one
213/// component at a time.
214pub fn encode_path(path: &str) -> String {
215 path.split('/').map(encode).collect::<Vec<_>>().join("/")
216}
217
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 /// Every awkward name this codec is expected to meet, plus a few nobody would write on
224 /// purpose. The properties below are asserted over all of them, rather than any one of them
225 /// being asserted to encode to a particular string: a fixed expectation for one input proves
226 /// the codec agrees with whoever typed the expectation, and nothing else.
227 fn corpus() -> Vec<String> {
228 let mut v: Vec<String> = vec![
229 // The name that started it.
230 "70074.3.daimond:2,".into(),
231 "70074.3.daimond:2,S".into(),
232 "70074.3.daimond:2,FRS".into(),
233 // Ordinary names, which must come through untouched.
234 "crystal.json".into(),
235 "crystal.html".into(),
236 "meta.json".into(),
237 ".daimond".into(),
238 "a file with spaces.txt".into(),
239 "foo.txt~".into(),
240 "-".into(),
241 "a".into(),
242 // Already percent-shaped.
243 "file%20name".into(),
244 "100%".into(),
245 "%".into(),
246 "%%".into(),
247 "%2".into(),
248 "%25".into(),
249 "%253A".into(),
250 "%3A".into(),
251 "%3a".into(),
252 "a%3Ab".into(),
253 "https%3A%2F%2Fexample.com".into(),
254 // The reserved punctuation, alone and in company.
255 "\"".into(),
256 "*".into(),
257 "/".into(),
258 ":".into(),
259 "<".into(),
260 ">".into(),
261 "?".into(),
262 "\\".into(),
263 "|".into(),
264 "a\"b*c:d<e>f?g\\h|i".into(),
265 // The filesystem's own reserved words, and the empty name.
266 "".into(),
267 ".".into(),
268 "..".into(),
269 "...".into(),
270 // Not ASCII.
271 "é".into(),
272 "日本語.txt".into(),
273 "emoji-\u{1F600}.png".into(),
274 "Ω:Ω".into(),
275 // At the length limit a real folder imposes, and past it.
276 "x".repeat(255),
277 "x".repeat(256),
278 fmt!("{}:{}", "y".repeat(126), "y".repeat(128)),
279 ];
280 // Every byte value 0x00-0x7F, alone and inside a name.
281 for b in 0u8..0x80 {
282 let c = b as char;
283 v.push(c.to_string());
284 v.push(fmt!("a{}b", c));
285 v.push(fmt!("{}lead", c));
286 v.push(fmt!("trail{}", c));
287 }
288 v
289 }
290
291 /// The property the whole design rests on: nothing is lost on the way to disk and back.
292 #[test]
293 fn round_trip() {
294 for s in corpus() {
295 let there = encode(&s);
296 let back = decode(&there);
297 assert_eq!(back, s, "round trip failed for {:?} (encoded {:?})", s, there);
298 }
299 }
300
301 /// Two names must never become one, or two messages become one message.
302 ///
303 /// Asserted directly over the corpus rather than inferred from the round trip, because it is
304 /// the property a reader will want to see stated.
305 #[test]
306 fn injective() {
307 let all = corpus();
308 for (i, a) in all.iter().enumerate() {
309 for b in all.iter().skip(i + 1) {
310 if a == b {
311 continue;
312 }
313 assert_ne!(encode(a), encode(b),
314 "two names encode alike: {:?} and {:?}", a, b);
315 }
316 }
317 }
318
319 /// The encoded name is legal: no reserved byte survives it.
320 #[test]
321 fn output_is_legal() {
322 for s in corpus() {
323 let there = encode(&s);
324 for b in there.as_bytes() {
325 assert!(!is_reserved(*b),
326 "{:?} encoded to {:?}, which still holds byte {:#04X}", s, there, b);
327 }
328 }
329 }
330
331 /// Decoding must never produce a path separator that was not already there.
332 ///
333 /// A listing is decoded and the names in it are then joined back into paths. A name that
334 /// gained a `/` on the way out would be read as two components, and would address a file
335 /// nobody has.
336 #[test]
337 fn a_separator_is_never_conjured() {
338 for s in corpus() {
339 if s.contains('/') {
340 continue; // a component cannot hold one to begin with
341 }
342 let back = decode(&s);
343 assert!(!back.contains('/'),
344 "{:?} decoded to {:?}, which a path join would split", s, back);
345 }
346 // The name this rule was written for.
347 let url = "https%3A%2F%2Fexample.com.html";
348 assert!(!decode(url).contains('/'));
349 assert_eq!(encode(&decode(url)), url, "the file would stop opening");
350 }
351
352 /// An ordinary name is the same bytes it always was, so nothing already stored moves.
353 ///
354 /// This is the whole of the no-migration claim, and it is asserted rather than described.
355 #[test]
356 fn ordinary_names_are_untouched() {
357 let ordinary = [
358 "crystal.json", "crystal.html", "crystal.md", "meta.json", "notes.txt",
359 ".daimond", ".git", "index.md", "README.md", "INBOX", "cur", "new", "tmp",
360 "mail", "diamonds", "d~1a2b3c", "alice@example.com", "a file with spaces.txt",
361 "foo.txt~", "100%", "file%20name", "café.md", "日本語.txt", "v1.2.3-rc1",
362 "[Gmail]_All_Mail", "report (final).pdf", "x=1&y=2", "a,b,c", "a;b", "a#b",
363 "a!b", "a$b", "a&b", "a'b", "a(b)c", "a+b", "a=b", "a@b", "a[b]c", "a{b}c",
364 "a^b", "a`b", "a~b", "a_b", "a-b",
365 ];
366 for s in ordinary {
367 assert_eq!(encode(s), s, "{:?} would move on disk", s);
368 assert_eq!(decode(s), s, "{:?} would be read back as something else", s);
369 }
370 }
371
372 /// Decoding a name this codec never produced leaves it alone, which is what makes a store
373 /// written before the codec existed still readable.
374 #[test]
375 fn a_name_from_before_the_codec_reads_as_itself() {
376 for s in ["70074.3.daimond:2,S", "a:b", "a|b", "a?b", "file%20name", "100%", "%2"] {
377 assert_eq!(decode(s), s, "{:?} was read as something else", s);
378 }
379 }
380
381 /// Applying the codec twice does not encode twice, so a migration built on it — if one is ever
382 /// needed — cannot run away with the name.
383 #[test]
384 fn encode_of_decode_is_a_fixed_point() {
385 for s in corpus() {
386 let once = encode(&decode(&s));
387 let twice = encode(&decode(&once));
388 assert_eq!(once, twice, "{:?} did not settle: {:?} then {:?}", s, once, twice);
389 }
390 }
391
392 /// A path keeps its separators and loses nothing.
393 #[test]
394 fn a_whole_path_survives() {
395 let p = "mail/alice@example.com/INBOX/cur/70074.3.daimond:2,S";
396 let there = encode_path(p);
397 assert!(!there.contains(':'), "the colon survived: {}", there);
398 assert_eq!(there.split('/').count(), p.split('/').count());
399 let back = there.split('/').map(decode).collect::<Vec<_>>().join("/");
400 assert_eq!(back, p);
401 }
402
403 /// The one property the escape rule is tuned for: a name that already carries a percent
404 /// sequence the codec does not use stays exactly where it is.
405 #[test]
406 fn only_our_own_escapes_are_re_escaped() {
407 // `%20` is a space and space is not reserved, so nothing about it is ambiguous.
408 assert_eq!(encode("file%20name"), "file%20name");
409 assert_eq!(encode("a%41b"), "a%41b");
410 // `%3A` would decode to a colon, so the marker has to be escaped.
411 assert_eq!(encode("a%3Ab"), "a%253Ab");
412 assert_eq!(encode("a%25b"), "a%2525b");
413 // Lower case is not an escape, because the encoder never writes one.
414 assert_eq!(encode("a%3ab"), "a%3ab");
415 }
416}