Skip to main content

eska_presentation/
encoding.rs

1//! Reversible text encodings shared by the versioned CLI JSON contracts.
2
3use std::{ffi::OsStr, fmt::Write as _};
4
5/// Keep valid UTF-8 Git text exact and percent-encode every byte otherwise.
6#[must_use]
7pub fn json_git_text(value: &[u8]) -> (String, &'static str) {
8    std::str::from_utf8(value).map_or_else(
9        |_| (percent_encode_bytes(value), "percent"),
10        |value| (value.to_owned(), "utf-8"),
11    )
12}
13
14/// Preserve a UTF-8 path directly and use a reversible platform encoding otherwise.
15#[must_use]
16pub fn json_path(path: &OsStr) -> (String, &'static str) {
17    path.to_str()
18        .map_or_else(|| encoded_path(path), |value| (value.to_owned(), "utf-8"))
19}
20
21#[cfg(unix)]
22/// Preserve raw Unix path bytes using the same encoding as arbitrary Git text.
23fn encoded_path(path: &OsStr) -> (String, &'static str) {
24    use std::os::unix::ffi::OsStrExt;
25
26    (percent_encode_bytes(path.as_bytes()), "percent")
27}
28
29#[cfg(windows)]
30/// Preserve every UTF-16 code unit, including unpaired Windows surrogates.
31fn encoded_path(path: &OsStr) -> (String, &'static str) {
32    use std::os::windows::ffi::OsStrExt;
33
34    let mut encoded = String::new();
35    for unit in path.encode_wide() {
36        write!(encoded, "%{unit:04X}").expect("writing to String cannot fail");
37    }
38    (encoded, "utf-16-percent")
39}
40
41/// Encode all bytes, including literal percent signs, so decoding is unambiguous.
42fn percent_encode_bytes(value: &[u8]) -> String {
43    let mut encoded = String::with_capacity(value.len() * 3);
44    for byte in value {
45        write!(encoded, "%{byte:02X}").expect("writing to String cannot fail");
46    }
47    encoded
48}
49
50#[cfg(test)]
51mod tests {
52    use super::{json_git_text, json_path};
53    use std::ffi::OsStr;
54
55    /// Unicode, percent signs and empty strings retain their original JSON values.
56    #[test]
57    fn unicode_text_and_paths_are_preserved_verbatim() {
58        for value in ["", "src/Заказы 100%.bsl", "emoji-😀", "line\nnext"] {
59            let expected = (value.to_owned(), "utf-8");
60            assert_eq!(json_git_text(value.as_bytes()), expected);
61            assert_eq!(json_path(OsStr::new(value)), expected);
62        }
63    }
64
65    /// The fallback encodes every byte with uppercase hex, not URI escaping rules.
66    #[test]
67    fn non_utf8_git_text_has_a_reversible_encoding() {
68        assert_eq!(
69            json_git_text(b"raw-%\xFF"),
70            ("%72%61%77%2D%25%FF".to_owned(), "percent")
71        );
72        let bytes: Vec<_> = (0..=u8::MAX).collect();
73        let (encoded, encoding) = json_git_text(&bytes);
74        assert_eq!(encoding, "percent");
75        let decoded: Vec<_> = encoded
76            .split('%')
77            .skip(1)
78            .map(|byte| u8::from_str_radix(byte, 16).expect("encoded byte"))
79            .collect();
80        assert_eq!(decoded, bytes);
81    }
82
83    /// Native Unix paths and Git text use identical byte encodings.
84    #[cfg(unix)]
85    #[test]
86    fn non_utf8_unix_path_preserves_native_bytes() {
87        use std::os::unix::ffi::OsStrExt;
88
89        let bytes = b"src/%\xFF.bsl";
90        assert_eq!(json_path(OsStr::from_bytes(bytes)), json_git_text(bytes));
91    }
92
93    /// Windows fallback preserves code units that cannot be converted to UTF-8.
94    #[cfg(windows)]
95    #[test]
96    fn windows_path_preserves_unpaired_surrogates() {
97        use std::{ffi::OsString, os::windows::ffi::OsStringExt};
98
99        let path = OsString::from_wide(&[0x0043, 0x003A, 0x005C, 0xD800, 0x0025]);
100        assert_eq!(
101            json_path(&path),
102            ("%0043%003A%005C%D800%0025".to_owned(), "utf-16-percent")
103        );
104    }
105}