Skip to main content

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;
123pub(crate) mod 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    ///
447    /// A message exactly the slide's length has no room for C's NUL, so none
448    /// is written; unobservable, as nothing reads past the length.
449    pub(crate) fn put_slide_at(&mut self, at: usize, buf: &[u8], nul: bool) {
450        if self.slide.is_empty() {
451            self.pending_slide.push((at, buf.to_vec(), nul));
452            return;
453        }
454        let end = at + buf.len();
455        if end <= self.slide.len() {
456            self.slide[at..end].copy_from_slice(buf);
457            if nul && end < self.slide.len() {
458                self.slide[end] = 0;
459            }
460        }
461    }
462
463    /// The window is back from a decoder: what `Info()` and `fnfilter()`
464    /// wrote meanwhile goes to it, in order (P13).
465    pub(crate) fn restore_slide(&mut self) {
466        for (at, b, nul) in std::mem::take(&mut self.pending_slide) {
467            self.put_slide_at(at, &b, nul);
468        }
469    }
470
471    // port: fileio.c:UzpMessagePrnt
472    /// `(*G.message)(&G, buf, size, flag)`: print `buf` to standard error
473    /// when `flag` has [`MSG_STDERR`] and `-t` is not set, else to standard
474    /// output, with the leading and trailing newlines `flag` asks for, and
475    /// flush. Write errors are ignored, as every `Info()` caller ignores
476    /// them. Under `-M` every line shown is counted (a line reaching the
477    /// screen's width counts too, `SCREENLWRAP`; no tab expansion, no
478    /// `TABSIZE` in either build) and a full screen pauses
479    /// ([`fileio::more_pause`], `--More--(N)`).
480    pub(crate) fn print_raw(&mut self, flag: u32, buf: &[u8]) {
481        let _ = self.message(flag, buf, false);
482    }
483
484    /// `(*G.message)(&G, rawbuf, size, 0)` for `-c` and `-p`'s data:
485    /// standard output, flushed (and paged under `-M`); a failed write is
486    /// returned (flush() then gives up on the entry).
487    pub(crate) fn print_data(&mut self, buf: &[u8]) -> io::Result<()> {
488        self.message(0, buf, true)
489    }
490
491    // port: fileio.c:UzpMessagePrnt
492    /// [`Uz::print_raw`] and [`Uz::print_data`]: `strict` returns the first
493    /// failed write, as `WriteTxtErr()`'s error is returned; a message's is
494    /// ignored, as every `Info()` caller ignores it. After the run's
495    /// `EXIT` (`abort`) nothing more is printed: the C has exited.
496    fn message(&mut self, flag: u32, buf: &[u8], strict: bool) -> io::Result<()> {
497        // ruling: T11-7. Nothing is printed after the run's `EXIT`.
498        if self.abort.is_some() {
499            return Ok(());
500        }
501        let to_stderr = flag & MSG_STDERR != 0 && !self.o.tflag;
502        let owned: Vec<u8>;
503        let buf = if flag & MSG_TNEWLN != 0
504            && ((buf.is_empty() && !self.sol) || (!buf.is_empty() && buf.last() != Some(&b'\n')))
505        {
506            owned = [buf, b"\n"].concat();
507            &owned[..]
508        } else {
509            buf
510        };
511        if self.o.m_flag {
512            // room for --More-- and one line of overlap:
513            let (height, width) = ttyio::screensize(&self.host);
514            self.height = height - 2;
515            self.width = width;
516        }
517        // error output from testing redirected:  also send to stderr
518        let also_stderr = flag & MSG_STDERR != 0
519            && self.o.tflag
520            && !self.host.stdout_is_tty()
521            && self.host.stderr_is_tty();
522        if flag & MSG_LNEWLN != 0 && !self.sol {
523            // not at start of line:  want newline
524            self.emit(to_stderr, b"\n", strict)?;
525            if self.o.m_flag {
526                self.chars = 0;
527                self.numlines += 1;
528                self.lines += 1;
529                if self.lines >= self.height {
530                    self.more();
531                    if self.abort.is_some() {
532                        // ruling: T11-7 (`q`: the C has exited)
533                        return Ok(());
534                    }
535                }
536            }
537            if also_stderr {
538                let _ = self.err.write_all(b"\n");
539                let _ = self.err.flush();
540            }
541            self.sol = true;
542        }
543
544        // put zipfile name, filename and/or error/warning keywords here
545        let mut q = 0;
546        if self.o.m_flag {
547            for p in 0..buf.len() {
548                let mut islinefeed = false;
549                if buf[p] == b'\n' {
550                    islinefeed = true;
551                } else if buf[p] == b'\r' {
552                    self.chars = 0;
553                } else {
554                    self.chars += 1;
555                    if self.chars >= self.width {
556                        islinefeed = true;
557                    }
558                }
559                if islinefeed && self.o.m_flag {
560                    // (`-M` off again mid-message: ruling T11-4's end of
561                    // input)
562                    self.chars = 0;
563                    self.numlines += 1;
564                    self.lines += 1;
565                    if self.lines >= self.height {
566                        self.emit(to_stderr, &buf[q..=p], strict)?;
567                        self.sol = true;
568                        q = p + 1;
569                        self.more();
570                        if self.abort.is_some() {
571                            // ruling: T11-7
572                            return Ok(());
573                        }
574                    }
575                }
576            }
577        }
578        let rest = &buf[q..];
579        if !rest.is_empty() {
580            self.emit(to_stderr, rest, strict)?;
581            if also_stderr {
582                let _ = self.err.write_all(rest);
583                let _ = self.err.flush();
584            }
585            self.sol = buf.last() == Some(&b'\n');
586        }
587        Ok(())
588    }
589
590    /// `(*G.mpause)(&G, MorePrompt, 1)`.
591    fn more(&mut self) {
592        let mut prompt = Vec::new();
593        printf(
594            &mut prompt,
595            m::MORE_PROMPT,
596            &[self.numlines.to_string().as_bytes()],
597        );
598        fileio::more_pause(self, &prompt, 1);
599    }
600
601    /// `WriteTxtErr(buf, outfp)` + `fflush(outfp)`: the error returned when
602    /// `strict`, else ignored.
603    fn emit(&mut self, to_stderr: bool, buf: &[u8], strict: bool) -> io::Result<()> {
604        if !strict {
605            self.write(to_stderr, buf);
606            return Ok(());
607        }
608        let w: &mut dyn Write = if to_stderr {
609            &mut *self.err
610        } else {
611            &mut self.out
612        };
613        w.write_all(buf)?;
614        w.flush()
615    }
616
617    /// [`Uz::info`] of `fmt` with its `%s`/`%d` filled from `args`.
618    pub(crate) fn info_fmt(&mut self, flag: u32, fmt: &str, args: &[&[u8]]) {
619        let mut buf = Vec::new();
620        printf(&mut buf, fmt, args);
621        self.info(flag, &buf);
622    }
623
624    /// One message to one stream, flushed (`WriteTxtErr` + `fflush`).
625    fn write(&mut self, to_stderr: bool, buf: &[u8]) {
626        if to_stderr {
627            let _ = self.err.write_all(buf);
628            let _ = self.err.flush();
629        } else {
630            let _ = self.out.write_all(buf);
631            let _ = self.out.flush();
632        }
633    }
634}
635
636/// Entry point for `unzip`, `zipinfo` and `funzip`; `name` is the
637/// canonical invoked name, `argv0` the program as invoked (unzip.c reads
638/// its last component for ZipInfo mode) and `args` the arguments after it.
639pub fn run(name: &'static str, argv0: &OsStr, args: Vec<OsString>) -> ExitCode {
640    // Deviation 1: as the other families.
641    host::reset_sigpipe();
642    let env = |k: &str| std::env::var_os(k);
643    let mut err = io::stderr();
644    let flavour = match flavour_from(env(FLAVOR_VAR)) {
645        Ok(f) => f,
646        Err(v) => {
647            printf(
648                &mut err,
649                m::FLAVOR_REFUSED,
650                &[name.as_bytes(), v.as_encoded_bytes()],
651            );
652            return ExitCode::from(1);
653        }
654    };
655    let argv0 = if argv0.is_empty() {
656        name.as_bytes()
657    } else {
658        argv0.as_encoded_bytes()
659    };
660    let code = main(flavour, name, argv0, &args, &env, Host::Real, &mut err);
661    // The process's exit status is the code's low byte, as `exit()` makes
662    // it (every code here is below 256).
663    ExitCode::from(code as u8)
664}
665
666/// [`run`] without the process, as `flavour` (R7): operands resolve against
667/// `dir`, standard input is `stdin` (a pipe of bytes), standard output and
668/// error are captured and returned with the exit code and what each input
669/// read, neither is a terminal, `env` stands in for the environment
670/// (`UNZIP`, `UNZIPOPT`, `ZIPINFO`, `ZIPINFOOPT`; `STUFFR_UNZIP_FLAVOR` is
671/// not consulted), no signal is touched, and standard output and output
672/// files share the [`SANDBOX_OUTPUT_CAP`] budget. For the fuzz target; not
673/// a stable interface.
674#[doc(hidden)]
675pub fn run_in_counted(
676    flavour: Flavour,
677    dir: &Path,
678    name: &'static str,
679    args: &[OsString],
680    stdin: &[u8],
681    env: &dyn Fn(&str) -> Option<OsString>,
682) -> CountedRun {
683    let stdout = Arc::new(Mutex::new(Vec::new()));
684    let reads = Arc::new(Mutex::new(BTreeMap::new()));
685    let host = Host::Sandbox(Sandbox {
686        dir: dir.to_path_buf(),
687        stdin: Some(stdin.to_vec()),
688        stdout: stdout.clone(),
689        budget: Arc::new(AtomicU64::new(SANDBOX_OUTPUT_CAP)),
690        reads: reads.clone(),
691    });
692    let mut err = Vec::new();
693    let code = main(flavour, name, name.as_bytes(), args, env, host, &mut err);
694    let out = std::mem::take(&mut *stdout.lock().unwrap_or_else(|p| p.into_inner()));
695    let reads = std::mem::take(&mut *reads.lock().unwrap_or_else(|p| p.into_inner()));
696    (code, out, err, reads)
697}
698
699/// The run below the process boundary. Returns the exit code.
700fn main(
701    flavour: Flavour,
702    name: &str,
703    argv0: &[u8],
704    args: &[OsString],
705    env: &dyn Fn(&str) -> Option<OsString>,
706    host: Host,
707    err: &mut dyn Write,
708) -> i32 {
709    if name == "funzip" {
710        let mut uz = Uz::new(host, flavour, env, err);
711        return funzip::funzip(&mut uz, args);
712    }
713    // port: unzip.c:unzip (`SET_SIGHANDLER(SIGINT, handler)` and its
714    // siblings): for the whole run, an interrupt puts the terminal's echo
715    // back and exits with IZ_CTRLC, leaving a partial output in place.
716    // probe: `kill -INT`/`-TERM`/`-ABRT` during `unzip big.zip` (Apple's and
717    // Ubuntu's): exit 80, nothing printed, `big.bin` kept, also under an
718    // inherited `SIG_IGN`; SIGHUP and SIGQUIT kill it.
719    let _interrupt =
720        host::arm_interrupt_exit(&host, host::UNZIP_SIGNALS, IZ_CTRLC, m::HANDLER_TEXT);
721    let mut uz = Uz::new(host, flavour, env, err);
722    uz.native_is_utf8 = fileio::init_locale(&uz.host);
723    // ubuntu: 20-unzip60-alt-iconv-utf8.patch (unzip.c: `#ifdef UNIX
724    // init_conversion_charsets()`, before the options are read)
725    if flavour::iconv_charsets(flavour) {
726        unix::charset::init_conversion_charsets(&mut uz);
727    }
728    let code = unzip(&mut uz, argv0, args);
729    let _ = uz.out.flush();
730    code
731}
732
733/// Whether `s` begins with `prefix`, ignoring ASCII case (`STRNICMP(s,
734/// prefix, n) == 0` with `n` the prefix's length).
735fn starts_with_ignore_case(s: &[u8], prefix: &[u8]) -> bool {
736    s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix)
737}
738
739// port: unzip.c:unzip
740/// unzip.c's `unzip()`: ZipInfo mode or UnZip mode, the command line, then
741/// the archives.
742fn unzip(uz: &mut Uz, argv0: &[u8], args: &[OsString]) -> i32 {
743    // The program name's last component (after `DIR_END`).
744    let start = argv0
745        .iter()
746        .rposition(|&b| b == b'/' || (cfg!(windows) && b == b'\\'))
747        .map_or(0, |i| i + 1);
748    let p = &argv0[start..];
749    let zipinfo = starts_with_ignore_case(p, b"zipinfo")
750        || starts_with_ignore_case(p, b"ii")
751        || args
752            .first()
753            .is_some_and(|a| a.as_encoded_bytes().starts_with(b"-Z"));
754    let parsed = if zipinfo {
755        cli::parse_zipinfo(uz, args)
756    } else {
757        cli::parse_unzip(uz, args)
758    };
759    match parsed {
760        Ok(p) => process_zipfiles(uz, &p),
761        Err(code) => code,
762    }
763}
764
765/// `process.c`'s `process_zipfiles()`.
766fn process_zipfiles(uz: &mut Uz, parsed: &cli::Parsed) -> i32 {
767    process::process_zipfiles(uz, parsed)
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    fn run(f: Flavour, name: &'static str, args: &[&str]) -> (i32, String, String) {
775        let args: Vec<OsString> = args.iter().map(OsString::from).collect();
776        let dir = std::env::temp_dir();
777        let (code, out, err, _) = run_in_counted(f, &dir, name, &args, b"", &|_| None);
778        (
779            code,
780            String::from_utf8(out).unwrap(),
781            String::from_utf8(err).unwrap(),
782        )
783    }
784
785    #[test]
786    fn the_default_flavour_follows_the_platform() {
787        let apple = cfg!(any(
788            target_os = "macos",
789            target_os = "freebsd",
790            target_os = "netbsd",
791            target_os = "openbsd",
792            target_os = "dragonfly"
793        ));
794        let want = if apple {
795            Flavour::Apple
796        } else {
797            Flavour::Ubuntu
798        };
799        assert_eq!(default_flavour(), want);
800    }
801
802    /// Each flavour's banners say whose build they alter (R2), with this
803    /// stuffr's version.
804    #[test]
805    fn banners_name_stuffr_and_the_build() {
806        let v = env!("CARGO_PKG_VERSION");
807        let (code, out, err) = run(Flavour::Apple, "unzip", &[]);
808        assert_eq!((code, err.as_str()), (0, ""));
809        let want = format!(
810            "UnZip 6.00 of 20 April 2009, by stuffr {v} (altered; original by Info-ZIP, \
811             with modifications by Apple Inc.).\nPlease report bugs to Apple.\n\nUsage: "
812        );
813        assert!(out.starts_with(&want), "{out}");
814        let (_, out, _) = run(Flavour::Ubuntu, "unzip", &["-h"]);
815        let want = format!(
816            "UnZip 6.00 of 20 April 2009, by stuffr {v} (altered; Debian's build, original \
817             by Info-ZIP).\n\nUsage: "
818        );
819        assert!(out.starts_with(&want), "{out}");
820        let (code, out, _) = run(Flavour::Apple, "zipinfo", &[]);
821        assert_eq!(code, 0);
822        assert!(
823            out.starts_with(&format!(
824                "ZipInfo 3.00 of 20 April 2009, by stuffr {v} (altered; original by Greg \
825                 Roelofs and the Info-ZIP group, with modifications by Apple Inc.).\n\nList"
826            )),
827            "{out}"
828        );
829        let (_, out, _) = run(Flavour::Ubuntu, "unzip", &["-Z"]);
830        assert!(
831            out.starts_with(&format!(
832                "ZipInfo 3.00 of 20 April 2009, by stuffr {v} (altered; Debian's build, \
833                 original by Greg Roelofs and the Info-ZIP group).\n"
834            )),
835            "{out}"
836        );
837        // deviation 2: the Apple line is the recorded build's, pinned exactly
838        let (_, out, _) = run(Flavour::Apple, "unzip", &["-v"]);
839        assert!(
840            out.contains(
841                "\nCompiled with gcc Apple LLVM 21.0.0 (clang-2100.3.31.1) [+internal-os] \
842                 for Unix Mac OS X on Aug  8 2026.\n\n"
843            ),
844            "{out}"
845        );
846        let (_, out, _) = run(Flavour::Ubuntu, "unzip", &["-v"]);
847        assert!(
848            out.contains("\nCompiled with gcc 13.2.0 for Unix (Linux ELF).\n\n"),
849            "{out}"
850        );
851    }
852
853    /// A command line that gets past the parser (an archive operand,
854    /// funzip without a terminal) does not panic and exits with one of the
855    /// flavour's codes. What it answers is the archive tasks' to pin.
856    #[test]
857    fn past_the_parser_a_run_never_panics() {
858        for f in [Flavour::Apple, Flavour::Ubuntu] {
859            let codes = match f {
860                Flavour::Apple => EXIT_CODES_APPLE,
861                Flavour::Ubuntu => EXIT_CODES_UBUNTU,
862            };
863            for (name, args) in [
864                ("funzip", &[][..]),
865                ("unzip", &["-l", "a.zip", "x", "-x", "y"]),
866                ("zipinfo", &["a.zip"]),
867            ] {
868                let (code, _, _) = run(f, name, args);
869                assert!(codes.contains(&code), "{f:?} {name} {args:?}: exit {code}");
870            }
871        }
872    }
873
874    /// The exit-code tables differ by `PK_BOMB` only.
875    #[test]
876    fn ubuntu_adds_only_pk_bomb() {
877        let extra: Vec<i32> = EXIT_CODES_UBUNTU
878            .iter()
879            .copied()
880            .filter(|c| !EXIT_CODES_APPLE.contains(c))
881            .collect();
882        assert_eq!(extra, [12]);
883        assert!(
884            EXIT_CODES_APPLE
885                .iter()
886                .all(|c| EXIT_CODES_UBUNTU.contains(c))
887        );
888    }
889}