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}