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//!
69//! `files.rs` is the file handling (`NextFile`, `OpenFiles`, `CloseFiles`,
70//! `CopyStat`, `ReadDictionary`), `codec.rs` compression and the
71//! decompression loop, decoding (with `-t`, `-K` and the comment check).
72
73use std::ffi::{OsStr, OsString};
74use std::io::Write;
75use std::path::Path;
76use std::process::ExitCode;
77
78use super::CountedRun;
79use super::host::{self, Host, SANDBOX_OUTPUT_CAP, Sandbox};
80
81pub(crate) mod cli;
82mod codec;
83mod files;
84mod messages;
85
86use cli::Command;
87
88/// Entry point for the three names; `name` is the canonical invoked name,
89/// `argv0` the program as invoked (its last component is the name the help
90/// text prints) and `args` the arguments after it.
91pub fn run(name: &'static str, argv0: &OsStr, args: Vec<OsString>) -> ExitCode {
92    host::reset_sigpipe();
93    let argv0 = if argv0.is_empty() {
94        name.as_bytes()
95    } else {
96        argv0.as_encoded_bytes()
97    };
98    let code = main(argv0, &args, &mut Host::Real, &mut std::io::stderr());
99    ExitCode::from(code as u8)
100}
101
102/// [`run`] without the process: operands resolve against `dir`, standard
103/// input is `stdin`, standard output and error are captured and returned
104/// with the exit code. For the fuzz target; not a stable interface.
105#[doc(hidden)]
106pub fn run_in_counted(
107    dir: &Path,
108    name: &'static str,
109    args: &[OsString],
110    stdin: &[u8],
111) -> CountedRun {
112    use std::collections::BTreeMap;
113    use std::sync::atomic::AtomicU64;
114    use std::sync::{Arc, Mutex};
115
116    let stdout = Arc::new(Mutex::new(Vec::new()));
117    let reads = Arc::new(Mutex::new(BTreeMap::new()));
118    let mut host = Host::Sandbox(Sandbox {
119        dir: dir.to_path_buf(),
120        stdin: Some(stdin.to_vec()),
121        stdout: stdout.clone(),
122        budget: Arc::new(AtomicU64::new(SANDBOX_OUTPUT_CAP)),
123        reads: reads.clone(),
124    });
125    let mut err = Vec::new();
126    let code = main(name.as_bytes(), args, &mut host, &mut err);
127    drop(host);
128    let out = std::mem::take(&mut *stdout.lock().unwrap_or_else(|p| p.into_inner()));
129    let reads = std::mem::take(&mut *reads.lock().unwrap_or_else(|p| p.into_inner()));
130    (code, out, err, reads)
131}
132
133/// The run below the process boundary. Returns the exit code.
134fn main(argv0: &[u8], args: &[OsString], host: &mut Host, err: &mut dyn Write) -> i32 {
135    let mut out = host.out_writer(None);
136    let name = cli::file_name(argv0);
137    let code = match cli::parse(argv0, args, err) {
138        Err(Command::Help) => {
139            let _ = err.write_all(&messages::help_text(name));
140            0
141        }
142        Err(Command::Version) => {
143            let _ = out.write_all(messages::version_line().as_bytes());
144            0
145        }
146        // `Command::Invalid`: the message is out; the help text goes to
147        // standard output, the reference's `PrintHelp(name, is_ok)`.
148        Err(_) => {
149            let _ = out.write_all(&messages::help_text(name));
150            1
151        }
152        Ok(params) => {
153            // `main`: the dictionary first, then the buffers and the files.
154            let mut ctx = files::Ctx::new(&params, host, err);
155            let mut is_ok = files::read_dictionary(&mut ctx, params.command);
156            if is_ok {
157                let mut bufs = codec::Buffers::new();
158                is_ok = match params.command {
159                    Command::Compress => codec::compress_files(&mut ctx, &mut bufs),
160                    _ => codec::decompress_files(&mut ctx, &mut bufs),
161                };
162            }
163            if ctx.iterator_error {
164                is_ok = false;
165            }
166            i32::from(!is_ok)
167        }
168    };
169    let _ = out.flush();
170    code
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn run(name: &'static str, args: &[&str]) -> (i32, String, String) {
178        let dir = std::env::temp_dir();
179        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
180        let (code, out, err, _) = run_in_counted(&dir, name, &args, b"");
181        (
182            code,
183            String::from_utf8(out).unwrap(),
184            String::from_utf8(err).unwrap(),
185        )
186    }
187
188    #[test]
189    fn help_goes_to_standard_error_and_usage_errors_to_standard_output() {
190        let (code, out, err) = run("brcat", &["-h"]);
191        assert_eq!((code, out.as_str()), (0, ""));
192        assert!(
193            err.starts_with("Usage: brcat [OPTION]... [FILE]...\n"),
194            "{err}"
195        );
196        let (code, out, err) = run("brotli", &["-x"]);
197        assert_eq!((code, err.as_str()), (1, "invalid argument -x\n"));
198        assert!(out.starts_with("Usage: brotli ["), "{out}");
199    }
200
201    #[test]
202    fn version_goes_to_standard_output() {
203        assert_eq!(
204            run("unbrotli", &["-V"]),
205            (0, "brotli 1.2.1\n".into(), String::new())
206        );
207    }
208
209    /// A scratch directory `stuffr-brotli-<pid>-<n>`, removed on drop.
210    struct Scratch(std::path::PathBuf);
211
212    impl Scratch {
213        fn new() -> Self {
214            use std::sync::atomic::{AtomicUsize, Ordering};
215            static N: AtomicUsize = AtomicUsize::new(0);
216            let d = std::env::temp_dir().join(format!(
217                "stuffr-brotli-{}-{}",
218                std::process::id(),
219                N.fetch_add(1, Ordering::Relaxed)
220            ));
221            let _ = std::fs::remove_dir_all(&d);
222            std::fs::create_dir_all(&d).unwrap();
223            Scratch(d)
224        }
225    }
226
227    impl Drop for Scratch {
228        fn drop(&mut self) {
229            let _ = std::fs::remove_dir_all(&self.0);
230        }
231    }
232
233    fn run_at(dir: &Path, args: &[&str], stdin: &[u8]) -> (i32, Vec<u8>, String) {
234        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
235        let (code, out, err, _) = run_in_counted(dir, "brotli", &args, stdin);
236        (code, out, String::from_utf8(err).unwrap())
237    }
238
239    /// `brotli -c a` of `hello\n`, as the reference writes it (lgwin 16).
240    const BR: &[u8] = b"\x21\x14\x00\x04hello\n\x03";
241
242    #[test]
243    fn compresses_a_file_and_standard_input_in_the_sandbox() {
244        let s = Scratch::new();
245        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
246        assert_eq!(run_at(&s.0, &["a"], b""), (0, Vec::new(), String::new()));
247        let written = std::fs::read(s.0.join("a.br")).unwrap();
248        let (code, out, err) = run_at(&s.0, &["-c"], b"hello\n");
249        assert_eq!((code, err.as_str()), (0, ""));
250        if cfg!(feature = "brotli-c") {
251            assert_eq!(written, BR);
252            // Standard input: window 24, so another header.
253            assert_ne!(out, BR);
254        }
255        // `-j` removes the input once its output is complete.
256        assert_eq!(run_at(&s.0, &["-jf", "a"], b"").0, 0);
257        assert!(!s.0.join("a").exists() && s.0.join("a.br").exists());
258    }
259
260    #[test]
261    fn an_output_that_is_the_input_is_refused() {
262        let s = Scratch::new();
263        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
264        for args in [&["-f", "-o", "a", "a"][..], &["-df", "-o", "a", "a"]] {
265            let (code, out, err) = run_at(&s.0, args, b"");
266            assert_eq!(
267                (code, out.as_slice(), err.as_str()),
268                (
269                    1,
270                    &b""[..],
271                    "input file [a] is the same as the output file\n"
272                ),
273                "{args:?}"
274            );
275            assert_eq!(std::fs::read(s.0.join("a")).unwrap(), b"hello\n");
276        }
277        // Without `-f`: the reference's own refusal.
278        let (code, _, err) = run_at(&s.0, &["-o", "a", "a"], b"");
279        assert_eq!(
280            (code, err.as_str()),
281            (1, "failed to open output file [a]: File exists\n")
282        );
283    }
284
285    #[test]
286    fn names_map_as_next_file_maps_them() {
287        let s = Scratch::new();
288        std::fs::write(s.0.join("x"), BR).unwrap();
289        let (code, _, err) = run_at(&s.0, &["-d", "x"], b"");
290        assert_eq!(
291            (code, err.as_str()),
292            (1, "empty output file name for [x] input file\n")
293        );
294        std::fs::write(s.0.join("abcd"), BR).unwrap();
295        let (code, _, err) = run_at(&s.0, &["-d", "abcd"], b"");
296        assert_eq!(
297            (code, err.as_str()),
298            (1, "input file [abcd] suffix mismatch\n")
299        );
300        let (code, _, err) = run_at(&s.0, &["-S", ".x", "nosuch"], b"");
301        assert_eq!(
302            (code, err.as_str()),
303            (
304                1,
305                "failed to open input file [nosuch]: No such file or directory\n"
306            )
307        );
308    }
309
310    #[test]
311    fn verbose_and_squash_lines() {
312        let s = Scratch::new();
313        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
314        let (code, _, err) = run_at(&s.0, &["-v", "-s", "a"], b"");
315        assert_eq!(code, 0);
316        // The sandbox's clock is frozen.
317        let size = if cfg!(feature = "brotli-c") {
318            "11 B"
319        } else {
320            ""
321        };
322        assert!(err.starts_with("Compressed [a]: 6 B -> "), "{err}");
323        assert!(err.contains(size));
324        assert!(
325            err.ends_with(" in 0.00 sec\nOutput is larger than input\n"),
326            "{err}"
327        );
328        assert!(!s.0.join("a.br").exists());
329    }
330
331    /// `main` in a sandbox whose output budget is `budget` bytes.
332    fn run_capped(dir: &Path, args: &[&str], budget: u64) -> (i32, String) {
333        use std::collections::BTreeMap;
334        use std::sync::atomic::AtomicU64;
335        use std::sync::{Arc, Mutex};
336        let mut host = Host::Sandbox(Sandbox {
337            dir: dir.to_path_buf(),
338            stdin: Some(Vec::new()),
339            stdout: Arc::new(Mutex::new(Vec::new())),
340            budget: Arc::new(AtomicU64::new(budget)),
341            reads: Arc::new(Mutex::new(BTreeMap::new())),
342        });
343        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
344        let mut err = Vec::new();
345        let code = main(b"brotli", &args, &mut host, &mut err);
346        (code, String::from_utf8(err).unwrap())
347    }
348
349    /// Ruling T4-g: the output fits the buffer, so the full disk shows only
350    /// at the close; `-j` must keep the input then (the reference unlinks
351    /// it), compressing and decompressing.
352    #[test]
353    fn rm_keeps_the_input_when_the_close_fails() {
354        let s = Scratch::new();
355        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
356        let (code, err) = run_capped(&s.0, &["-j", "a"], 5);
357        assert_eq!(code, 1);
358        assert!(err.starts_with("fclose failed [a.br]: "), "{err}");
359        assert_eq!(std::fs::read(s.0.join("a")).unwrap(), b"hello\n");
360        std::fs::write(s.0.join("b.br"), BR).unwrap();
361        let (code, _) = run_capped(&s.0, &["-dj", "b.br"], 3);
362        assert_eq!(code, 1);
363        assert_eq!(std::fs::read(s.0.join("b.br")).unwrap(), BR);
364        // With room, `-j` removes it.
365        assert_eq!(run_capped(&s.0, &["-jf", "a"], 1 << 20).0, 0);
366        assert!(!s.0.join("a").exists());
367    }
368
369    /// Ruling T4-f: an output that is a later input operand is refused.
370    #[test]
371    fn a_later_input_operand_is_never_overwritten() {
372        let s = Scratch::new();
373        std::fs::write(s.0.join("a"), b"hello\n").unwrap();
374        std::fs::write(s.0.join("a.br"), b"keep").unwrap();
375        let (code, _, err) = run_at(&s.0, &["-f", "a", "a.br"], b"");
376        assert_eq!(
377            (code, err.as_str()),
378            (
379                1,
380                "input file [a.br] would be overwritten by the output of [a]\n"
381            )
382        );
383        assert_eq!(std::fs::read(s.0.join("a.br")).unwrap(), b"keep");
384        // An earlier operand is not protected (it has been read), nor is
385        // `-` (standard input is no file name).
386        let (code, _, _) = run_at(&s.0, &["-f", "a", "-"], b"");
387        assert_eq!(code, 0);
388    }
389
390    /// `DecompressFile` in the sandbox: a stream, `-K`, the comment check
391    /// and the reason lines (the clock is frozen).
392    #[test]
393    fn decodes_tests_and_checks_comments() {
394        let s = Scratch::new();
395        let mut two = BR.to_vec();
396        two.extend_from_slice(BR);
397        std::fs::write(s.0.join("two.br"), &two).unwrap();
398        assert_eq!(
399            run_at(&s.0, &["-vdcK", "two.br"], b""),
400            (
401                0,
402                b"hello\nhello\n".to_vec(),
403                "extra input\nDecompressed [two.br]: 22 B -> 12 B in 0.00 sec\n".into()
404            )
405        );
406        let (code, out, err) = run_at(&s.0, &["-vdc", "two.br"], b"");
407        assert_eq!(
408            (code, out.as_slice(), err.as_str()),
409            (
410                1,
411                &b"hello\n"[..],
412                "corrupt input [two.br]\nreason: extra input\n"
413            )
414        );
415        // Truncation is the end of input inside a stream.
416        let (code, _, err) = run_at(&s.0, &["-vt"], &BR[..8]);
417        assert_eq!(
418            (code, err.as_str()),
419            (1, "corrupt input [con]\nreason: truncated input\n")
420        );
421        // A decoder error, its name without the `_ERROR_` prefix.
422        let (code, _, err) = run_at(&s.0, &["-vt"], b"\x1c");
423        assert_eq!(
424            (code, err.as_str()),
425            (1, "corrupt input [con]\nreason: FORMAT_RESERVED (-2)\n")
426        );
427        // The comment `foo`: right, wrong, and absent (printed, exit 0).
428        let foo = b"\x21\x4b\x00foo\x28\x00\x08hello\n\x03";
429        assert_eq!(run_at(&s.0, &["-t", "-C", "Zm9v"], foo).0, 0);
430        let (code, _, err) = run_at(&s.0, &["-vt", "-C", "Zm9x"], foo);
431        assert_eq!(
432            (code, err.as_str()),
433            (1, "corrupt input [con]\nreason: comment mismatch\n")
434        );
435        let (code, _, err) = run_at(&s.0, &["-t", "-C", "Zm9v"], BR);
436        assert_eq!((code, err.as_str()), (0, "corrupt input [con]\n"));
437    }
438
439    /// `-t` writes nothing, but in a sandbox what it decodes counts against
440    /// the output budget: a small bomb cannot decode without bound.
441    #[test]
442    fn test_mode_is_charged_in_the_sandbox() {
443        let s = Scratch::new();
444        // 2 MiB of zeros, compressed.
445        let mut enc = stuffr::brotli_opts::Encoder::new(
446            &stuffr::brotli_opts::EncoderParams {
447                quality: 5,
448                lgwin: 22,
449                large_window: false,
450                size_hint: None,
451            },
452            None,
453        )
454        .unwrap();
455        let zeros = vec![0u8; 2 << 20];
456        let mut input = &zeros[..];
457        let mut buf = vec![0u8; 1 << 16];
458        let mut outv = Vec::new();
459        while !enc.is_finished() {
460            let mut out = &mut buf[..];
461            let room = out.len();
462            assert!(enc.compress_stream(
463                stuffr::brotli_opts::Operation::Finish,
464                &mut input,
465                &mut out
466            ));
467            let n = room - out.len();
468            outv.extend_from_slice(&buf[..n]);
469        }
470        std::fs::write(s.0.join("z.br"), &outv).unwrap();
471        assert_eq!(run_capped(&s.0, &["-t", "z.br"], 4 << 20).0, 0);
472        let (code, err) = run_capped(&s.0, &["-t", "z.br"], 1 << 20);
473        assert_eq!(code, 1);
474        // `current_output_path` under `-t` is the name compression would
475        // give (`NextFile` appends the suffix): never opened, only named.
476        assert!(
477            err.starts_with("failed to write output [z.br.br]: "),
478            "{err}"
479        );
480        assert!(!s.0.join("z.br.br").exists());
481    }
482}