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