Skip to main content

vcs_diff/
pathbytes.rs

1//! Lossless raw-bytes → `OsString`/`PathBuf` bridge for filesystem paths taken
2//! from `git`/`jj` machine output.
3//!
4//! A filesystem path is *bytes*, not text: on Unix a filename can be any byte
5//! sequence except `/` and NUL, so it need not be valid UTF-8. Decoding such a
6//! path through [`String::from_utf8_lossy`] substitutes `U+FFFD` for the offending
7//! bytes, and the resulting `String` no longer names the same file — feeding it
8//! back to `add`/`commit_paths` then addresses a *different* path (or none at
9//! all). These helpers preserve the exact bytes so a path read from
10//! status/diff/conflict output round-trips into a mutating call unchanged.
11
12use std::ffi::OsString;
13use std::path::PathBuf;
14
15/// Build an [`OsString`] from raw filesystem-path `bytes`, losslessly on Unix.
16///
17/// - **Unix:** the bytes *are* the OS path encoding, wrapped verbatim via
18///   `std::os::unix::ffi::OsStringExt::from_vec`, so a
19///   filename whose bytes are not valid UTF-8 survives byte-for-byte.
20/// - **Other platforms (Windows/WASI):** `git` and `jj` emit their `-z` / machine
21///   path output as UTF-8 there, so the bytes are decoded as UTF-8. A genuinely
22///   invalid sequence — which these tools do not produce on this path — falls back
23///   to the lossy replacement, preserving the pre-existing Windows
24///   `String`/`OsString` behaviour (Unicode names like `𝓁abc` still round-trip).
25pub fn os_from_bytes(bytes: &[u8]) -> OsString {
26    #[cfg(unix)]
27    {
28        use std::os::unix::ffi::OsStringExt;
29        OsString::from_vec(bytes.to_vec())
30    }
31    #[cfg(not(unix))]
32    {
33        OsString::from(String::from_utf8_lossy(bytes).into_owned())
34    }
35}
36
37/// [`os_from_bytes`] as a [`PathBuf`] — the path type the facade DTOs carry.
38pub fn path_from_bytes(bytes: &[u8]) -> PathBuf {
39    PathBuf::from(os_from_bytes(bytes))
40}
41
42/// Decode a git **C-quoted** path into its raw bytes.
43///
44/// git wraps a path in double quotes and C-escapes it when it contains a
45/// control byte, a `"`, a `\\`, or — with the default
46/// `core.quotePath=true` — a non-ASCII byte (for example, `é` becomes
47/// `\\303\\251`). A path without a leading `"` is returned unchanged, so callers
48/// may apply this unconditionally. Octal escapes decode to raw bytes, allowing
49/// the result to be passed losslessly to [`path_from_bytes`]. Decoding stops at
50/// the first unescaped closing quote; trailing bytes are ignored.
51pub fn unquote_c_style_path(s: &str) -> Vec<u8> {
52    let bytes = s.as_bytes();
53    if bytes.first() != Some(&b'"') {
54        return bytes.to_vec();
55    }
56    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
57    let mut i = 1;
58    while i < bytes.len() {
59        match bytes[i] {
60            b'"' => break,
61            b'\\' if i + 1 < bytes.len() => {
62                i += 1;
63                match bytes[i] {
64                    b'a' => out.push(0x07),
65                    b'b' => out.push(0x08),
66                    b't' => out.push(b'\t'),
67                    b'n' => out.push(b'\n'),
68                    b'v' => out.push(0x0b),
69                    b'f' => out.push(0x0c),
70                    b'r' => out.push(b'\r'),
71                    b'"' => out.push(b'"'),
72                    b'\\' => out.push(b'\\'),
73                    d @ b'0'..=b'7' => {
74                        let mut val = u32::from(d - b'0');
75                        let mut taken = 0;
76                        while taken < 2
77                            && i + 1 < bytes.len()
78                            && (b'0'..=b'7').contains(&bytes[i + 1])
79                        {
80                            i += 1;
81                            val = val * 8 + u32::from(bytes[i] - b'0');
82                            taken += 1;
83                        }
84                        out.push(val as u8);
85                    }
86                    other => out.push(other),
87                }
88                i += 1;
89            }
90            b => {
91                out.push(b);
92                i += 1;
93            }
94        }
95    }
96    out
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn ascii_and_utf8_round_trip_on_every_platform() {
105        assert_eq!(path_from_bytes(b"src/lib.rs"), PathBuf::from("src/lib.rs"));
106        // A multibyte UTF-8 name decodes to the same scalar on all platforms.
107        assert_eq!(
108            path_from_bytes("café.txt".as_bytes()),
109            PathBuf::from("café.txt")
110        );
111    }
112
113    #[test]
114    fn unquotes_c_style_paths_and_passes_through_plain_paths() {
115        assert_eq!(unquote_c_style_path("b/plain.txt"), b"b/plain.txt");
116        assert_eq!(
117            unquote_c_style_path("\"b/caf\\303\\251.txt\""),
118            "b/café.txt".as_bytes()
119        );
120        assert_eq!(unquote_c_style_path("\"a\\tb\""), b"a\tb");
121        assert_eq!(unquote_c_style_path("\"a\\\\b\""), b"a\\b");
122        assert_eq!(unquote_c_style_path("\"a\\\"b\""), b"a\"b");
123        assert_eq!(unquote_c_style_path("\"\\377.bin\""), b"\xff.bin");
124    }
125
126    // On Unix, a non-UTF-8 filename survives byte-for-byte (the load-bearing
127    // property this whole change exists for): the bytes go in and come back out
128    // of the `OsString` unchanged, never substituted with U+FFFD.
129    #[cfg(unix)]
130    #[test]
131    fn non_utf8_bytes_survive_on_unix() {
132        use std::os::unix::ffi::OsStrExt;
133        let raw = b"caf\xff.txt"; // 0xFF is never valid UTF-8
134        let os = os_from_bytes(raw);
135        assert_eq!(os.as_bytes(), raw, "the exact bytes must survive");
136    }
137}