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}