Skip to main content

stuffr_cli/compat/brotli/
mod.rs

1//! The brotli family (`brotli`, `unbrotli`, `brcat`): stuffr behaving as the
2//! brotli 1.2.1 command line tool.
3//!
4//! A port of `c/tools/brotli.c` of the 1.2.1 tag (MIT; `LICENSE-brotli`)
5//! over the doc-hidden `stuffr::brotli_opts` seam. `cli.rs` is
6//! `ParseParams`, `messages.rs` holds every string byte for byte. The
7//! reference reads no environment variable and installs no signal handler.
8//!
9//! **Streams** (probed, ruling T1-a): `-h` writes the help text to standard
10//! error and exits 0; a usage error writes its message (if any) to standard
11//! error, the help text to standard output and exits 1; `-V` writes
12//! `brotli 1.2.1` to standard output. Exit codes are 0 and 1 only.
13//!
14//! **The promise.** Stdout, stderr and exit codes (0 and 1) are those of
15//! brotli 1.2.1 on both build tiers, and the `.br` bytes are identical on
16//! the C tier (`brotli-c`, libbrotli 1.2.1 vendored; also in `c-backed`).
17//! `-V` prints `brotli 1.2.1`; there is no stuffr banner.
18//!
19//! Known deviations (the source of truth; the README mirrors it with the
20//! same numbers and words):
21//!
22//! 1. Pure tier: the `.br` bytes are compatible, not identical. brotli 1.2.1
23//!    decodes them to the input; the C tier's are the reference's.
24//! 2. Pure tier: `-D` is refused in both directions (`-D is not supported
25//!    by this build (pure tier); build with --features brotli-c`, exit 1,
26//!    once the dictionary file has been read), and so is a one-byte `-C`
27//!    comment when compressing (`failed to emit metadata [NAME]`, exit 1;
28//!    the pure encoder writes a corrupt stream for it). Every other `-C`
29//!    length, and `--large_window`, work.
30//! 3. Pure tier: a corrupt stream's `reason:` line can differ. The decoder
31//!    cannot tell `-10` (`BLOCK_LENGTH_2`) from `-9` (`BLOCK_LENGTH_1`) and
32//!    reports `-9`, and it accepts some streams libbrotli rejects with `-9`.
33//!    `-15` is reported as `-14` where the decoder is at a metablock
34//!    header.
35//! 4. An input that is also the output is refused, where the reference
36//!    truncates it, and with `-d` deletes it: `brotli -f -o a a`, a hard
37//!    link or a symlink to the input as the output. stuffr prints `input file
38//!    [NAME] is the same as the output file` and exits 1 before opening the
39//!    output.
40//! 5. A later input operand that an earlier output would overwrite is
41//!    refused before the output is opened (`brotli -f a a.br`, `-df a.br
42//!    a`; `input file [NAME] would be overwritten by the output of
43//!    [NAME]`, exit 1), where the reference overwrites that input before
44//!    reading it.
45//! 6. `-j` removes the source only after the output closed without error;
46//!    the reference removes it even when `fclose` failed (ENOSPC, EIO),
47//!    leaving a truncated output and no input. stuffr buffers its output
48//!    in 64 KiB, the reference in stdio's buffer, so on a full disk the
49//!    failure may be reported as `fclose failed [x]` where the reference
50//!    says `failed to write output [x]`. The output is removed either way.
51//! 7. `brotli - -` (standard input named twice) writes both streams; the
52//!    reference closes standard output after the first file and crashes on
53//!    the second.
54//! 8. `could not read dictionary: out of memory` is never printed. The
55//!    dictionary buffer (at most `MAX_DICTIONARY_SIZE`, 50331660 bytes,
56//!    about 48 MiB, the reference's own limit) is a plain allocation; when the allocator
57//!    cannot give it, Rust aborts the process (SIGABRT, status 134) where
58//!    the reference prints that line and exits 1.
59//! 9. No signal handler, as the reference: an interrupt leaves the partial
60//!    output where it was written (outputs are created directly under their
61//!    names, never through a temporary).
62//! 10. A closed pipe kills stuffr by SIGPIPE, as it kills brotli under the
63//!     default disposition. Where the parent ignores SIGPIPE brotli answers
64//!     `failed to write output [NAME]: Broken pipe` and exits 1; stuffr still dies of the
65//!     signal, because the Rust runtime ignores SIGPIPE before `main` and
66//!     the inherited setting cannot be told apart. A permanent deviation,
67//!     as in every family.
68//! 11. Hardening (ruling T5 of the 2026-10-31 security audit): `-f` never
69//!     writes through a symlink at the output name. The reference opens a
70//!     forced output `O_CREAT | O_TRUNC` without `O_EXCL`, so a symlink
71//!     there, dangling or not, is followed and the file it names is
72//!     created or truncated. stuffr unlinks the link and creates a fresh
73//!     file (`O_NOFOLLOW`), its target untouched; without `-f` both refuse
74//!     it (`O_EXCL`), as before. The attributes `CopyStat` copies are set
75//!     after the checked close, as the reference sets them, but by name it
76//!     follows a symlink there; stuffr reopens the output `O_NOFOLLOW`,
77//!     checks it is the file this run opened (device and inode) and sets
78//!     times, mode and owner through that descriptor: if the name was
79//!     swapped meanwhile they are not copied. Why: a planted link must not
80//!     redirect a write or a `chmod`.
81//!
82//! `files.rs` is the file handling (`NextFile`, `OpenFiles`, `CloseFiles`,
83//! `CopyStat`, `ReadDictionary`), `codec.rs` compression and the
84//! decompression loop, decoding (with `-t`, `-K` and the comment check).
85
86use std::ffi::{OsStr, OsString};
87use std::io::Write;
88use std::path::Path;
89use std::process::ExitCode;
90
91use super::CountedRun;
92use super::host::{self, Host, SANDBOX_OUTPUT_CAP, Sandbox};
93
94pub(crate) mod cli;
95mod codec;
96mod files;
97mod messages;
98
99use cli::Command;
100
101/// Entry point for the three names; `name` is the canonical invoked name,
102/// `argv0` the program as invoked (its last component is the name the help
103/// text prints) and `args` the arguments after it.
104pub fn run(name: &'static str, argv0: &OsStr, args: Vec<OsString>) -> ExitCode {
105    host::reset_sigpipe();
106    let argv0 = if argv0.is_empty() {
107        name.as_bytes()
108    } else {
109        argv0.as_encoded_bytes()
110    };
111    let code = main(argv0, &args, &mut Host::Real, &mut std::io::stderr());
112    ExitCode::from(code as u8)
113}
114
115/// [`run`] without the process: operands resolve against `dir`, standard
116/// input is `stdin`, standard output and error are captured and returned
117/// with the exit code. For the fuzz target; not a stable interface.
118#[doc(hidden)]
119pub fn run_in_counted(
120    dir: &Path,
121    name: &'static str,
122    args: &[OsString],
123    stdin: &[u8],
124) -> CountedRun {
125    use std::collections::BTreeMap;
126    use std::sync::atomic::AtomicU64;
127    use std::sync::{Arc, Mutex};
128
129    let stdout = Arc::new(Mutex::new(Vec::new()));
130    let reads = Arc::new(Mutex::new(BTreeMap::new()));
131    let mut host = Host::Sandbox(Sandbox {
132        dir: dir.to_path_buf(),
133        stdin: Some(stdin.to_vec()),
134        stdout: stdout.clone(),
135        budget: Arc::new(AtomicU64::new(SANDBOX_OUTPUT_CAP)),
136        reads: reads.clone(),
137    });
138    let mut err = Vec::new();
139    let code = main(name.as_bytes(), args, &mut host, &mut err);
140    drop(host);
141    let out = std::mem::take(&mut *stdout.lock().unwrap_or_else(|p| p.into_inner()));
142    let reads = std::mem::take(&mut *reads.lock().unwrap_or_else(|p| p.into_inner()));
143    (code, out, err, reads)
144}
145
146/// The run below the process boundary. Returns the exit code.
147fn main(argv0: &[u8], args: &[OsString], host: &mut Host, err: &mut dyn Write) -> i32 {
148    let mut out = host.out_writer(None);
149    let name = cli::file_name(argv0);
150    let code = match cli::parse(argv0, args, err) {
151        Err(Command::Help) => {
152            let _ = err.write_all(&messages::help_text(name));
153            0
154        }
155        Err(Command::Version) => {
156            let _ = out.write_all(messages::version_line().as_bytes());
157            0
158        }
159        // `Command::Invalid`: the message is out; the help text goes to
160        // standard output, the reference's `PrintHelp(name, is_ok)`.
161        Err(_) => {
162            let _ = out.write_all(&messages::help_text(name));
163            1
164        }
165        Ok(params) => {
166            // `main`: the dictionary first, then the buffers and the files.
167            let mut ctx = files::Ctx::new(&params, host, err);
168            let mut is_ok = files::read_dictionary(&mut ctx, params.command);
169            if is_ok {
170                let mut bufs = codec::Buffers::new();
171                is_ok = match params.command {
172                    Command::Compress => codec::compress_files(&mut ctx, &mut bufs),
173                    _ => codec::decompress_files(&mut ctx, &mut bufs),
174                };
175            }
176            if ctx.iterator_error {
177                is_ok = false;
178            }
179            i32::from(!is_ok)
180        }
181    };
182    let _ = out.flush();
183    code
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn run(name: &'static str, args: &[&str]) -> (i32, String, String) {
191        let dir = std::env::temp_dir();
192        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
193        let (code, out, err, _) = run_in_counted(&dir, name, &args, b"");
194        (
195            code,
196            String::from_utf8(out).unwrap(),
197            String::from_utf8(err).unwrap(),
198        )
199    }
200
201    #[test]
202    fn help_goes_to_standard_error_and_usage_errors_to_standard_output() {
203        let (code, out, err) = run("brcat", &["-h"]);
204        assert_eq!((code, out.as_str()), (0, ""));
205        assert!(
206            err.starts_with("Usage: brcat [OPTION]... [FILE]...\n"),
207            "{err}"
208        );
209        let (code, out, err) = run("brotli", &["-x"]);
210        assert_eq!((code, err.as_str()), (1, "invalid argument -x\n"));
211        assert!(out.starts_with("Usage: brotli ["), "{out}");
212    }
213
214    #[test]
215    fn version_goes_to_standard_output() {
216        assert_eq!(
217            run("unbrotli", &["-V"]),
218            (0, "brotli 1.2.1\n".into(), String::new())
219        );
220    }
221
222    /// A scratch directory `stuffr-brotli-<pid>-<n>`, removed on drop.
223    struct Scratch(std::path::PathBuf);
224
225    impl Scratch {
226        fn new() -> Self {
227            use std::sync::atomic::{AtomicUsize, Ordering};
228            static N: AtomicUsize = AtomicUsize::new(0);
229            let d = std::env::temp_dir().join(format!(
230                "stuffr-brotli-{}-{}",
231                std::process::id(),
232                N.fetch_add(1, Ordering::Relaxed)
233            ));
234            let _ = std::fs::remove_dir_all(&d);
235            std::fs::create_dir_all(&d).unwrap();
236            Scratch(d)
237        }
238    }
239
240    impl Drop for Scratch {
241        fn drop(&mut self) {
242            let _ = std::fs::remove_dir_all(&self.0);
243        }
244    }
245
246    fn run_at(dir: &Path, args: &[&str], stdin: &[u8]) -> (i32, Vec<u8>, String) {
247        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
248        let (code, out, err, _) = run_in_counted(dir, "brotli", &args, stdin);
249        (code, out, String::from_utf8(err).unwrap())
250    }
251
252    /// `brotli -c a` of `hello\n`, as the reference writes it (lgwin 16).
253    const BR: &[u8] = b"\x21\x14\x00\x04hello\n\x03";
254
255    #[test]
256    fn compresses_a_file_and_standard_input_in_the_sandbox() {
257        let s = Scratch::new();
258        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
259        assert_eq!(run_at(&s.0, &["a"], b""), (0, Vec::new(), String::new()));
260        let written = std::fs::read(s.0.join("a.br")).unwrap();
261        let (code, out, err) = run_at(&s.0, &["-c"], b"hello\n");
262        assert_eq!((code, err.as_str()), (0, ""));
263        if cfg!(feature = "brotli-c") {
264            assert_eq!(written, BR);
265            // Standard input: window 24, so another header.
266            assert_ne!(out, BR);
267        }
268        // `-j` removes the input once its output is complete.
269        assert_eq!(run_at(&s.0, &["-jf", "a"], b"").0, 0);
270        assert!(!s.0.join("a").exists() && s.0.join("a.br").exists());
271    }
272
273    #[test]
274    fn an_output_that_is_the_input_is_refused() {
275        let s = Scratch::new();
276        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
277        for args in [&["-f", "-o", "a", "a"][..], &["-df", "-o", "a", "a"]] {
278            let (code, out, err) = run_at(&s.0, args, b"");
279            assert_eq!(
280                (code, out.as_slice(), err.as_str()),
281                (
282                    1,
283                    &b""[..],
284                    "input file [a] is the same as the output file\n"
285                ),
286                "{args:?}"
287            );
288            assert_eq!(std::fs::read(s.0.join("a")).unwrap(), b"hello\n");
289        }
290        // Without `-f`: the reference's own refusal.
291        let (code, _, err) = run_at(&s.0, &["-o", "a", "a"], b"");
292        assert_eq!(
293            (code, err.as_str()),
294            (1, "failed to open output file [a]: File exists\n")
295        );
296    }
297
298    #[test]
299    fn names_map_as_next_file_maps_them() {
300        let s = Scratch::new();
301        std::fs::write(s.0.join("x"), BR).unwrap();
302        let (code, _, err) = run_at(&s.0, &["-d", "x"], b"");
303        assert_eq!(
304            (code, err.as_str()),
305            (1, "empty output file name for [x] input file\n")
306        );
307        std::fs::write(s.0.join("abcd"), BR).unwrap();
308        let (code, _, err) = run_at(&s.0, &["-d", "abcd"], b"");
309        assert_eq!(
310            (code, err.as_str()),
311            (1, "input file [abcd] suffix mismatch\n")
312        );
313        let (code, _, err) = run_at(&s.0, &["-S", ".x", "nosuch"], b"");
314        assert_eq!(
315            (code, err.as_str()),
316            (
317                1,
318                "failed to open input file [nosuch]: No such file or directory\n"
319            )
320        );
321    }
322
323    #[test]
324    fn verbose_and_squash_lines() {
325        let s = Scratch::new();
326        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
327        let (code, _, err) = run_at(&s.0, &["-v", "-s", "a"], b"");
328        assert_eq!(code, 0);
329        // The sandbox's clock is frozen.
330        let size = if cfg!(feature = "brotli-c") {
331            "11 B"
332        } else {
333            ""
334        };
335        assert!(err.starts_with("Compressed [a]: 6 B -> "), "{err}");
336        assert!(err.contains(size));
337        assert!(
338            err.ends_with(" in 0.00 sec\nOutput is larger than input\n"),
339            "{err}"
340        );
341        assert!(!s.0.join("a.br").exists());
342    }
343
344    /// `main` in a sandbox whose output budget is `budget` bytes.
345    fn run_capped(dir: &Path, args: &[&str], budget: u64) -> (i32, String) {
346        use std::collections::BTreeMap;
347        use std::sync::atomic::AtomicU64;
348        use std::sync::{Arc, Mutex};
349        let mut host = Host::Sandbox(Sandbox {
350            dir: dir.to_path_buf(),
351            stdin: Some(Vec::new()),
352            stdout: Arc::new(Mutex::new(Vec::new())),
353            budget: Arc::new(AtomicU64::new(budget)),
354            reads: Arc::new(Mutex::new(BTreeMap::new())),
355        });
356        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
357        let mut err = Vec::new();
358        let code = main(b"brotli", &args, &mut host, &mut err);
359        (code, String::from_utf8(err).unwrap())
360    }
361
362    /// Ruling T4-g: the output fits the buffer, so the full disk shows only
363    /// at the close; `-j` must keep the input then (the reference unlinks
364    /// it), compressing and decompressing.
365    #[test]
366    fn rm_keeps_the_input_when_the_close_fails() {
367        let s = Scratch::new();
368        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
369        let (code, err) = run_capped(&s.0, &["-j", "a"], 5);
370        assert_eq!(code, 1);
371        assert!(err.starts_with("fclose failed [a.br]: "), "{err}");
372        assert_eq!(std::fs::read(s.0.join("a")).unwrap(), b"hello\n");
373        std::fs::write(s.0.join("b.br"), BR).unwrap();
374        let (code, _) = run_capped(&s.0, &["-dj", "b.br"], 3);
375        assert_eq!(code, 1);
376        assert_eq!(std::fs::read(s.0.join("b.br")).unwrap(), BR);
377        // With room, `-j` removes it.
378        assert_eq!(run_capped(&s.0, &["-jf", "a"], 1 << 20).0, 0);
379        assert!(!s.0.join("a").exists());
380    }
381
382    /// Ruling T4-f: an output that is a later input operand is refused.
383    #[test]
384    fn a_later_input_operand_is_never_overwritten() {
385        let s = Scratch::new();
386        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
387        std::fs::write(s.0.join("a.br"), b"keep").unwrap();
388        let (code, _, err) = run_at(&s.0, &["-f", "a", "a.br"], b"");
389        assert_eq!(
390            (code, err.as_str()),
391            (
392                1,
393                "input file [a.br] would be overwritten by the output of [a]\n"
394            )
395        );
396        assert_eq!(std::fs::read(s.0.join("a.br")).unwrap(), b"keep");
397        // An earlier operand is not protected (it has been read), nor is
398        // `-` (standard input is no file name).
399        let (code, _, _) = run_at(&s.0, &["-f", "a", "-"], b"");
400        assert_eq!(code, 0);
401    }
402
403    /// `DecompressFile` in the sandbox: a stream, `-K`, the comment check
404    /// and the reason lines (the clock is frozen).
405    #[test]
406    fn decodes_tests_and_checks_comments() {
407        let s = Scratch::new();
408        let mut two = BR.to_vec();
409        two.extend_from_slice(BR);
410        std::fs::write(s.0.join("two.br"), &two).unwrap();
411        assert_eq!(
412            run_at(&s.0, &["-vdcK", "two.br"], b""),
413            (
414                0,
415                b"hello\nhello\n".to_vec(),
416                "extra input\nDecompressed [two.br]: 22 B -> 12 B in 0.00 sec\n".into()
417            )
418        );
419        let (code, out, err) = run_at(&s.0, &["-vdc", "two.br"], b"");
420        assert_eq!(
421            (code, out.as_slice(), err.as_str()),
422            (
423                1,
424                &b"hello\n"[..],
425                "corrupt input [two.br]\nreason: extra input\n"
426            )
427        );
428        // Truncation is the end of input inside a stream.
429        let (code, _, err) = run_at(&s.0, &["-vt"], &BR[..8]);
430        assert_eq!(
431            (code, err.as_str()),
432            (1, "corrupt input [con]\nreason: truncated input\n")
433        );
434        // A decoder error, its name without the `_ERROR_` prefix.
435        let (code, _, err) = run_at(&s.0, &["-vt"], b"\x1c");
436        assert_eq!(
437            (code, err.as_str()),
438            (1, "corrupt input [con]\nreason: FORMAT_RESERVED (-2)\n")
439        );
440        // The comment `foo`: right, wrong, and absent (printed, exit 0).
441        let foo = b"\x21\x4b\x00foo\x28\x00\x08hello\n\x03";
442        assert_eq!(run_at(&s.0, &["-t", "-C", "Zm9v"], foo).0, 0);
443        let (code, _, err) = run_at(&s.0, &["-vt", "-C", "Zm9x"], foo);
444        assert_eq!(
445            (code, err.as_str()),
446            (1, "corrupt input [con]\nreason: comment mismatch\n")
447        );
448        let (code, _, err) = run_at(&s.0, &["-t", "-C", "Zm9v"], BR);
449        assert_eq!((code, err.as_str()), (0, "corrupt input [con]\n"));
450    }
451
452    /// `-t` writes nothing, but in a sandbox what it decodes counts against
453    /// the output budget: a small bomb cannot decode without bound.
454    #[test]
455    fn test_mode_is_charged_in_the_sandbox() {
456        let s = Scratch::new();
457        // 2 MiB of zeros, compressed.
458        let mut enc = stuffr::brotli_opts::Encoder::new(
459            &stuffr::brotli_opts::EncoderParams {
460                quality: 5,
461                lgwin: 22,
462                large_window: false,
463                size_hint: None,
464            },
465            None,
466        )
467        .unwrap();
468        let zeros = vec![0u8; 2 << 20];
469        let mut input = &zeros[..];
470        let mut buf = vec![0u8; 1 << 16];
471        let mut outv = Vec::new();
472        while !enc.is_finished() {
473            let mut out = &mut buf[..];
474            let room = out.len();
475            assert!(enc.compress_stream(
476                stuffr::brotli_opts::Operation::Finish,
477                &mut input,
478                &mut out
479            ));
480            let n = room - out.len();
481            outv.extend_from_slice(&buf[..n]);
482        }
483        std::fs::write(s.0.join("z.br"), &outv).unwrap();
484        assert_eq!(run_capped(&s.0, &["-t", "z.br"], 4 << 20).0, 0);
485        let (code, err) = run_capped(&s.0, &["-t", "z.br"], 1 << 20);
486        assert_eq!(code, 1);
487        // `current_output_path` under `-t` is the name compression would
488        // give (`NextFile` appends the suffix): never opened, only named.
489        assert!(
490            err.starts_with("failed to write output [z.br.br]: "),
491            "{err}"
492        );
493        assert!(!s.0.join("z.br.br").exists());
494    }
495}