stuffr_cli/compat/unzip/mod.rs
1//! The unzip family (`unzip`, `zipinfo`, `funzip`): stuffr behaving as the
2//! platform's Info-ZIP UnZip 6.00.
3//!
4//! **A port** of Apple's `zip-33` tree (`unzip/unzip60/`, the Info-ZIP
5//! licence version 2009-Jan-02, `LICENSE-infozip`; this is an altered
6//! version, marked as such by every banner). Each ported function carries
7//! `// port: <file>.c:<function>`, each Apple change `// apple:
8//! <file>.c:<line>`, each Ubuntu difference `// ubuntu: <patch>` or `//
9//! ubuntu: -D<DEFINE>`, and each rule taken from the binary rather than the
10//! source `// probe: <command>`; where the two disagree, the binary wins.
11//! Every string is in `messages.rs`.
12//!
13//! **Flavours** ([`Flavour`], `flavour.rs`): Apple's `/usr/bin/unzip` on
14//! macOS and the BSDs, Ubuntu 24.04's `6.0-28ubuntu4.1` elsewhere
15//! ([`default_flavour`]), or as `STUFFR_UNZIP_FLAVOR` (`apple`, `ubuntu`)
16//! says; [`run`] reads it once, and anything else in it is refused, exit 1,
17//! in stuffr's own words. Ubuntu's build options (its `-D` DEFINES) and
18//! its patch series are flavour checks at the points they change
19//! behaviour (R13), each marked `// ubuntu:`.
20//!
21//! **G becomes `Uz`** (R5): Info-ZIP's global `G` (and its `uO`
22//! options) is one value per run, holding the host, the flavour, the
23//! options and the state, so a sandboxed run ([`run_in_counted`]) touches
24//! nothing of the process.
25//!
26//! **What is here:** the names, the flavour, the
27//! command line (`unzip.c`'s `unzip()` front half and `uz_opts()`,
28//! `zipinfo.c`'s `zi_opts()`, `envargs.c`), the usage screens, `-hh`, the
29//! `-v` screen and funzip's usage (Task 7); opening archives, wildcard
30//! archive names, the end records, Zip64, the central directory walk,
31//! wildcards and unzip's listings `-l`, `-v`, `-z`, `-T` (`process.rs`,
32//! `unix/mod.rs`, `matching.rs`, `list.rs`, `fileio.rs`: Task 8); zipinfo's
33//! listings, every format, the totals and the `-v` report (`zipinfo/`:
34//! Task 9); testing and extraction (`extract/`: `extract.c`, `fileio.c`'s
35//! output side, crypt.c's `decrypt()`; `unix/mapname.rs`, `unix/attrs.rs`:
36//! `unix.c`'s names, paths, modes, times and symlinks; `unix/beneath.rs`:
37//! the resolve-beneath walk every change goes through; Task 10); the
38//! terminal (`ttyio.rs`: `getp()` on `/dev/tty`, `zgetch()`,
39//! `screensize()`; `fileio.rs`: `UzpPassword()`, `UzpMorePause()`; `-M`'s
40//! line counting in `Uz::print_raw`; crypt.c's retries; the interrupt
41//! handler, exit 80: Task 11); funzip (`funzip.rs`: funzip.c, Task 12);
42//! Ubuntu's build options and patch series as flavour checks throughout
43//! (Tasks 13 and 14; `process/unicode.rs` is Ubuntu's alone).
44
45//! Known deviations (the source of truth; the README and the spec mirror
46//! this list):
47//!
48//! 1. SIGPIPE: `run` restores SIGPIPE's default action, as the other
49//! families do; under a parent that ignores SIGPIPE, unzip sees `EPIPE`
50//! and stuffr still dies of the signal (the Rust runtime ignores it
51//! before `main`, so the inherited disposition cannot be told apart).
52//! 2. The first banner line of each program's usage and `-v` names stuffr
53//! and says it is altered (Info-ZIP licence clause 3):
54//! `UnZip 6.00 of 20 April 2009, by stuffr X.Y.Z (altered; original by
55//! Info-ZIP, with modifications by Apple Inc.).` (Ubuntu: `(altered;
56//! Debian's build, original by Info-ZIP).`), and likewise for zipinfo
57//! and funzip. That line is wider than the pager's 80 columns, so under
58//! `-M` it counts as two screen lines and `-Mh` pauses one text line
59//! earlier than the reference (P26). `-v`'s `Compiled with` line is the
60//! recorded reference build's (Apple: this release's macOS build), so
61//! other macOS builds print a different one.
62//! 3. Pre-existing symlinks: the Ubuntu flavour also stays beneath the
63//! extraction root, as Apple's does. On unix only: off unix (Windows)
64//! the walk (`unix/beneath.rs`'s `imp_std`) follows symlinks, so
65//! neither flavour is contained there.
66//! 4. The archive being read is never overwritten.
67//! 5. `-O`/`-I` (Ubuntu) convert names through `iconv(3)`; they are
68//! unavailable on Windows, where they are parsed as Ubuntu parses them
69//! and convert nothing, as a conversion Ubuntu's `iconv_open()` refuses
70//! (silently: the names stay as stored; ruling T14-5).
71//! 6. Locale-dependent output is identical under `C`, `C.UTF-8` and
72//! `en_US.UTF-8` only.
73//! 7. On macOS the Ubuntu flavour also propagates the archive's quarantine
74//! attribute (R9), as Apple's does.
75//! 8. The terminal (T11-1, P28): after a prompt, a pager key or an
76//! interrupt, the terminal's mode is put back exactly as it was, where
77//! unzip's `echon()` only sets echo again (after an interrupt in the
78//! `-M` pager it leaves canonical mode off). A SIGABRT stuffr raises
79//! itself (a crash) kills it by the signal; one sent by another process
80//! exits 80, as unzip does for both. funzip installs no handler, so a
81//! signal at its password prompt kills it, echo off; stuffr puts the
82//! mode back first, then dies of the same signal (`host::arm_tty_restore`).
83//! 9. funzip never loses a write failure (T12-4): a failed write to
84//! standard output is answered `out of space on stdout`, exit 9, on a
85//! real host too. The C writes through buffered stdio and does not check
86//! its final `fflush()`, so it loses the data and exits 0.
87//! 10. Undefined behaviour and endless loops are not reproduced. Where a
88//! reference's behaviour is undefined, or never ends, stuffr gives a
89//! defined answer instead. Ubuntu's bzip2 decoding loops for ever on a
90//! truncated entry; stuffr answers as for bad bzip2 data, exit 2
91//! (T13-2). Apple's `-:` on a `../` directory entry changes the
92//! directory above the extraction root; stuffr refuses (T10-10). After
93//! a failed create Apple writes through the previous entry's closed
94//! stream; stuffr says "cannot create" and goes on (T10-11). At the end
95//! of input the C asks for a new name for ever at the rename prompt,
96//! and waits for ever at the `-M` pager's; stuffr takes `[N]one` and
97//! stops pausing (T10-9, T11-4). Buffer overflows are answered in both
98//! flavours with the forms of Ubuntu's CVE fixes: a method above 999
99//! is `Unk` and four hex digits (`Unk03E8`), and a password prompt
100//! whose names are too long is "Enter password: " (T11-5). The full
101//! set is in the code's `// ruling:` comments.
102
103use std::collections::BTreeMap;
104use std::ffi::{OsStr, OsString};
105use std::io::{self, BufWriter, Write};
106use std::path::Path;
107use std::process::ExitCode;
108use std::sync::atomic::AtomicU64;
109use std::sync::{Arc, Mutex};
110
111use super::CountedRun;
112use super::host::{self, Host, SANDBOX_OUTPUT_CAP, Sandbox};
113
114pub(crate) mod cli;
115mod extract;
116mod fileio;
117pub(crate) mod flavour;
118mod funzip;
119mod list;
120mod matching;
121mod messages;
122mod process;
123mod ttyio;
124mod unix;
125mod zipinfo;
126
127pub use flavour::Flavour;
128use messages::{self as m, printf};
129
130/// The environment variable that overrides [`default_flavour`].
131const FLAVOR_VAR: &str = "STUFFR_UNZIP_FLAVOR";
132
133// unzip.h's PK_* exit codes (those the front end returns).
134/// unzip.h:PK_OK, no error.
135pub(crate) const PK_OK: i32 = 0;
136/// unzip.h:PK_COOL, no error (the usage screen was asked for).
137pub(crate) const PK_COOL: i32 = 0;
138/// unzip.h:PK_WARN, a warning.
139pub(crate) const PK_WARN: i32 = 1;
140/// unzip.h:PK_ERR, an error in the archive.
141pub(crate) const PK_ERR: i32 = 2;
142/// unzip.h:PK_BADERR, a severe error.
143pub(crate) const PK_BADERR: i32 = 3;
144/// unzip.h:PK_NOZIP, the archive was not found.
145pub(crate) const PK_NOZIP: i32 = 9;
146/// unzip.h:PK_PARAM, bad or illegal parameters.
147pub(crate) const PK_PARAM: i32 = 10;
148/// unzip.h:PK_FIND, no files found.
149pub(crate) const PK_FIND: i32 = 11;
150/// unzip.h:PK_EOF, an unexpected end of the archive.
151pub(crate) const PK_EOF: i32 = 51;
152/// unzip.h:IZ_CTRLC, "user hit ^C to terminate": the interrupt's exit code.
153pub(crate) const IZ_CTRLC: i32 = 80;
154/// unzpriv.h:IZ_DIR, the archive operand is a directory (never an exit
155/// code: `process_zipfiles()` counts it).
156pub(crate) const IZ_DIR: i32 = 76;
157
158// unzpriv.h's message flag bits, read by `Uz::info` (fileio.c's
159// `UzpMessagePrnt`).
160/// unzpriv.h:MSG_STDERR, bit 0: standard error (standard output under `-t`).
161pub(crate) const MSG_STDERR: u32 = 0x0001;
162/// unzpriv.h:MSG_LNEWLN, bit 5: a leading newline when not at the start of
163/// a line.
164pub(crate) const MSG_LNEWLN: u32 = 0x0020;
165/// unzpriv.h:MSG_TNEWLN, bit 6: a trailing newline when the text lacks one.
166pub(crate) const MSG_TNEWLN: u32 = 0x0040;
167
168/// The exit codes the family can return under Apple's flavour (the plan's
169/// Global Constraints): 0–3, 4–8 (`PK_MEM*`, only where the reference gives
170/// them), 9–11, 50, 51, 80–82. For the `compat` fuzz target (Task 15).
171#[doc(hidden)]
172pub const EXIT_CODES_APPLE: &[i32] = &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 50, 51, 80, 81, 82];
173/// [`EXIT_CODES_APPLE`] plus 12 (`PK_BOMB`, ubuntu:
174/// 23-cve-2019-13232-zip-bomb-with-overlapped-entries.patch).
175#[doc(hidden)]
176pub const EXIT_CODES_UBUNTU: &[i32] =
177 &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 50, 51, 80, 81, 82];
178
179/// The flavour a build uses when `STUFFR_UNZIP_FLAVOR` is unset or empty:
180/// Apple on macOS and the BSDs, Ubuntu everywhere else, Windows included.
181pub fn default_flavour() -> Flavour {
182 if cfg!(any(
183 target_os = "macos",
184 target_os = "freebsd",
185 target_os = "netbsd",
186 target_os = "openbsd",
187 target_os = "dragonfly"
188 )) {
189 Flavour::Apple
190 } else {
191 Flavour::Ubuntu
192 }
193}
194
195/// The flavour `STUFFR_UNZIP_FLAVOR`'s value selects: `apple` or `ubuntu`,
196/// the default when unset or empty, and `Err(value)` for anything else.
197pub(crate) fn flavour_from(value: Option<OsString>) -> Result<Flavour, OsString> {
198 match value {
199 None => Ok(default_flavour()),
200 Some(v) if v.is_empty() => Ok(default_flavour()),
201 Some(v) if v == "apple" => Ok(Flavour::Apple),
202 Some(v) if v == "ubuntu" => Ok(Flavour::Ubuntu),
203 Some(v) => Err(v),
204 }
205}
206
207/// One run's state: Info-ZIP's `G` (globals.h `Uz_Globs`) with its `uO`
208/// ([`cli::Options`]), R5: the command line, the message layer, the
209/// archive and entry state, and the window the decoders share.
210pub(crate) struct Uz<'a> {
211 /// The process, or a sandbox's stand-ins for it.
212 pub(crate) host: Host,
213 /// Which reference this run behaves as.
214 pub(crate) flavour: Flavour,
215 /// `G.UzO` (`uO`): the options.
216 pub(crate) o: cli::Options,
217 /// `getenv`: the process's environment, or a sandbox's stand-in.
218 pub(crate) env: &'a dyn Fn(&str) -> Option<OsString>,
219 /// Standard output (stdio's `stdout`), flushed after every message as
220 /// `UzpMessagePrnt` does.
221 pub(crate) out: BufWriter<Box<dyn Write + Send>>,
222 /// Standard error.
223 pub(crate) err: &'a mut dyn Write,
224 /// `G.sol`: the last message ended a line (`TRUE` at start).
225 pub(crate) sol: bool,
226 /// `G.noargs`: the command line had no arguments at all.
227 pub(crate) noargs: bool,
228 /// `G.extract_flag`: the run extracts (no `-c -t -l -v -z -T`, not
229 /// zipinfo).
230 pub(crate) extract_flag: bool,
231 /// `G.process_all_files`: no names were given after the archive.
232 pub(crate) process_all_files: bool,
233 /// `G.native_is_utf8` (ubuntu: -DUTF8_MAYBE_NATIVE): the locale's
234 /// codeset is UTF-8 (`fileio::init_locale`; `false` under `LANG=C` and
235 /// in a sandbox).
236 /// Apple's `fnfilter()` reads the same fact through `isprint()`.
237 pub(crate) native_is_utf8: bool,
238 /// `G.unicode_escape_all` (ubuntu: -DUNICODE_WCHAR): `-U` given once.
239 pub(crate) unicode_escape_all: bool,
240 /// `checkdir()`'s state: the extraction root (`G.rootpath`,
241 /// `G.rootlen`, Apple's `G.rootdir`: apple: globals.c:164, `-1` at
242 /// start) and the path being built (`unix/mapname.rs`).
243 pub(crate) ck: unix::mapname::Checkdir,
244 /// `G.qf`, what the archive's quarantine gives what is extracted from
245 /// it (P7's rewritten value; `NULL`: none; apple: globals.c:165).
246 pub(crate) qf: Option<Vec<u8>>,
247 /// `G.overwrite_mode`: query, always or never (`extract::OVERWRT_*`).
248 pub(crate) overwrite_mode: i32,
249 /// `G.answerbuf`: the last answer read from standard input (kept when
250 /// an answer finds the end of input).
251 pub(crate) answerbuf: [u8; 10],
252 /// Standard input as one stdio stream for the run (`fgets(...,
253 /// stdin)`), opened at the first question ([`Uz::stdin`]).
254 pub(crate) stdin: Option<Box<dyn io::BufRead>>,
255 /// `G.key`: the password tried on encrypted entries (zeroed when
256 /// dropped).
257 pub(crate) key: Option<ttyio::Password>,
258 /// `G.nopwd`: no more prompting (`-P`, or "skip all").
259 pub(crate) nopwd: bool,
260 /// `G.newzip`: no encrypted entry of this archive seen yet.
261 pub(crate) newzip: bool,
262 /// The run ends with this code: readbyte()'s `EXIT(PK_BADERR)` after a
263 /// read error.
264 pub(crate) abort: Option<i32>,
265 /// What `Info()` formatted into `slide` while a decoder held it, in
266 /// order: written to its front when it comes back (P13).
267 /// Each is an offset, the bytes and whether a NUL follows them.
268 pub(crate) pending_slide: Vec<(usize, Vec<u8>, bool)>,
269 /// `G.area.Slide`: the one window every decoder writes into and the
270 /// message layer formats into (rulings P13/P16), at least
271 /// `stuffr::infozip::WSIZE`, reused across entries. `fnfilter()` and
272 /// `do_string()` write into it as the C does.
273 pub(crate) slide: Box<[u8]>,
274 /// The archive input: `G.zipfd`, `G.inbuf` and `readbuf()`'s state.
275 pub(crate) zin: fileio::ZipIn,
276 /// `G.wildzipfn`: the archive operand as given.
277 pub(crate) wildzipfn: Vec<u8>,
278 /// `G.zipfn`: the archive being processed.
279 pub(crate) zipfn: Vec<u8>,
280 /// `G.pfnames[0..G.filespecs]`: the include patterns.
281 pub(crate) pfnames: Vec<Vec<u8>>,
282 /// `G.pxnames[0..G.xfilespecs]`: the exclude patterns.
283 pub(crate) pxnames: Vec<Vec<u8>>,
284 /// `G.no_ecrec`: an earlier candidate had no end record (so the last
285 /// chance says "cannot find zipfile directory").
286 pub(crate) no_ecrec: bool,
287 /// `G.ecrec`: the end-of-central-directory record.
288 pub(crate) ecrec: process::Ecrec,
289 /// `G.lrec`: the last local header read (getZip64Data() reads it too).
290 pub(crate) lrec: process::Lrec,
291 /// `G.csize`: the entry's compressed bytes left (a C `zoff_t`).
292 pub(crate) csize: i64,
293 /// `G.zip64` (ubuntu: 23-cve-2019-13232-zip-bomb-with-overlapped-
294 /// entries.patch): cleared by each `getZip64Data()` and set by a Unicode
295 /// Path block that `getUnicodeData()` takes (where the patch's hunk
296 /// landed), never by a Zip64 block; `extract_or_test_member()`'s data
297 /// descriptor skip reads it.
298 pub(crate) zip64: bool,
299 /// `G.real_ecrec_offset`: where the end record was found.
300 pub(crate) real_ecrec_offset: i64,
301 /// `G.expect_ecrec_offset`: where the directory says it should be.
302 pub(crate) expect_ecrec_offset: i64,
303 /// `G.sig`: the last signature read (bytes a short read did not
304 /// replace are the earlier ones).
305 pub(crate) sig: [u8; 4],
306 /// `G.filename`, `FILNAMSIZ` bytes (`flavour::filnamsiz`): the entry's
307 /// name as a C string, the bytes past its NUL left from earlier names.
308 pub(crate) filename: Vec<u8>,
309 /// `G.filename_full` (ubuntu: -DUNICODE_SUPPORT): the last name read,
310 /// whole, as a C string, for the Unicode Path checksum; `None` (`NULL`)
311 /// until a name is read. Its length is `G.fnfull_bufsize`.
312 pub(crate) filename_full: Option<Vec<u8>>,
313 /// `G.crec.relative_offset_local_header` as zipinfo's loop last left
314 /// it (after `getZip64Data()` for a selected entry): `zipinfo()` starts
315 /// the next archive's `endprev` at 4 when it is 4.
316 pub(crate) crec_relative_offset_local_header: u64,
317 /// `-M`'s pager (globals.h, `MORE`): `G.height`, the lines a screen
318 /// holds less two (`SCREENSIZE()`, read at every message).
319 pub(crate) height: i32,
320 /// `G.width`, the screen's columns (`SCREENLWRAP`: a line that reaches
321 /// it counts as two).
322 pub(crate) width: i32,
323 /// `G.lines`, lines shown since the last full screen.
324 pub(crate) lines: i32,
325 /// `G.numlines`, every line shown, for `--More--(%lu)`.
326 pub(crate) numlines: u64,
327 /// `G.chars`, the columns used on the current line.
328 pub(crate) chars: i32,
329}
330
331impl<'a> Uz<'a> {
332 // port: globals.c:globalsCtor
333 /// A run's `G` as `globalsCtor()` and Apple's `apple_globals_ctor()`
334 /// leave it.
335 pub(crate) fn new(
336 host: Host,
337 flavour: Flavour,
338 env: &'a dyn Fn(&str) -> Option<OsString>,
339 err: &'a mut dyn Write,
340 ) -> Uz<'a> {
341 let out = host.out_writer(None);
342 Uz {
343 host,
344 flavour,
345 o: cli::Options::new(),
346 env,
347 out,
348 err,
349 sol: true, // at start of line
350 noargs: false,
351 extract_flag: false,
352 process_all_files: false,
353 native_is_utf8: false,
354 unicode_escape_all: false,
355 // apple: globals.c:164 (`G.rootdir = -1`)
356 ck: unix::mapname::Checkdir::default(),
357 // apple: globals.c:165 (`G.qf = NULL`)
358 qf: None,
359 overwrite_mode: extract::OVERWRT_QUERY,
360 answerbuf: [0; 10],
361 stdin: None,
362 key: None,
363 nopwd: false,
364 newzip: false,
365 abort: None,
366 pending_slide: Vec::new(),
367 slide: vec![0u8; stuffr::infozip::WSIZE].into_boxed_slice(),
368 zin: fileio::ZipIn::new(),
369 wildzipfn: Vec::new(),
370 zipfn: Vec::new(),
371 pfnames: Vec::new(),
372 pxnames: Vec::new(),
373 no_ecrec: false,
374 ecrec: process::Ecrec::default(),
375 lrec: process::Lrec::default(),
376 csize: 0,
377 zip64: false,
378 real_ecrec_offset: 0,
379 expect_ecrec_offset: 0,
380 sig: [0; 4],
381 filename: vec![0u8; flavour::filnamsiz(flavour)],
382 filename_full: None,
383 crec_relative_offset_local_header: 0,
384 height: 0,
385 width: 0,
386 lines: 0,
387 numlines: 0,
388 chars: 0,
389 }
390 }
391
392 /// Standard input as stdio's `stdin`: a fill takes what one `read(2)`
393 /// returns (a line from a terminal, what a pipe holds), so a question
394 /// is answered as soon as its line is typed. A sandbox's is its bytes.
395 pub(crate) fn stdin(&mut self) -> &mut dyn io::BufRead {
396 if self.stdin.is_none() {
397 self.stdin = Some(match self.host {
398 Host::Real => Box::new(io::stdin().lock()),
399 Host::Sandbox(_) => Box::new(self.host.stdin()),
400 });
401 }
402 self.stdin.as_deref_mut().expect("just opened")
403 }
404
405 /// unzip's `EXIT(code)` from inside the run (the pager's `q`): the
406 /// process exits with `code` once its output is flushed. A sandbox
407 /// cannot exit: the code is kept in `abort`, and the caller returns.
408 pub(crate) fn exit_run(&mut self, code: i32) {
409 if matches!(self.host, Host::Real) {
410 let _ = self.out.flush();
411 let _ = self.err.flush();
412 std::process::exit(code);
413 }
414 self.abort = Some(code);
415 }
416
417 /// `Info(slide, flag, ((char *)slide, ...))`: the message formatted into
418 /// `slide`'s front (and its NUL), as the C's `sprintf` puts it there
419 /// (P13), then printed ([`Uz::print_raw`]).
420 pub(crate) fn info(&mut self, flag: u32, buf: &[u8]) {
421 let owned: Vec<u8>;
422 let buf = if flag & MSG_TNEWLN != 0
423 && ((buf.is_empty() && !self.sol) || (!buf.is_empty() && buf.last() != Some(&b'\n')))
424 {
425 // UzpMessagePrnt appends the newline in the buffer itself
426 owned = [buf, b"\n"].concat();
427 &owned[..]
428 } else {
429 buf
430 };
431 self.put_in_slide(buf);
432 self.print_raw(flag & !MSG_TNEWLN, buf);
433 }
434
435 /// `buf` at `slide`'s front with a NUL after it (or, while a decoder
436 /// holds the window, kept for [`Uz::restore_slide`]).
437 fn put_in_slide(&mut self, buf: &[u8]) {
438 self.put_slide_at(0, buf, true);
439 }
440
441 /// `buf` into `slide` at `at`, and a NUL after it when `nul` and it
442 /// fits: what `sprintf()` and `fnfilter()` write there. While a decoder
443 /// holds the window (`slide` empty) it is kept for
444 /// [`Uz::restore_slide`]; a write past the window's end is the C's
445 /// never-reached case and is dropped.
446 pub(crate) fn put_slide_at(&mut self, at: usize, buf: &[u8], nul: bool) {
447 if self.slide.is_empty() {
448 self.pending_slide.push((at, buf.to_vec(), nul));
449 return;
450 }
451 let end = at + buf.len();
452 if end <= self.slide.len() {
453 self.slide[at..end].copy_from_slice(buf);
454 if nul && end < self.slide.len() {
455 self.slide[end] = 0;
456 }
457 }
458 }
459
460 /// The window is back from a decoder: what `Info()` and `fnfilter()`
461 /// wrote meanwhile goes to it, in order (P13).
462 pub(crate) fn restore_slide(&mut self) {
463 for (at, b, nul) in std::mem::take(&mut self.pending_slide) {
464 self.put_slide_at(at, &b, nul);
465 }
466 }
467
468 // port: fileio.c:UzpMessagePrnt
469 /// `(*G.message)(&G, buf, size, flag)`: print `buf` to standard error
470 /// when `flag` has [`MSG_STDERR`] and `-t` is not set, else to standard
471 /// output, with the leading and trailing newlines `flag` asks for, and
472 /// flush. Write errors are ignored, as every `Info()` caller ignores
473 /// them. Under `-M` every line shown is counted (a line reaching the
474 /// screen's width counts too, `SCREENLWRAP`; no tab expansion, no
475 /// `TABSIZE` in either build) and a full screen pauses
476 /// ([`fileio::more_pause`], `--More--(N)`).
477 pub(crate) fn print_raw(&mut self, flag: u32, buf: &[u8]) {
478 let _ = self.message(flag, buf, false);
479 }
480
481 /// `(*G.message)(&G, rawbuf, size, 0)` for `-c` and `-p`'s data:
482 /// standard output, flushed (and paged under `-M`); a failed write is
483 /// returned (flush() then gives up on the entry).
484 pub(crate) fn print_data(&mut self, buf: &[u8]) -> io::Result<()> {
485 self.message(0, buf, true)
486 }
487
488 // port: fileio.c:UzpMessagePrnt
489 /// [`Uz::print_raw`] and [`Uz::print_data`]: `strict` returns the first
490 /// failed write, as `WriteTxtErr()`'s error is returned; a message's is
491 /// ignored, as every `Info()` caller ignores it. After the run's
492 /// `EXIT` (`abort`) nothing more is printed: the C has exited.
493 fn message(&mut self, flag: u32, buf: &[u8], strict: bool) -> io::Result<()> {
494 // ruling: T11-7. Nothing is printed after the run's `EXIT`.
495 if self.abort.is_some() {
496 return Ok(());
497 }
498 let to_stderr = flag & MSG_STDERR != 0 && !self.o.tflag;
499 let owned: Vec<u8>;
500 let buf = if flag & MSG_TNEWLN != 0
501 && ((buf.is_empty() && !self.sol) || (!buf.is_empty() && buf.last() != Some(&b'\n')))
502 {
503 owned = [buf, b"\n"].concat();
504 &owned[..]
505 } else {
506 buf
507 };
508 if self.o.m_flag {
509 // room for --More-- and one line of overlap:
510 let (height, width) = ttyio::screensize(&self.host);
511 self.height = height - 2;
512 self.width = width;
513 }
514 // error output from testing redirected: also send to stderr
515 let also_stderr = flag & MSG_STDERR != 0
516 && self.o.tflag
517 && !self.host.stdout_is_tty()
518 && self.host.stderr_is_tty();
519 if flag & MSG_LNEWLN != 0 && !self.sol {
520 // not at start of line: want newline
521 self.emit(to_stderr, b"\n", strict)?;
522 if self.o.m_flag {
523 self.chars = 0;
524 self.numlines += 1;
525 self.lines += 1;
526 if self.lines >= self.height {
527 self.more();
528 if self.abort.is_some() {
529 // ruling: T11-7 (`q`: the C has exited)
530 return Ok(());
531 }
532 }
533 }
534 if also_stderr {
535 let _ = self.err.write_all(b"\n");
536 let _ = self.err.flush();
537 }
538 self.sol = true;
539 }
540
541 // put zipfile name, filename and/or error/warning keywords here
542 let mut q = 0;
543 if self.o.m_flag {
544 for p in 0..buf.len() {
545 let mut islinefeed = false;
546 if buf[p] == b'\n' {
547 islinefeed = true;
548 } else if buf[p] == b'\r' {
549 self.chars = 0;
550 } else {
551 self.chars += 1;
552 if self.chars >= self.width {
553 islinefeed = true;
554 }
555 }
556 if islinefeed && self.o.m_flag {
557 // (`-M` off again mid-message: ruling T11-4's end of
558 // input)
559 self.chars = 0;
560 self.numlines += 1;
561 self.lines += 1;
562 if self.lines >= self.height {
563 self.emit(to_stderr, &buf[q..=p], strict)?;
564 self.sol = true;
565 q = p + 1;
566 self.more();
567 if self.abort.is_some() {
568 // ruling: T11-7
569 return Ok(());
570 }
571 }
572 }
573 }
574 }
575 let rest = &buf[q..];
576 if !rest.is_empty() {
577 self.emit(to_stderr, rest, strict)?;
578 if also_stderr {
579 let _ = self.err.write_all(rest);
580 let _ = self.err.flush();
581 }
582 self.sol = buf.last() == Some(&b'\n');
583 }
584 Ok(())
585 }
586
587 /// `(*G.mpause)(&G, MorePrompt, 1)`.
588 fn more(&mut self) {
589 let mut prompt = Vec::new();
590 printf(
591 &mut prompt,
592 m::MORE_PROMPT,
593 &[self.numlines.to_string().as_bytes()],
594 );
595 fileio::more_pause(self, &prompt, 1);
596 }
597
598 /// `WriteTxtErr(buf, outfp)` + `fflush(outfp)`: the error returned when
599 /// `strict`, else ignored.
600 fn emit(&mut self, to_stderr: bool, buf: &[u8], strict: bool) -> io::Result<()> {
601 if !strict {
602 self.write(to_stderr, buf);
603 return Ok(());
604 }
605 let w: &mut dyn Write = if to_stderr {
606 &mut *self.err
607 } else {
608 &mut self.out
609 };
610 w.write_all(buf)?;
611 w.flush()
612 }
613
614 /// [`Uz::info`] of `fmt` with its `%s`/`%d` filled from `args`.
615 pub(crate) fn info_fmt(&mut self, flag: u32, fmt: &str, args: &[&[u8]]) {
616 let mut buf = Vec::new();
617 printf(&mut buf, fmt, args);
618 self.info(flag, &buf);
619 }
620
621 /// One message to one stream, flushed (`WriteTxtErr` + `fflush`).
622 fn write(&mut self, to_stderr: bool, buf: &[u8]) {
623 if to_stderr {
624 let _ = self.err.write_all(buf);
625 let _ = self.err.flush();
626 } else {
627 let _ = self.out.write_all(buf);
628 let _ = self.out.flush();
629 }
630 }
631}
632
633/// Entry point for `unzip`, `zipinfo` and `funzip`; `name` is the
634/// canonical invoked name, `argv0` the program as invoked (unzip.c reads
635/// its last component for ZipInfo mode) and `args` the arguments after it.
636pub fn run(name: &'static str, argv0: &OsStr, args: Vec<OsString>) -> ExitCode {
637 // Deviation 1: as the other families.
638 host::reset_sigpipe();
639 let env = |k: &str| std::env::var_os(k);
640 let mut err = io::stderr();
641 let flavour = match flavour_from(env(FLAVOR_VAR)) {
642 Ok(f) => f,
643 Err(v) => {
644 printf(
645 &mut err,
646 m::FLAVOR_REFUSED,
647 &[name.as_bytes(), v.as_encoded_bytes()],
648 );
649 return ExitCode::from(1);
650 }
651 };
652 let argv0 = if argv0.is_empty() {
653 name.as_bytes()
654 } else {
655 argv0.as_encoded_bytes()
656 };
657 let code = main(flavour, name, argv0, &args, &env, Host::Real, &mut err);
658 // The process's exit status is the code's low byte, as `exit()` makes
659 // it (every code here is below 256).
660 ExitCode::from(code as u8)
661}
662
663/// [`run`] without the process, as `flavour` (R7): operands resolve against
664/// `dir`, standard input is `stdin` (a pipe of bytes), standard output and
665/// error are captured and returned with the exit code and what each input
666/// read, neither is a terminal, `env` stands in for the environment
667/// (`UNZIP`, `UNZIPOPT`, `ZIPINFO`, `ZIPINFOOPT`; `STUFFR_UNZIP_FLAVOR` is
668/// not consulted), no signal is touched, and standard output and output
669/// files share the [`SANDBOX_OUTPUT_CAP`] budget. For the fuzz target; not
670/// a stable interface.
671#[doc(hidden)]
672pub fn run_in_counted(
673 flavour: Flavour,
674 dir: &Path,
675 name: &'static str,
676 args: &[OsString],
677 stdin: &[u8],
678 env: &dyn Fn(&str) -> Option<OsString>,
679) -> CountedRun {
680 let stdout = Arc::new(Mutex::new(Vec::new()));
681 let reads = Arc::new(Mutex::new(BTreeMap::new()));
682 let host = Host::Sandbox(Sandbox {
683 dir: dir.to_path_buf(),
684 stdin: Some(stdin.to_vec()),
685 stdout: stdout.clone(),
686 budget: Arc::new(AtomicU64::new(SANDBOX_OUTPUT_CAP)),
687 reads: reads.clone(),
688 });
689 let mut err = Vec::new();
690 let code = main(flavour, name, name.as_bytes(), args, env, host, &mut err);
691 let out = std::mem::take(&mut *stdout.lock().unwrap_or_else(|p| p.into_inner()));
692 let reads = std::mem::take(&mut *reads.lock().unwrap_or_else(|p| p.into_inner()));
693 (code, out, err, reads)
694}
695
696/// The run below the process boundary. Returns the exit code.
697fn main(
698 flavour: Flavour,
699 name: &str,
700 argv0: &[u8],
701 args: &[OsString],
702 env: &dyn Fn(&str) -> Option<OsString>,
703 host: Host,
704 err: &mut dyn Write,
705) -> i32 {
706 if name == "funzip" {
707 let mut uz = Uz::new(host, flavour, env, err);
708 return funzip::funzip(&mut uz, args);
709 }
710 // port: unzip.c:unzip (`SET_SIGHANDLER(SIGINT, handler)` and its
711 // siblings): for the whole run, an interrupt puts the terminal's echo
712 // back and exits with IZ_CTRLC, leaving a partial output in place.
713 // probe: `kill -INT`/`-TERM`/`-ABRT` during `unzip big.zip` (Apple's and
714 // Ubuntu's): exit 80, nothing printed, `big.bin` kept, also under an
715 // inherited `SIG_IGN`; SIGHUP and SIGQUIT kill it.
716 let _interrupt =
717 host::arm_interrupt_exit(&host, host::UNZIP_SIGNALS, IZ_CTRLC, m::HANDLER_TEXT);
718 let mut uz = Uz::new(host, flavour, env, err);
719 uz.native_is_utf8 = fileio::init_locale(&uz.host);
720 // ubuntu: 20-unzip60-alt-iconv-utf8.patch (unzip.c: `#ifdef UNIX
721 // init_conversion_charsets()`, before the options are read)
722 if flavour::iconv_charsets(flavour) {
723 unix::charset::init_conversion_charsets(&mut uz);
724 }
725 let code = unzip(&mut uz, argv0, args);
726 let _ = uz.out.flush();
727 code
728}
729
730/// Whether `s` begins with `prefix`, ignoring ASCII case (`STRNICMP(s,
731/// prefix, n) == 0` with `n` the prefix's length).
732fn starts_with_ignore_case(s: &[u8], prefix: &[u8]) -> bool {
733 s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix)
734}
735
736// port: unzip.c:unzip
737/// unzip.c's `unzip()`: ZipInfo mode or UnZip mode, the command line, then
738/// the archives.
739fn unzip(uz: &mut Uz, argv0: &[u8], args: &[OsString]) -> i32 {
740 // The program name's last component (after `DIR_END`).
741 let start = argv0
742 .iter()
743 .rposition(|&b| b == b'/' || (cfg!(windows) && b == b'\\'))
744 .map_or(0, |i| i + 1);
745 let p = &argv0[start..];
746 let zipinfo = starts_with_ignore_case(p, b"zipinfo")
747 || starts_with_ignore_case(p, b"ii")
748 || args
749 .first()
750 .is_some_and(|a| a.as_encoded_bytes().starts_with(b"-Z"));
751 let parsed = if zipinfo {
752 cli::parse_zipinfo(uz, args)
753 } else {
754 cli::parse_unzip(uz, args)
755 };
756 match parsed {
757 Ok(p) => process_zipfiles(uz, &p),
758 Err(code) => code,
759 }
760}
761
762/// `process.c`'s `process_zipfiles()`.
763fn process_zipfiles(uz: &mut Uz, parsed: &cli::Parsed) -> i32 {
764 process::process_zipfiles(uz, parsed)
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 fn run(f: Flavour, name: &'static str, args: &[&str]) -> (i32, String, String) {
772 let args: Vec<OsString> = args.iter().map(OsString::from).collect();
773 let dir = std::env::temp_dir();
774 let (code, out, err, _) = run_in_counted(f, &dir, name, &args, b"", &|_| None);
775 (
776 code,
777 String::from_utf8(out).unwrap(),
778 String::from_utf8(err).unwrap(),
779 )
780 }
781
782 #[test]
783 fn the_default_flavour_follows_the_platform() {
784 let apple = cfg!(any(
785 target_os = "macos",
786 target_os = "freebsd",
787 target_os = "netbsd",
788 target_os = "openbsd",
789 target_os = "dragonfly"
790 ));
791 let want = if apple {
792 Flavour::Apple
793 } else {
794 Flavour::Ubuntu
795 };
796 assert_eq!(default_flavour(), want);
797 }
798
799 /// Each flavour's banners say whose build they alter (R2), with this
800 /// stuffr's version.
801 #[test]
802 fn banners_name_stuffr_and_the_build() {
803 let v = env!("CARGO_PKG_VERSION");
804 let (code, out, err) = run(Flavour::Apple, "unzip", &[]);
805 assert_eq!((code, err.as_str()), (0, ""));
806 let want = format!(
807 "UnZip 6.00 of 20 April 2009, by stuffr {v} (altered; original by Info-ZIP, \
808 with modifications by Apple Inc.).\nPlease report bugs to Apple.\n\nUsage: "
809 );
810 assert!(out.starts_with(&want), "{out}");
811 let (_, out, _) = run(Flavour::Ubuntu, "unzip", &["-h"]);
812 let want = format!(
813 "UnZip 6.00 of 20 April 2009, by stuffr {v} (altered; Debian's build, original \
814 by Info-ZIP).\n\nUsage: "
815 );
816 assert!(out.starts_with(&want), "{out}");
817 let (code, out, _) = run(Flavour::Apple, "zipinfo", &[]);
818 assert_eq!(code, 0);
819 assert!(
820 out.starts_with(&format!(
821 "ZipInfo 3.00 of 20 April 2009, by stuffr {v} (altered; original by Greg \
822 Roelofs and the Info-ZIP group, with modifications by Apple Inc.).\n\nList"
823 )),
824 "{out}"
825 );
826 let (_, out, _) = run(Flavour::Ubuntu, "unzip", &["-Z"]);
827 assert!(
828 out.starts_with(&format!(
829 "ZipInfo 3.00 of 20 April 2009, by stuffr {v} (altered; Debian's build, \
830 original by Greg Roelofs and the Info-ZIP group).\n"
831 )),
832 "{out}"
833 );
834 // deviation 2: the Apple line is the recorded build's, pinned exactly
835 let (_, out, _) = run(Flavour::Apple, "unzip", &["-v"]);
836 assert!(
837 out.contains(
838 "\nCompiled with gcc Apple LLVM 21.0.0 (clang-2100.3.31.1) [+internal-os] \
839 for Unix Mac OS X on Aug 8 2026.\n\n"
840 ),
841 "{out}"
842 );
843 let (_, out, _) = run(Flavour::Ubuntu, "unzip", &["-v"]);
844 assert!(
845 out.contains("\nCompiled with gcc 13.2.0 for Unix (Linux ELF).\n\n"),
846 "{out}"
847 );
848 }
849
850 /// A command line that gets past the parser (an archive operand,
851 /// funzip without a terminal) does not panic and exits with one of the
852 /// flavour's codes. What it answers is the archive tasks' to pin.
853 #[test]
854 fn past_the_parser_a_run_never_panics() {
855 for f in [Flavour::Apple, Flavour::Ubuntu] {
856 let codes = match f {
857 Flavour::Apple => EXIT_CODES_APPLE,
858 Flavour::Ubuntu => EXIT_CODES_UBUNTU,
859 };
860 for (name, args) in [
861 ("funzip", &[][..]),
862 ("unzip", &["-l", "a.zip", "x", "-x", "y"]),
863 ("zipinfo", &["a.zip"]),
864 ] {
865 let (code, _, _) = run(f, name, args);
866 assert!(codes.contains(&code), "{f:?} {name} {args:?}: exit {code}");
867 }
868 }
869 }
870
871 /// The exit-code tables differ by `PK_BOMB` only.
872 #[test]
873 fn ubuntu_adds_only_pk_bomb() {
874 let extra: Vec<i32> = EXIT_CODES_UBUNTU
875 .iter()
876 .copied()
877 .filter(|c| !EXIT_CODES_APPLE.contains(c))
878 .collect();
879 assert_eq!(extra, [12]);
880 assert!(
881 EXIT_CODES_APPLE
882 .iter()
883 .all(|c| EXIT_CODES_UBUNTU.contains(c))
884 );
885 }
886}