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}