Skip to main content

disk_core/
raw_name.rs

1//! Byte-exact on-disk names through the `&str` filesystem API.
2//!
3//! Unix filesystems store a name as bytes, not text. NeXTSTEP wrote its names
4//! in the 8-bit **NeXTSTEP encoding** (ASCII below 0x80; 0x80-0xFD are Latin
5//! letters and punctuation laid out unlike ISO 8859-1 — `0xF6` is `u` with a
6//! diaeresis, `0xBC` a horizontal ellipsis; see Unicode's
7//! `VENDORS/NEXT/NEXTSTEP.TXT`), and Japanese NeXTSTEP-J wrote EUC-JP. Neither
8//! is UTF-8, and which one a given disk used is not recorded anywhere, so a
9//! name cannot be transcoded safely — only carried.
10//!
11//! The filesystem traits pass names as `String`, so undecodable bytes travel as
12//! placeholder characters: byte `b` becomes `U+10FF00 + b`, the last 256 code
13//! points of Supplementary Private Use Area-B, which no real name uses.
14//! [`decode`] turns on-disk bytes into such a name (valid UTF-8 runs are kept
15//! as text), and [`encode`] turns it back into the exact bytes, so a name read
16//! off one disk and written to another lands byte for byte. A host file whose
17//! name carries the placeholders (an earlier extract) re-imports the same way.
18
19use std::borrow::Cow;
20
21/// First placeholder code point; byte `b` maps to `RAW_BASE + b`.
22const RAW_BASE: u32 = 0x0010_FF00;
23
24/// On-disk name bytes as a name: UTF-8 kept as text, every other byte a placeholder.
25pub fn decode(bytes: &[u8]) -> String {
26    let mut out = String::with_capacity(bytes.len());
27    let mut rest = bytes;
28    loop {
29        match std::str::from_utf8(rest) {
30            Ok(s) => {
31                out.push_str(s);
32                return out;
33            }
34            Err(e) => {
35                let (good, bad) = rest.split_at(e.valid_up_to());
36                out.push_str(std::str::from_utf8(good).unwrap_or_default());
37                let n = e.error_len().unwrap_or(bad.len());
38                out.extend(bad[..n].iter().map(|&b| placeholder(b)));
39                rest = &bad[n..];
40            }
41        }
42    }
43}
44
45/// The bytes to store for `name`: placeholders become their byte, everything else UTF-8.
46pub fn encode(name: &str) -> Cow<'_, [u8]> {
47    if !name.chars().any(|c| raw_byte(c).is_some()) {
48        return Cow::Borrowed(name.as_bytes());
49    }
50    let mut out = Vec::with_capacity(name.len());
51    let mut buf = [0u8; 4];
52    for c in name.chars() {
53        match raw_byte(c) {
54            Some(b) => out.push(b),
55            None => out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()),
56        }
57    }
58    Cow::Owned(out)
59}
60
61/// The byte a placeholder stands for, or `None` for an ordinary character.
62pub fn raw_byte(c: char) -> Option<u8> {
63    let v = u32::from(c);
64    (RAW_BASE..=RAW_BASE + 0xFF)
65        .contains(&v)
66        .then(|| (v - RAW_BASE) as u8)
67}
68
69fn placeholder(b: u8) -> char {
70    char::from_u32(RAW_BASE + u32::from(b)).expect("Private Use Area-B is valid")
71}
72
73/// `name` with each placeholder shown as `\xNN`, for terminal output.
74pub fn display(name: &str) -> Cow<'_, str> {
75    if !name.chars().any(|c| raw_byte(c).is_some()) {
76        return Cow::Borrowed(name);
77    }
78    let mut out = String::with_capacity(name.len());
79    for c in name.chars() {
80        match raw_byte(c) {
81            Some(b) => out.push_str(&format!("\\x{b:02X}")),
82            None => out.push(c),
83        }
84    }
85    Cow::Owned(out)
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn utf8_names_pass_through_untouched() {
94        assert_eq!(decode(b"NeXTSTEP_3.3"), "NeXTSTEP_3.3");
95        assert_eq!(decode("caf\u{e9}".as_bytes()), "caf\u{e9}");
96        assert!(matches!(encode("plain"), Cow::Borrowed(_)));
97    }
98
99    /// NeXTSTEP-encoded `u` with diaeresis (0xF6) and an EUC-JP run both survive the round trip.
100    #[test]
101    fn nextstep_and_euc_bytes_round_trip() {
102        for raw in [
103            &b"Steroidgrundger\xf6st.lookMol"[..],
104            &b"Eject\xbcE.tiff"[..],
105            &b"01OmniWeb \xa4\xd8\xa4\xe8\xa4\xa6\xa4\xb3\xa4\xbd.wdesk"[..],
106            &b"\xff\xfe"[..],
107        ] {
108            let name = decode(raw);
109            assert_eq!(&*encode(&name), raw, "{name:?}");
110        }
111    }
112}