Skip to main content

zsh/
script_bytes.rs

1//! !!! WARNING: RUST-ONLY MODULE — NO C COUNTERPART !!!
2//!
3//! Lossless decode of RAW SCRIPT BYTES into the shell pipeline's
4//! `String` form.
5//!
6//! C zsh has no encoding requirement on script input. `Src/input.c`
7//! reads bytes (`shingetchar`, c:229) and `Src/utils.c:4856 metafy`
8//! escapes only the reserved IMETA bytes (`Src/utils.c:4195-4201` —
9//! `'\0'`, `Meta` 0x83, `Marker` 0xa2 and the token range
10//! `Pound`..`Nularg`); every other byte, including a lone ISO-8859-1
11//! `0xe9`, travels through the lexer untouched and is written back
12//! out verbatim. That is why a legacy byte in a completion file has
13//! never bothered zsh.
14//!
15//! Rust `String` is UTF-8, so a raw non-UTF-8 byte cannot be stored
16//! directly. zshrs already has one encoding for exactly this, used by
17//! the `$'\xNN'` decoders (`src/ported/lex.rs` `getkeystring_dollar_quote`,
18//! `src/extensions/compile_zsh.rs` `meta_encode_byte`) and decoded at
19//! every write/exec boundary by `crate::ported::utils::unmetafy_str`:
20//! a byte `>= 0x80` becomes the two chars `U+0083` (Meta) and
21//! `U+00(b ^ 32)`. This module applies that same encoding to the
22//! bytes a script file or SHIN hands us.
23//!
24//! The transform is the byte-level twin of `Src/input.c:267
25//! shingetline`, which the interactive/stdin path already performs
26//! inline: valid UTF-8 stays real `char`s (the lexer is Unicode-based
27//! — `src/ported/lex.rs:6171` "zshrs does not metafy, it keeps UTF-8
28//! `str`"), and only bytes that cannot be part of a UTF-8 sequence
29//! are Meta-encoded. Clean-ASCII and valid-UTF-8 input therefore
30//! decode to exactly the same `String` `read_to_string` produced, so
31//! the common path is unchanged; the difference is that an invalid
32//! byte no longer kills the read.
33
34use std::io;
35use std::path::Path;
36
37/// Meta-encode ONE raw byte into the pipeline's `String` form.
38///
39/// Mirrors the metafy step of `Src/utils.c:4856`
40/// (`if (imeta(c)) { *p++ = Meta; *p++ = c ^ 32; }`) transposed onto
41/// zshrs's char-level encoding, and is the exact inverse of
42/// `crate::ported::utils::unmetafy_str`.
43fn push_meta_byte(out: &mut String, b: u8) {
44    if b < 0x80 {
45        out.push(b as char);
46    } else {
47        out.push('\u{83}'); // Meta — Src/zsh.h:144
48        out.push(char::from(b ^ 32));
49    }
50}
51
52/// Decode raw script bytes into the pipeline's `String` form without
53/// losing a single byte.
54///
55/// Valid UTF-8 runs are copied verbatim; every byte that is not part
56/// of a valid UTF-8 sequence is Meta-encoded so
57/// `crate::ported::utils::unmetafy_str` reproduces it exactly at the
58/// output boundary.
59///
60/// A literal `U+0083` that was validly UTF-8 encoded in the source
61/// (bytes `c2 83`) is copied through as the `char` `U+0083`, matching
62/// what `Src/input.c:267 shingetline` already does on the stdin path;
63/// it then reads as a Meta marker, which is the pre-existing ambiguity
64/// of the char-level encoding and is not introduced here.
65pub fn decode_script_bytes(bytes: &[u8]) -> String {
66    // Fast path: the overwhelmingly common case is a fully valid
67    // file, where this is one validation pass and one allocation —
68    // the same work `read_to_string` did.
69    match std::str::from_utf8(bytes) {
70        Ok(s) => return s.to_string(),
71        Err(_) => {}
72    }
73
74    let mut out = String::with_capacity(bytes.len());
75    let mut rest = bytes;
76    loop {
77        match std::str::from_utf8(rest) {
78            Ok(s) => {
79                out.push_str(s);
80                return out;
81            }
82            Err(e) => {
83                let good = e.valid_up_to();
84                // SAFETY-free: `valid_up_to` bytes are valid UTF-8 by
85                // definition of the error.
86                out.push_str(std::str::from_utf8(&rest[..good]).unwrap_or_default());
87                // `error_len() == None` means the input ENDS with a
88                // truncated-but-so-far-valid sequence; those trailing
89                // bytes are still real bytes on disk, so encode them
90                // all rather than dropping them.
91                let bad = e.error_len().unwrap_or(rest.len() - good);
92                for &b in &rest[good..good + bad] {
93                    push_meta_byte(&mut out, b);
94                }
95                rest = &rest[good + bad..];
96            }
97        }
98    }
99}
100
101/// Regroup the Meta-pair bytes a `$'…'` decoder produced.
102///
103/// c:Src/utils.c:7289-7294 — getkeystring's GETKEY_DOLLAR_QUOTE arm
104/// metafies only `imeta` bytes; every other byte of `$'\xc2\xa3'` is
105/// stored raw, so the two bytes ARE the UTF-8 `£` the multibyte pattern
106/// code reads. zshrs's `$'…'` decoders emit each byte >= 0x80 as a Meta
107/// pair, which left `£` as two opaque metafied bytes that never compared
108/// equal to a literal `£` (a real `char` in this pipeline). Each run of
109/// Meta pairs is decoded exactly as script input is: valid UTF-8 becomes
110/// chars, anything else stays Meta-encoded.
111pub fn regroup_meta_utf8(s: String) -> String {
112    if !s.contains('\u{83}') {
113        return s;
114    }
115    let mut out = String::with_capacity(s.len());
116    let mut bytes: Vec<u8> = Vec::new();
117    let mut it = s.chars().peekable();
118    while let Some(c) = it.next() {
119        if c == '\u{83}' {
120            if let Some(&n) = it.peek() {
121                if (0x80..=0xff).contains(&(n as u32)) {
122                    it.next();
123                    bytes.push((n as u32 as u8) ^ 32);
124                    continue;
125                }
126            }
127        }
128        if !bytes.is_empty() {
129            out.push_str(&decode_script_bytes(&bytes));
130            bytes.clear();
131        }
132        out.push(c);
133    }
134    if !bytes.is_empty() {
135        out.push_str(&decode_script_bytes(&bytes));
136    }
137    out
138}
139
140/// Read a script file as raw bytes and decode it losslessly.
141///
142/// Replaces `fs::read_to_string` on every path that loads shell CODE
143/// (`zshrs FILE`, `source`/`.`, `autoload`, `stuff`, `$(< FILE)`).
144/// `read_to_string` rejected the ENTIRE file on the first invalid
145/// byte, which surfaced as a hard error for a script argument and as
146/// a SILENT no-op for `source` and `autoload`.
147pub fn read_script_file(path: impl AsRef<Path>) -> io::Result<String> {
148    Ok(decode_script_bytes(&std::fs::read(path)?))
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::ported::utils::unmetafy_str;
155
156    #[test]
157    fn ascii_and_utf8_decode_unchanged() {
158        for s in ["", "echo hi\n", "s=日本語\n", "e=🎉 é ü\n"] {
159            assert_eq!(decode_script_bytes(s.as_bytes()), s);
160        }
161    }
162
163    #[test]
164    fn invalid_bytes_round_trip_through_unmetafy() {
165        for raw in [
166            &b"echo caf\xe9\n"[..],
167            &b"\xff\xfe"[..],
168            &b"a\x80b\x81c\x82d"[..],
169            // truncated multibyte lead at end of file
170            &b"tail\xe2\x80"[..],
171            // valid UTF-8 either side of a lone byte
172            "日本\u{0}語".as_bytes(),
173        ] {
174            let decoded = decode_script_bytes(raw);
175            assert_eq!(unmetafy_str(&decoded), raw, "round trip for {raw:?}");
176        }
177    }
178
179    #[test]
180    fn one_bad_byte_does_not_lose_the_rest() {
181        let decoded = decode_script_bytes(b"echo before\necho caf\xe9\necho after\n");
182        assert!(decoded.starts_with("echo before\n"));
183        assert!(decoded.ends_with("echo after\n"));
184        assert!(!decoded.contains('\u{FFFD}'), "no lossy replacement");
185    }
186}