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//! 10. Hardening (ruling T5 of the 2026-10-31 security audit): a symlink
105//!     at an output name is never written through. lz4 checks for an
106//!     existing output with `stat` and opens it with `fopen(.., "wb")`, so a
107//!     dangling symlink looks absent and a live one is truncated, and the
108//!     data lands wherever the link points. stuffr checks with `lstat`: a
109//!     symlink, dangling or not, is an existing file (`already exists` /
110//!     the overwrite question); overwriting it (`-f`, or `y`) unlinks the
111//!     link and creates a fresh file, its target untouched, and every
112//!     output is opened `O_NOFOLLOW`. An output that takes the input's mode
113//!     (a frame from a regular file) is created 0600 and given that mode,
114//!     owner and time through its own descriptor at the end, where lz4
115//!     creates it 0666 less the umask and sets them by name; one whose run
116//!     fails before that is given lz4's 0666 less the umask intersected
117//!     with the input's permission bits (lz4's mode for an ordinary 0644
118//!     input; a private input's partial output stays private). Why: a
119//!     planted link must not redirect a write, and a private input must not
120//!     sit readable while it is written.
121
122use std::collections::BTreeMap;
123use std::ffi::{OsStr, OsString};
124use std::io::{Read, Write};
125use std::path::Path;
126use std::process::ExitCode;
127use std::sync::atomic::AtomicU64;
128use std::sync::{Arc, Mutex};
129
130use super::CountedRun;
131use super::host::{self, Host, SANDBOX_OUTPUT_CAP, Sandbox};
132
133pub(crate) mod cli;
134mod decode;
135mod display;
136mod io;
137mod list;
138mod messages;
139
140use cli::{Command, Op, Stop};
141use messages::{self as m, printf};
142
143/// Entry point for the four names; `name` is the canonical invoked name,
144/// `argv0` the program as invoked (its last component is the program name
145/// the usage texts print, and picks the presets) and `args` the arguments
146/// after it.
147pub fn run(name: &'static str, argv0: &OsStr, args: Vec<OsString>) -> ExitCode {
148    // Ruling T3-c, deviation 4: as `gzip::run` and `xz::run`.
149    host::reset_sigpipe();
150    let argv0 = if argv0.is_empty() {
151        name.as_bytes()
152    } else {
153        argv0.as_encoded_bytes()
154    };
155    let env = |k: &str| std::env::var_os(k);
156    let code = main(argv0, &args, &env, &mut Host::Real, &mut std::io::stderr());
157    // The process's exit status is the code's low byte, as `exit()` makes
158    // it.
159    ExitCode::from(code as u8)
160}
161
162/// The exit codes the reference produced over every row of the differential
163/// suite and its mutation fuzz: the set the `compat` fuzz target accepts
164/// under the lz4 names (with 0 and 1 always allowed there).
165///
166/// Probed 2026-10-16 over the rows and over ~3000 corruptions and cuts
167/// of ten frames (`-t`, `-dc`, `--list`).
168///
169/// Task 3 added 50: `lz4 -c --rm` on standard input is `Error 50 :
170/// Remove error : stdin: No such file or directory` (probed 2026-10-16).
171///
172/// Task 4 added 69, 72 and 74: `--sparse` decoding into a pipe (`lz4
173/// --sparse -dc X | cat`: `Error 69 : Final skip error (sparse file)`,
174/// `Error 72 : Sparse skip error(29): Illegal seek ; try --no-sparse`,
175/// `Error 74 : Sparse skip error ; try --no-sparse`; probed 2026-10-16,
176/// pinned by `sparse_output_matches_the_reference`).
177///
178/// Task 5 added 71 (and met 72 again): `--list` of a file that is only a
179/// frame's magic number is `Error 71 : Error reading NAME`, of one cut
180/// inside a header that promises a content size `Error 72 : Error
181/// reading NAME` (probed 2026-10-16, pinned by the `--list` rows).
182#[doc(hidden)]
183pub const EXIT_CODES: [i32; 19] = [
184    0, 1, 26, 27, 34, 38, 40, 42, 43, 44, 50, 61, 63, 64, 69, 70, 71, 72, 74,
185];
186
187/// [`run`] without the process: operands resolve against `dir`, standard
188/// input is `stdin` (a pipe), standard output and error are captured and
189/// returned with the exit code, neither is a terminal, `env` stands in for
190/// the environment (`LZ4_CLEVEL`, `LZ4_NBWORKERS`) and `SIGPIPE` is left
191/// alone. The machine is 8 logical and 4 physical cores (R4).
192///
193/// R9: nothing needs a cap beyond the shared output budget
194/// ([`SANDBOX_OUTPUT_CAP`], standard output and output files together):
195/// compression always runs on one thread, a frame's blocks are at most
196/// 4 MiB and a legacy block at most 8 MiB by format, and dictionary files
197/// are read through [`Host::track`] like every input. No signal handler is
198/// installed. For the fuzz target; not a stable interface.
199#[doc(hidden)]
200pub fn run_in_counted(
201    dir: &Path,
202    name: &'static str,
203    args: &[OsString],
204    stdin: &[u8],
205    env: &dyn Fn(&str) -> Option<OsString>,
206) -> CountedRun {
207    let stdout = Arc::new(Mutex::new(Vec::new()));
208    let reads = Arc::new(Mutex::new(BTreeMap::new()));
209    let mut host = Host::Sandbox(Sandbox {
210        dir: dir.to_path_buf(),
211        stdin: Some(stdin.to_vec()),
212        stdout: stdout.clone(),
213        budget: Arc::new(AtomicU64::new(SANDBOX_OUTPUT_CAP)),
214        reads: reads.clone(),
215    });
216    let mut err = Vec::new();
217    let code = main(name.as_bytes(), args, env, &mut host, &mut err);
218    drop(host);
219    let out = std::mem::take(&mut *stdout.lock().unwrap_or_else(|p| p.into_inner()));
220    let reads = std::mem::take(&mut *reads.lock().unwrap_or_else(|p| p.into_inner()));
221    (code, out, err, reads)
222}
223
224/// The run below the process boundary. Returns the exit code.
225fn main(
226    argv0: &[u8],
227    args: &[OsString],
228    env: &dyn Fn(&str) -> Option<OsString>,
229    host: &mut Host,
230    err: &mut dyn Write,
231) -> i32 {
232    let program = cli::program_name(argv0);
233    let mut out = host.out_writer(None);
234    let stop = match cli::parse(program, env, args, host::cores(host), &mut out, err) {
235        Ok(cmd) => execute(&cmd, host, &mut out, err),
236        Err(stop) => stop,
237    };
238    let _ = out.flush();
239    drop(out);
240    if stop.pause {
241        // probe: `lz4 -p -V < /dev/null`: the prompt, then a read of
242        // standard input.
243        printf(err, m::PRESS_ENTER, &[]);
244        let mut byte = [0u8; 1];
245        let _ = host.stdin().read(&mut byte);
246    }
247    stop.code
248}
249
250/// Everything after a successful parse.
251fn execute(cmd: &Command, host: &mut Host, out: &mut dyn Write, err: &mut dyn Write) -> Stop {
252    announce(cmd, err);
253    let plan = match cli::plan(cmd, host.stdin_is_tty(), host.stdout_is_tty()) {
254        Ok(plan) => plan,
255        Err(refusal) => {
256            return Stop {
257                code: cli::refuse(err, cmd, refusal),
258                pause: false,
259            };
260        }
261    };
262    let ended = match plan.op {
263        Op::Compress => io::compress_files(cmd, &plan, host, out, err),
264        Op::Decompress | Op::Test => io::decompress_files(cmd, &plan, host, out, err),
265        Op::List => list::list_files(cmd, host, out, err),
266    };
267    Stop {
268        code: ended.code,
269        pause: ended.pause,
270    }
271}
272
273/// What lz4 prints once the command line has parsed, before anything else
274/// (probe: `lz4 -v -d nosuch`, `lz4 -vv -t a`, `lz4 -vv --list b.lz4`; the
275/// same under every operation, and before a refusal or a usage error that
276/// the arguments did not raise): the banner at display level 3 and up,
277/// and at level 4 and up the two `_POSIX_VERSION` lines, after a
278/// `_POSIX_C_SOURCE` line on Linux.
279fn announce(cmd: &Command, err: &mut dyn Write) {
280    if cmd.display_level >= 3 {
281        cli::print_banner(err);
282    }
283    if cmd.display_level >= 4 {
284        // probe: `lz4 -vv -c f` on Linux, glibc and musl (CI run
285        // 39166896170; see `m::POSIX_C_SOURCE`); none on macOS.
286        #[cfg(target_os = "linux")]
287        printf(err, m::POSIX_C_SOURCE, &[]);
288        let v = posix_version();
289        printf(err, m::POSIX_VERSION, &[v.as_bytes(), v.as_bytes()]);
290    }
291}
292
293/// `_POSIX_VERSION` as the system says it (200112 on macOS, probed with
294/// lz4: `_POSIX_VERSION defined: 200112L`; 200809 on Linux, probed with the
295/// tarball build on glibc and musl and on CI run 39166896170);
296/// `sysconf(_SC_VERSION)` is the run-time spelling of the same fact.
297fn posix_version() -> String {
298    #[cfg(unix)]
299    {
300        // SAFETY: `sysconf` reads no memory of ours and has no
301        // preconditions; `_SC_VERSION` is a valid name on every unix.
302        let v = unsafe { libc::sysconf(libc::_SC_VERSION) };
303        if v > 0 {
304            return v.to_string();
305        }
306    }
307    // Where the system cannot say: the value lz4 prints on macOS.
308    "200112".to_string()
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn run(name: &'static str, args: &[&str], stdin: &[u8]) -> (i32, String, String) {
316        let dir = std::env::temp_dir().join(format!("stuffr-lz4-mod-{}", std::process::id()));
317        let _ = std::fs::remove_dir_all(&dir);
318        std::fs::create_dir_all(&dir).unwrap();
319        std::fs::write(dir.join("a"), b"hello\n").unwrap();
320        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
321        let (code, out, err, _) = run_in_counted(&dir, name, &args, stdin, &|_| None);
322        let _ = std::fs::remove_dir_all(&dir);
323        (
324            code,
325            String::from_utf8(out).unwrap(),
326            String::from_utf8(err).unwrap(),
327        )
328    }
329
330    /// The sandbox runs the parse and the decision table: `-V` reaches its
331    /// captured standard output, `-p` reads its standard input, and a
332    /// refusal answers before anything is opened.
333    #[test]
334    fn a_sandboxed_run_parses_and_plans() {
335        let banner = format!(
336            "*** lz4 v1.10.0 64-bit multithread (stuffr {}), by Yann Collet ***\n",
337            env!("CARGO_PKG_VERSION")
338        );
339        assert_eq!(run("lz4", &["-V"], b""), (0, banner.clone(), String::new()));
340        assert_eq!(
341            run("lz4", &["-p", "-V"], b"\n"),
342            (0, banner, "Press enter to continue...\n".into())
343        );
344        let (code, out, err) = run("unlz4", &["a"], b"");
345        assert_eq!((code, out.as_str()), (1, ""));
346        assert!(
347            err.starts_with("Cannot determine an output filename \n"),
348            "{err}"
349        );
350        let (code, _, err) = run("lz4", &["--bogus"], b"");
351        assert_eq!(code, 1);
352        assert!(err.starts_with("Incorrect parameters\n"), "{err}");
353    }
354
355    /// A scratch directory for one sandboxed run, unique in the process.
356    struct Scratch(std::path::PathBuf);
357    impl Scratch {
358        fn new(files: &[(&str, &[u8])]) -> Scratch {
359            static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
360            let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
361            let dir =
362                std::env::temp_dir().join(format!("stuffr-lz4-io-{}-{n}", std::process::id()));
363            let _ = std::fs::remove_dir_all(&dir);
364            std::fs::create_dir_all(&dir).unwrap();
365            for (name, data) in files {
366                std::fs::write(dir.join(name), data).unwrap();
367            }
368            Scratch(dir)
369        }
370        fn run(&self, name: &'static str, args: &[&str], stdin: &[u8]) -> CountedRun {
371            let args: Vec<OsString> = args.iter().map(OsString::from).collect();
372            run_in_counted(&self.0, name, &args, stdin, &|_| None)
373        }
374    }
375    impl Drop for Scratch {
376        fn drop(&mut self) {
377            let _ = std::fs::remove_dir_all(&self.0);
378        }
379    }
380
381    /// A sandboxed run compresses into a file, decodes to its captured
382    /// standard output, tests, and counts what it read through
383    /// `Host::track` (R9).
384    #[test]
385    fn a_sandboxed_run_compresses_decodes_and_tests() {
386        let plain = b"hello hello hello hello\n".repeat(50);
387        let d = Scratch::new(&[("a", &plain)]);
388        let (code, out, err, reads) = d.run("lz4", &["-q", "a"], b"");
389        assert_eq!((code, out.len(), err.len()), (0, 0, 0));
390        let read = reads.get(&d.0.join("a")).copied().unwrap_or_default();
391        assert_eq!((read.opens, read.bytes), (1, plain.len() as u64));
392        let (code, out, err, _) = d.run("lz4cat", &["a.lz4"], b"");
393        assert_eq!((code, out, err.len()), (0, plain.clone(), 0));
394        let (code, _, err, _) = d.run("lz4", &["-t", "a.lz4"], b"");
395        assert_eq!(code, 0);
396        let err = String::from_utf8(err).unwrap();
397        let summary = format!("a.lz4{}: decoded 1200 bytes \n", " ".repeat(26));
398        assert!(err.ends_with(&summary), "{err:?}");
399    }
400
401    /// R11 in the sandbox: the overwrite question reads the run's standard
402    /// input, and the data after the answer is the input compressed.
403    #[test]
404    fn the_overwrite_question_reads_standard_input() {
405        let d = Scratch::new(&[("e", b"old")]);
406        let (code, _, err, _) = d.run("lz4", &["-", "e"], b"y\nhello");
407        assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err));
408        let (code, out, _, _) = d.run("lz4", &["-dc", "e"], b"");
409        assert_eq!((code, out.as_slice()), (0, &b"hello"[..]));
410        let (code, _, err, _) = d.run("lz4", &["-", "e"], b"n\nhello");
411        assert_eq!(code, 1);
412        let refused = format!("?{}not overwritten  \n", " ".repeat(5));
413        assert!(String::from_utf8(err).unwrap().ends_with(&refused));
414    }
415
416    /// `--sparse` to standard output: a sandbox's is a pipe, which refuses
417    /// the first seek, as lz4's does (`Error 72` for data, `Error 69` for
418    /// an output of zeros only).
419    #[test]
420    fn sparse_standard_output_is_a_pipe_in_the_sandbox() {
421        let d = Scratch::new(&[("a", b"AAAAAAAA"), ("z", &[0u8; 100])]);
422        assert_eq!(d.run("lz4", &["-q", "-m", "a", "z"], b"").0, 0);
423        let (code, out, err, _) = d.run("lz4", &["--sparse", "-dc", "z.lz4"], b"");
424        assert_eq!((code, out.len()), (69, 0));
425        assert_eq!(err, b"Error 69 : Final skip error (sparse file)\n \n");
426        let (code, out, err, _) = d.run("lz4", &["--sparse", "-dc", "a.lz4"], b"");
427        assert_eq!((code, out.len()), (72, 0));
428        let err = String::from_utf8(err).unwrap();
429        assert!(err.starts_with("Error 72 : Sparse skip error("), "{err:?}");
430        assert!(err.ends_with(" ; try --no-sparse \n"), "{err:?}");
431        // Without `--sparse`, standard output is written plainly.
432        let (code, out, _, _) = d.run("lz4", &["-dc", "a.lz4"], b"");
433        assert_eq!((code, out.as_slice()), (0, &b"AAAAAAAA"[..]));
434    }
435
436    /// Spec deviation 3 in the sandbox: an output that is the input is
437    /// refused and the input kept.
438    #[test]
439    fn an_output_that_is_the_input_is_refused() {
440        let d = Scratch::new(&[("a", b"keep me")]);
441        let (code, _, err, _) = d.run("lz4", &["-f", "a", "a"], b"");
442        assert_eq!(code, 1);
443        assert_eq!(err, b"a is the input file; not overwritten (stuffr)  \n");
444        assert_eq!(std::fs::read(d.0.join("a")).unwrap(), b"keep me");
445    }
446}