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}