Skip to main content

stuffr_cli/compat/lz4/
mod.rs

1//! The lz4 family (`lz4`, `unlz4`, `lz4cat`, `lz4c`): stuffr behaving as
2//! the LZ4 command line interface v1.10.0.
3//!
4//! **A clean-room implementation** (spec decision 3): lz4's `programs/` is
5//! GPL-2.0-or-later and was never read. Every rule comes from the man page
6//! (`lz4.1`), `NEWS`, `README.md`, `lz4 -h`/`-H` and probes of the 1.10.0
7//! binary, and carries its source in a comment; every string is in
8//! `messages.rs`. liblz4 itself (BSD-2-Clause, `LICENSE-lz4`) is reached
9//! through `stuffr::lz4_opts`. `cli.rs` holds the command line and the
10//! decision table for where output goes.
11//!
12//! **Names** (spec section 1, R12): dispatch registers the four exact
13//! names. The presets come from the invoked name's last path component
14//! (after `/` or `\`), matched as lz4 1.10.0 matches it, probed through
15//! `exec -a`: the name itself, or the name followed by `.` and anything
16//! (`unlz4.exe`, `lz4cat.1`), case-sensitively; `unlz4-1`, `xunlz4` and
17//! `UNLZ4` match nothing. `unlz4` decompresses; `lz4cat` is `-dcfm` at
18//! display level 1; `lz4c` reads the legacy arguments (`-c0`, `-c1`,
19//! `-c2`, `-hc`, `-y`) and lists them in its help.
20//!
21//! **The machine** (R4): the automatic thread count is the logical cores
22//! less an eighth, less one (`cli::resolve_threads`); the real process
23//! counts its cores (`host::cores`), a sandboxed run
24//! ([`run_in_counted`]) sees 8 logical and 4 physical.
25//!
26//! **Signals** (ruling T2-a): lz4 installs no handler; SIGINT, SIGTERM and
27//! SIGHUP kill it and the partial output stays under its name (probed).
28//! So nothing is armed here, and outputs are written directly to their
29//! names (`io.rs`), never to a temporary file: an interrupt leaves what lz4
30//! leaves. `run` restores SIGPIPE's default action, as the earlier families
31//! do (deviation 4).
32//!
33//! **Files** (`io.rs`, `decode.rs`): compression, decompression and test
34//! over files and streams, with lz4's order of opening, its overwrite
35//! question, `-m`/`-r`, `--rm`, sparse output and its display level 2
36//! (ruling P3).
37//!
38//! Known deviations (the source of truth; the README and the spec mirror
39//! this list):
40//!
41//! 1. The banner names stuffr wherever lz4 prints it (`-V`, the first line
42//!    of `-h`/`-H`, `-v`): `*** lz4 v1.10.0 64-bit multithread (stuffr
43//!    X.Y.Z), by Yann Collet ***`. The prefix `*** lz4 v1.10.0 ` is
44//!    unchanged.
45//! 2. Timing and host fields vary, as between two runs of lz4: the `Done
46//!    in` line's seconds, speed and cpu load, the progress refreshes, and
47//!    the `Using N threads` count where two programs count the host's
48//!    cores differently. Refreshes are drawn at most every 200 ms (the
49//!    first one always; at display level 4 every chunk), which `display.rs`
50//!    pins from recordings (a decode offers one per MiB where lz4 offers
51//!    one per 4 MiB chunk); under a sandbox the clock is frozen at zero.
52//!    The figures in the `\r` refresh line also differ from lz4's, not
53//!    only their timing: for a file under 4 MiB lz4 shows `==> 24.69%`
54//!    (the final ratio) where stuffr shows `==> 0.00%`, because the
55//!    one-shot frame is not written yet when stuffr draws it; `-l` and
56//!    large inputs differ too.
57//! 3. An input that is also the output is never lost. Where the output
58//!    named is the input file itself (the same device and inode: `lz4 -f a
59//!    a`, `lz4 -df s.lz4 s.lz4`, a hard link, standard input redirected
60//!    from it), lz4 truncates it before reading it, and the data is gone;
61//!    stuffr opens nothing, prints `NAME is the input file; not overwritten
62//!    (stuffr)` and counts that input as failed (exit 1, or one more in
63//!    `-m`'s count), whatever `-f` says.
64//! 4. Closed standard streams and a parent that ignores SIGPIPE behave as
65//!    in the earlier families: a closed pipe kills stuffr by SIGPIPE, as it
66//!    kills lz4 under the default disposition; where the parent ignores
67//!    SIGPIPE, lz4 answers `Error 70` (decoding) or `Error 38` (compressing)
68//!    and stuffr still dies of the signal (the Rust runtime ignores SIGPIPE
69//!    before `main`, so the inherited disposition cannot be told apart).
70//!    A closed standard input or output is reopened on `/dev/null` by the
71//!    runtime before `main`, so `lz4 -c <&-` (lz4: `Error 40`) compresses
72//!    an empty input.
73//! 5. Out of scope and refused: `-b#`, `-e#` and `-i#` print `Benchmark
74//!    mode is not available in this build (stuffr)` and exit 1, once the
75//!    whole command line has parsed (a usage error, `-h` or `-V` still
76//!    answers first). `-h` and `-H` still list them (R8). The hidden `-S`
77//!    (a benchmark switch with no effect elsewhere) is accepted and has no
78//!    effect, as in lz4.
79//! 6. The pure tier (`lz4_flex`): levels 3 to 12, `--best` and
80//!    `--favor-decSpeed` compress with its single level; a corrupt frame's
81//!    `Error N` text may differ, and output written before it may be a
82//!    strict prefix of lz4's. The summary's compressed size and ratio are
83//!    those of its own bytes.
84//! 7. A decode that fails keeps everything decoded before the fault in its
85//!    output (a file or standard output). lz4 writes on another thread and
86//!    exits from the failing one, so it leaves nothing or all of that, run
87//!    to run (probed: 0 or 8800 bytes of 8800 for the same input).
88//! 8. `--list` of a file that ends inside a block-size word: lz4
89//!    completes the word from an uninitialised buffer, which only the `-v`
90//!    size of that last frame shows. On macOS that is garbage once a frame
91//!    or skippable frame came before (probed: the same run to run, about
92//!    4.4 million, for a given shape) and zero before; on Linux it is
93//!    garbage even in the first frame, different on every run (probed: the
94//!    tarball build, `lz4 --list -v` of a header and one byte gave sizes
95//!    from 1.0 to 2.1 billion; CI run 39166896170). stuffr completes it from
96//!    the frame's previous block-size word, as lz4 does for a second block
97//!    (zero for the first).
98//! 9. `--list` of a regular file that cannot be opened (mode 000): lz4
99//!    aborts on an assertion (exit 134); stuffr names the error
100//!    (`NAME: Permission denied`) and exits 1.
101
102use std::collections::BTreeMap;
103use std::ffi::{OsStr, OsString};
104use std::io::{Read, Write};
105use std::path::Path;
106use std::process::ExitCode;
107use std::sync::atomic::AtomicU64;
108use std::sync::{Arc, Mutex};
109
110use super::CountedRun;
111use super::host::{self, Host, SANDBOX_OUTPUT_CAP, Sandbox};
112
113pub(crate) mod cli;
114mod decode;
115mod display;
116mod io;
117mod list;
118mod messages;
119
120use cli::{Command, Op, Stop};
121use messages::{self as m, printf};
122
123/// Entry point for the four names; `name` is the canonical invoked name,
124/// `argv0` the program as invoked (its last component is the program name
125/// the usage texts print, and picks the presets) and `args` the arguments
126/// after it.
127pub fn run(name: &'static str, argv0: &OsStr, args: Vec<OsString>) -> ExitCode {
128    // Ruling T3-c, deviation 4: as `gzip::run` and `xz::run`.
129    host::reset_sigpipe();
130    let argv0 = if argv0.is_empty() {
131        name.as_bytes()
132    } else {
133        argv0.as_encoded_bytes()
134    };
135    let env = |k: &str| std::env::var_os(k);
136    let code = main(argv0, &args, &env, &mut Host::Real, &mut std::io::stderr());
137    // The process's exit status is the code's low byte, as `exit()` makes
138    // it.
139    ExitCode::from(code as u8)
140}
141
142/// The exit codes the reference produced over every row of the differential
143/// suite and its mutation fuzz: the set the `compat` fuzz target accepts
144/// under the lz4 names (with 0 and 1 always allowed there).
145///
146/// Probed 2026-10-16 over the rows and over ~3000 corruptions and cuts
147/// of ten frames (`-t`, `-dc`, `--list`).
148///
149/// Task 3 added 50: `lz4 -c --rm` on standard input is `Error 50 :
150/// Remove error : stdin: No such file or directory` (probed 2026-10-16).
151///
152/// Task 4 added 69, 72 and 74: `--sparse` decoding into a pipe (`lz4
153/// --sparse -dc X | cat`: `Error 69 : Final skip error (sparse file)`,
154/// `Error 72 : Sparse skip error(29): Illegal seek ; try --no-sparse`,
155/// `Error 74 : Sparse skip error ; try --no-sparse`; probed 2026-10-16,
156/// pinned by `sparse_output_matches_the_reference`).
157///
158/// Task 5 added 71 (and met 72 again): `--list` of a file that is only a
159/// frame's magic number is `Error 71 : Error reading NAME`, of one cut
160/// inside a header that promises a content size `Error 72 : Error
161/// reading NAME` (probed 2026-10-16, pinned by the `--list` rows).
162#[doc(hidden)]
163pub const EXIT_CODES: [i32; 19] = [
164    0, 1, 26, 27, 34, 38, 40, 42, 43, 44, 50, 61, 63, 64, 69, 70, 71, 72, 74,
165];
166
167/// [`run`] without the process: operands resolve against `dir`, standard
168/// input is `stdin` (a pipe), standard output and error are captured and
169/// returned with the exit code, neither is a terminal, `env` stands in for
170/// the environment (`LZ4_CLEVEL`, `LZ4_NBWORKERS`) and `SIGPIPE` is left
171/// alone. The machine is 8 logical and 4 physical cores (R4).
172///
173/// R9: nothing needs a cap beyond the shared output budget
174/// ([`SANDBOX_OUTPUT_CAP`], standard output and output files together):
175/// compression always runs on one thread, a frame's blocks are at most
176/// 4 MiB and a legacy block at most 8 MiB by format, and dictionary files
177/// are read through [`Host::track`] like every input. No signal handler is
178/// installed. For the fuzz target; not a stable interface.
179#[doc(hidden)]
180pub fn run_in_counted(
181    dir: &Path,
182    name: &'static str,
183    args: &[OsString],
184    stdin: &[u8],
185    env: &dyn Fn(&str) -> Option<OsString>,
186) -> CountedRun {
187    let stdout = Arc::new(Mutex::new(Vec::new()));
188    let reads = Arc::new(Mutex::new(BTreeMap::new()));
189    let mut host = Host::Sandbox(Sandbox {
190        dir: dir.to_path_buf(),
191        stdin: Some(stdin.to_vec()),
192        stdout: stdout.clone(),
193        budget: Arc::new(AtomicU64::new(SANDBOX_OUTPUT_CAP)),
194        reads: reads.clone(),
195    });
196    let mut err = Vec::new();
197    let code = main(name.as_bytes(), args, env, &mut host, &mut err);
198    drop(host);
199    let out = std::mem::take(&mut *stdout.lock().unwrap_or_else(|p| p.into_inner()));
200    let reads = std::mem::take(&mut *reads.lock().unwrap_or_else(|p| p.into_inner()));
201    (code, out, err, reads)
202}
203
204/// The run below the process boundary. Returns the exit code.
205fn main(
206    argv0: &[u8],
207    args: &[OsString],
208    env: &dyn Fn(&str) -> Option<OsString>,
209    host: &mut Host,
210    err: &mut dyn Write,
211) -> i32 {
212    let program = cli::program_name(argv0);
213    let mut out = host.out_writer(None);
214    let stop = match cli::parse(program, env, args, host::cores(host), &mut out, err) {
215        Ok(cmd) => execute(&cmd, host, &mut out, err),
216        Err(stop) => stop,
217    };
218    let _ = out.flush();
219    drop(out);
220    if stop.pause {
221        // probe: `lz4 -p -V < /dev/null`: the prompt, then a read of
222        // standard input.
223        printf(err, m::PRESS_ENTER, &[]);
224        let mut byte = [0u8; 1];
225        let _ = host.stdin().read(&mut byte);
226    }
227    stop.code
228}
229
230/// Everything after a successful parse.
231fn execute(cmd: &Command, host: &mut Host, out: &mut dyn Write, err: &mut dyn Write) -> Stop {
232    announce(cmd, err);
233    let plan = match cli::plan(cmd, host.stdin_is_tty(), host.stdout_is_tty()) {
234        Ok(plan) => plan,
235        Err(refusal) => {
236            return Stop {
237                code: cli::refuse(err, cmd, refusal),
238                pause: false,
239            };
240        }
241    };
242    let ended = match plan.op {
243        Op::Compress => io::compress_files(cmd, &plan, host, out, err),
244        Op::Decompress | Op::Test => io::decompress_files(cmd, &plan, host, out, err),
245        Op::List => list::list_files(cmd, host, out, err),
246    };
247    Stop {
248        code: ended.code,
249        pause: ended.pause,
250    }
251}
252
253/// What lz4 prints once the command line has parsed, before anything else
254/// (probe: `lz4 -v -d nosuch`, `lz4 -vv -t a`, `lz4 -vv --list b.lz4`; the
255/// same under every operation, and before a refusal or a usage error that
256/// the arguments did not raise): the banner at display level 3 and up,
257/// and at level 4 and up the two `_POSIX_VERSION` lines, after a
258/// `_POSIX_C_SOURCE` line on Linux.
259fn announce(cmd: &Command, err: &mut dyn Write) {
260    if cmd.display_level >= 3 {
261        cli::print_banner(err);
262    }
263    if cmd.display_level >= 4 {
264        // probe: `lz4 -vv -c f` on Linux, glibc and musl (CI run
265        // 39166896170; see `m::POSIX_C_SOURCE`); none on macOS.
266        #[cfg(target_os = "linux")]
267        printf(err, m::POSIX_C_SOURCE, &[]);
268        let v = posix_version();
269        printf(err, m::POSIX_VERSION, &[v.as_bytes(), v.as_bytes()]);
270    }
271}
272
273/// `_POSIX_VERSION` as the system says it (200112 on macOS, probed with
274/// lz4: `_POSIX_VERSION defined: 200112L`; 200809 on Linux, probed with the
275/// tarball build on glibc and musl and on CI run 39166896170);
276/// `sysconf(_SC_VERSION)` is the run-time spelling of the same fact.
277fn posix_version() -> String {
278    #[cfg(unix)]
279    {
280        // SAFETY: `sysconf` reads no memory of ours and has no
281        // preconditions; `_SC_VERSION` is a valid name on every unix.
282        let v = unsafe { libc::sysconf(libc::_SC_VERSION) };
283        if v > 0 {
284            return v.to_string();
285        }
286    }
287    // Where the system cannot say: the value lz4 prints on macOS.
288    "200112".to_string()
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn run(name: &'static str, args: &[&str], stdin: &[u8]) -> (i32, String, String) {
296        let dir = std::env::temp_dir().join(format!("stuffr-lz4-mod-{}", std::process::id()));
297        let _ = std::fs::remove_dir_all(&dir);
298        std::fs::create_dir_all(&dir).unwrap();
299        std::fs::write(dir.join("a"), b"hello\n").unwrap();
300        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
301        let (code, out, err, _) = run_in_counted(&dir, name, &args, stdin, &|_| None);
302        let _ = std::fs::remove_dir_all(&dir);
303        (
304            code,
305            String::from_utf8(out).unwrap(),
306            String::from_utf8(err).unwrap(),
307        )
308    }
309
310    /// The sandbox runs the parse and the decision table: `-V` reaches its
311    /// captured standard output, `-p` reads its standard input, and a
312    /// refusal answers before anything is opened.
313    #[test]
314    fn a_sandboxed_run_parses_and_plans() {
315        let banner = format!(
316            "*** lz4 v1.10.0 64-bit multithread (stuffr {}), by Yann Collet ***\n",
317            env!("CARGO_PKG_VERSION")
318        );
319        assert_eq!(run("lz4", &["-V"], b""), (0, banner.clone(), String::new()));
320        assert_eq!(
321            run("lz4", &["-p", "-V"], b"\n"),
322            (0, banner, "Press enter to continue...\n".into())
323        );
324        let (code, out, err) = run("unlz4", &["a"], b"");
325        assert_eq!((code, out.as_str()), (1, ""));
326        assert!(
327            err.starts_with("Cannot determine an output filename \n"),
328            "{err}"
329        );
330        let (code, _, err) = run("lz4", &["--bogus"], b"");
331        assert_eq!(code, 1);
332        assert!(err.starts_with("Incorrect parameters\n"), "{err}");
333    }
334
335    /// A scratch directory for one sandboxed run, unique in the process.
336    struct Scratch(std::path::PathBuf);
337    impl Scratch {
338        fn new(files: &[(&str, &[u8])]) -> Scratch {
339            static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
340            let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
341            let dir =
342                std::env::temp_dir().join(format!("stuffr-lz4-io-{}-{n}", std::process::id()));
343            let _ = std::fs::remove_dir_all(&dir);
344            std::fs::create_dir_all(&dir).unwrap();
345            for (name, data) in files {
346                std::fs::write(dir.join(name), data).unwrap();
347            }
348            Scratch(dir)
349        }
350        fn run(&self, name: &'static str, args: &[&str], stdin: &[u8]) -> CountedRun {
351            let args: Vec<OsString> = args.iter().map(OsString::from).collect();
352            run_in_counted(&self.0, name, &args, stdin, &|_| None)
353        }
354    }
355    impl Drop for Scratch {
356        fn drop(&mut self) {
357            let _ = std::fs::remove_dir_all(&self.0);
358        }
359    }
360
361    /// A sandboxed run compresses into a file, decodes to its captured
362    /// standard output, tests, and counts what it read through
363    /// `Host::track` (R9).
364    #[test]
365    fn a_sandboxed_run_compresses_decodes_and_tests() {
366        let plain = b"hello hello hello hello\n".repeat(50);
367        let d = Scratch::new(&[("a", &plain)]);
368        let (code, out, err, reads) = d.run("lz4", &["-q", "a"], b"");
369        assert_eq!((code, out.len(), err.len()), (0, 0, 0));
370        let read = reads.get(&d.0.join("a")).copied().unwrap_or_default();
371        assert_eq!((read.opens, read.bytes), (1, plain.len() as u64));
372        let (code, out, err, _) = d.run("lz4cat", &["a.lz4"], b"");
373        assert_eq!((code, out, err.len()), (0, plain.clone(), 0));
374        let (code, _, err, _) = d.run("lz4", &["-t", "a.lz4"], b"");
375        assert_eq!(code, 0);
376        let err = String::from_utf8(err).unwrap();
377        let summary = format!("a.lz4{}: decoded 1200 bytes \n", " ".repeat(26));
378        assert!(err.ends_with(&summary), "{err:?}");
379    }
380
381    /// R11 in the sandbox: the overwrite question reads the run's standard
382    /// input, and the data after the answer is the input compressed.
383    #[test]
384    fn the_overwrite_question_reads_standard_input() {
385        let d = Scratch::new(&[("e", b"old")]);
386        let (code, _, err, _) = d.run("lz4", &["-", "e"], b"y\nhello");
387        assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
388        let (code, out, _, _) = d.run("lz4", &["-dc", "e"], b"");
389        assert_eq!((code, out.as_slice()), (0, &b"hello"[..]));
390        let (code, _, err, _) = d.run("lz4", &["-", "e"], b"n\nhello");
391        assert_eq!(code, 1);
392        let refused = format!("?{}not overwritten  \n", " ".repeat(5));
393        assert!(String::from_utf8(err).unwrap().ends_with(&refused));
394    }
395
396    /// `--sparse` to standard output: a sandbox's is a pipe, which refuses
397    /// the first seek, as lz4's does (`Error 72` for data, `Error 69` for
398    /// an output of zeros only).
399    #[test]
400    fn sparse_standard_output_is_a_pipe_in_the_sandbox() {
401        let d = Scratch::new(&[("a", b"AAAAAAAA"), ("z", &[0u8; 100])]);
402        assert_eq!(d.run("lz4", &["-q", "-m", "a", "z"], b"").0, 0);
403        let (code, out, err, _) = d.run("lz4", &["--sparse", "-dc", "z.lz4"], b"");
404        assert_eq!((code, out.len()), (69, 0));
405        assert_eq!(err, b"Error 69 : Final skip error (sparse file)\n \n");
406        let (code, out, err, _) = d.run("lz4", &["--sparse", "-dc", "a.lz4"], b"");
407        assert_eq!((code, out.len()), (72, 0));
408        let err = String::from_utf8(err).unwrap();
409        assert!(err.starts_with("Error 72 : Sparse skip error("), "{err:?}");
410        assert!(err.ends_with(" ; try --no-sparse \n"), "{err:?}");
411        // Without `--sparse`, standard output is written plainly.
412        let (code, out, _, _) = d.run("lz4", &["-dc", "a.lz4"], b"");
413        assert_eq!((code, out.as_slice()), (0, &b"AAAAAAAA"[..]));
414    }
415
416    /// Spec deviation 3 in the sandbox: an output that is the input is
417    /// refused and the input kept.
418    #[test]
419    fn an_output_that_is_the_input_is_refused() {
420        let d = Scratch::new(&[("a", b"keep me")]);
421        let (code, _, err, _) = d.run("lz4", &["-f", "a", "a"], b"");
422        assert_eq!(code, 1);
423        assert_eq!(err, b"a is the input file; not overwritten (stuffr)  \n");
424        assert_eq!(std::fs::read(d.0.join("a")).unwrap(), b"keep me");
425    }
426}