Skip to main content

stuffr_cli/compat/zip/
mod.rs

1//! The zip family (`zip`, `zipcloak`, `zipnote`, `zipsplit`): stuffr
2//! behaving as the platform's Info-ZIP Zip 3.0.
3//!
4//! **A port** of Info-ZIP Zip 3.0 (July 5th 2008; the Info-ZIP licence,
5//! `LICENSE-infozip`: this is an altered version, marked as such by every
6//! banner) in two builds, Apple's (`zip-33`, `zip30/`, default on macOS and
7//! the BSDs) and Ubuntu's (`3.0-13ubuntu0.2`, default elsewhere), chosen by
8//! [`default_flavour`] or `STUFFR_ZIP_FLAVOR=apple|ubuntu` ([`run`] reads
9//! it once; anything else in it is refused, exit 1, in stuffr's own
10//! words). Each ported function carries `// port: <file>.c:<function>`;
11//! Ubuntu's differences are checks in `flavour.rs`, one function per
12//! behaviour. Every string is in `messages.rs`.
13//!
14//! **What is here (Task 4 of 0.19.0):** the names, the flavour, the
15//! command lines of all four programs (`cli.rs`: `zip.c`'s option table and
16//! `main()`'s loop and checks, `fileio.c`'s `get_option()`, `util.c`'s
17//! `envargs()` for `ZIPOPT` and `ZIP`, and the three helpers' own
18//! parsing), and every help, licence, version and option-listing screen.
19//!
20//! **Task 5 (`scan.rs`, `unix.rs`):** which names `zip` operates on: the
21//! operands and `-R`, `-i`/`-x`/`-R` patterns, `-r`, `-j`, `-k`, `-D`, `-y`,
22//! `-t`/`-tt`, `-@`, duplicates and the archive itself, and `-sf`'s listing
23//! (`fileio.c`, `util.c`'s matcher, `unix/unix.c`); also the unix extra
24//! fields (`unix::extra_fields`).
25//!
26//! **Task 6 (`zipup.rs`, `zipfile.rs`, `write.rs`, `sink.rs`):** writing a
27//! new archive, byte for byte as the references write it: `zipup.c` (the
28//! method, stored or deflated through `stuffr::infozip::ZipDeflater`,
29//! `-l`/`-ll`, the
30//! descriptor or the rewritten local header), `zipfile.c`'s writing half
31//! (local and central headers, Zip64 fields and end records, the end
32//! record) and `zip.c:main` from the temporary archive to `finish()`
33//! (progress counters and dots, unreadable inputs, `-c`, `-z`, `-m`,
34//! `-o`).
35//!
36//! **Task 7 (`readzip.rs`, `update.rs`):** an archive already at the path:
37//! `zipfile.c`'s reading half (`readzipfile`, `scanzipf_regnew`,
38//! `readlocal`, `zipcopy`, `bfcopy`), names matched against its entries
39//! (`zsearch`, patterns for names not on disk), `-u`, `-f`, `-d`, `-FS`,
40//! `-g`, `-U`, `--out`, `-A`, `-J`, `-c`/`-z` over old entries, `-T`/`-TT`
41//! (`check_zipfile`), and zip's interrupt handler (INT, TERM, ABRT, BUS,
42//! ILL, SEGV: the temporary removed, `Interrupted (aborting)`, exit 9;
43//! `host::install_zip_abort`; since ruling T7a of the 2026-10-31 security
44//! audit, a signal stuffr sent itself, such as its own `abort()`, and on
45//! Linux a real memory fault, removes the temporary and is re-raised, so
46//! stuffr's own crash is not reported as an interrupt: deviation 34).
47//!
48//! **Task 8 (`split.rs`, `fix.rs`, `readzip.rs`):** split archives read
49//! (every part through `Host::track`, zip's questions when a part cannot be
50//! opened) and written (`-s`, `-sp`, `-sb`, `-sv`: `bfwrite`'s other half,
51//! the local header's split kept open for its rewrite, a split archive
52//! read and copied split as its first part is); `-F` (`scanzipf_regnew`'s
53//! fix mode: the offset of bytes before the archive, damaged central
54//! records skipped, a bad entry written over) and `-FF` (`scanzipf_fixnew`:
55//! the archive read from its first byte, every local header copied as it
56//! is met, a central header found later filling in what only it has).
57//!
58//! **Task 9 (`ttyio.rs`, `zipcloak.rs`, `zipup.rs`):** encryption (`-P`,
59//! `-e`): `crypthead`'s header after each local header (its check the
60//! time's, the entry always with a data descriptor), the data encrypted as
61//! `zfwrite` encrypts it; the password prompts on the controlling terminal
62//! (`getp`, unzip's port, the terminal put back by the interrupt handler
63//! too); and `zipcloak` (encrypt, `-d`, `-O`, `-b`), whose messages,
64//! warnings and errors are the helpers' (`zipcloak warning:`, `zipcloak
65//! error:`, `perror("zipcloak error")`).
66//!
67//! **Task 10 (`zipnote.rs`, `zipsplit.rs`):** `zipnote` (the comments listed,
68//! `-w` rewriting the archive with new comments and names) and `zipsplit`
69//! (`-n`, `-r`, `-t`, `-i`, `-p`, `-s`, `-b`: the sequential and the greedy
70//! split, the parts and the index written directly under their names), over
71//! the shared reader and writer.
72//!
73//! **Task 11 (`unicode.rs`, `zipup.rs`, `flavour.rs`):** Ubuntu's deltas:
74//! `UNICODE_SUPPORT` (bit 11 and the Unicode Path field written and read,
75//! `-UN`, `-su`/`-sU`, matching by the Unicode name, the helpers and `-FF`
76//! over such entries), `BZIP2_SUPPORT` (`-Z bzip2` through stuffr's bzip2
77//! encoder at zip's block size), and the thirteen-patch series (listed in
78//! `flavour.rs` with each hook or why it has none).
79//!
80//! **Zr is `G`:** zip's globals are one value per run (`Zr`, with its
81//! options `o`), so a sandboxed run ([`run_in_counted`]) touches nothing of
82//! the process.
83//!
84//! Known deviations (the source of truth; the README and the spec mirror
85//! this list):
86//!
87//! 1. SIGPIPE: [`run`] restores SIGPIPE's default action, as the other
88//!    families do; under a parent that ignores SIGPIPE, zip sees `EPIPE`
89//!    and stuffr still dies of the signal (the Rust runtime ignores it
90//!    before `main`).
91//! 2. The banner line of `-v` and the header line of each program's help
92//!    name stuffr and say it is altered (Info-ZIP licence clause 3):
93//!    `This is Zip 3.0 (July 5th 2008), by stuffr X.Y.Z (altered; original
94//!    by Info-ZIP, with modifications by Apple Inc.).` (Ubuntu: `(altered;
95//!    Debian's build, original by Info-ZIP)`), likewise `Zip 3.0 (July 5th
96//!    2008), by stuffr ... Usage:` and the helpers' `ZipCloak 3.0 (July
97//!    5th 2008), by stuffr ...`. `-v`'s `Compiled with` line is the
98//!    recorded reference build's (Apple: macOS 27's), so another macOS
99//!    build prints a different one.
100//! 3. Undefined behaviour and endless loops are not reproduced (the
101//!    record's rulings, in the code's `// ruling:` comments); 5 and 6 are
102//!    the ones the command line meets.
103//! 4. Encryption is compatible, not identical: the ten random bytes of each
104//!    12-byte encryption header come from the system's generator
105//!    (`getentropy`, `getrandom` or `/dev/urandom`; a system that gives
106//!    none ends the run, `Internal logic error`, exit 5), where zip's come
107//!    from `rand()` seeded with the time,
108//!    so an encrypted entry's header and data differ from the reference's
109//!    run to run (every other byte of the archive is the same, and it
110//!    decrypts the same).
111//! 5. `get_option`'s error text is built in a 50-byte buffer
112//!    (`optionerr`'s `char optname[50]`, `sprintf`). An error about an
113//!    option whose `'name' (description)` needs 50 bytes or more (`-lf`,
114//!    `--output-file`, `--junk-paths=x`, `--recurse-patterns-`: 59 forms in
115//!    all, listed by `cli/tests.rs`) aborts both references (Apple: SIGTRAP
116//!    from `__sprintf_chk`, Ubuntu: glibc's "buffer overflow detected" and
117//!    zip's SIGABRT handler, exit 9). stuffr prints the whole message,
118//!    exit 16, as the C would with room.
119//! 6. `-O` (`--output-file`) with no archive name at all: the C compares a
120//!    NULL `in_path` (`namecmp(in_path, out_path)`) and dies of SIGSEGV,
121//!    which zip's handler answers in both builds with a blank line and
122//!    `zip error: Interrupted (aborting)`, exit 9. stuffr gives that answer
123//!    without the dereference.
124//! 7. The "Scanning files" progress message `newname` prints when a scan
125//!    of 100 or more names takes over five seconds, with a dot every two
126//!    seconds after, is not made (it depends on the clock, not on the
127//!    names).
128//! 8. A name that is `//host` or `//host/share` and nothing more makes
129//!    `ex2in` dereference NULL in both references: SIGSEGV, which zip's
130//!    handler answers with a blank line and `Interrupted (aborting)`, exit
131//!    9. stuffr gives that answer without the dereference (as for 6), also
132//!    for an `-i`/`-x`/`-R` pattern of that shape. The order of two equal
133//!    names in the found list follows each libc's `qsort` (Ubuntu's stable
134//!    merge sort; Apple's, on macOS, the platform's own `qsort_r`, and off
135//!    macOS a BSD quicksort ported in `scan.rs`). Off macOS the port orders
136//!    6 of 4000 random arrays with many equal keys differently from macOS
137//!    (a known gap: which of two equal names is dropped, when the same
138//!    operand is given twice among many).
139//! 9. `-c` or `-z` after `-@` (or with zip as a filter) read standard error
140//!    (`fdopen(fileno(stderr), "r")`). When it is not open for reading (a
141//!    pipe, a file opened to write) `fdopen` fails and the C's `fgets(NULL)`
142//!    dies of SIGSEGV in both references, which zip's handler answers with
143//!    a blank line and `Interrupted (aborting)`, exit 9, the temporary
144//!    removed. stuffr gives that answer without the dereference (as 6 and
145//!    8), at the first question, and a sandbox's standard error is never
146//!    readable.
147//! 10. A `-c` or `-z` line is cut at its first NUL, as the C's `strlen` and
148//!     `strcpy` cut it; a line that BEGINS with a NUL makes the C read the
149//!     byte before its buffer (`p[k-1]`, `e[r-1]` with a length of 0).
150//!     stuffr takes it for an empty line.
151//! 11. A symbolic link stored under `-y` that is removed between zip's
152//!     `stat` and its `readlink` makes the C pass `readlink`'s -1 on as a
153//!     length to `crc32` and `zfwrite`. stuffr stores the link with no
154//!     text (0 bytes, CRC 0), exit 0.
155//! 12. A write to the archive that fails is answered where and as zip's
156//!     stdio stream answers it (`zipup.rs`, `sink.rs`; measured under
157//!     `ulimit -f` on both references). Two Ubuntu answers are not
158//!     reproduced: when only `fclose` meets the failure, Ubuntu's `ziperr`
159//!     closes the stream a second time and glibc aborts on the double free
160//!     (exit 5, the temporary left; undefined behaviour, as ruling Z3's);
161//!     stuffr answers as Apple does ("Temporary file failure", exit 10, the
162//!     temporary removed). And the `zip I/O error:` line of "unexpected
163//!     error on zip file" shows whatever `errno` glibc's later calls left
164//!     (`No such file or directory` from the time zone lookup in a
165//!     container without tzdata); stuffr shows the write's own error, as
166//!     Apple does.
167//! 13. Reading an archive, undefined behaviour of the C is not reproduced:
168//!     an end record or Zip64 record cut short is read as zeros (the C reads
169//!     its uninitialised stack buffer: Apple, for an archive that is only
170//!     `PK\5\6`, answers "cannot update a split archive"; stuffr sees an
171//!     empty archive); a Zip64 extra field shorter than the sizes it should
172//!     hold is read as zeros past its end; `-X-` stops copying old extra
173//!     blocks at one that runs past its field. And `-g` (grow) writes a copy
174//!     of the archive up to its central directory, appends, adds whatever
175//!     the old file had past the new end (zip writes in place and never
176//!     truncates, so a shorter `-z` comment leaves the old tail), and
177//!     renames the copy into place: the bytes of a successful run are
178//!     zip's. Except under `--out` (ruling Z33): zip opens the `--out` file
179//!     with `FOPW` (truncating it to nothing), grows the input archive in
180//!     place and skips `replace`, so `zip -g in.zip --out out.zip f` leaves
181//!     `out.zip` empty and `in.zip` grown; stuffr leaves `in.zip` untouched
182//!     and publishes the grown copy as `out.zip` (pinned by
183//!     `update::tests::growing_with_out_publishes_the_copy`). A failure
184//!     leaves the archive as it was: zip prints
185//!     `attempting to restore <zip> to its previous state` (so does
186//!     stuffr, on every failure and interrupt under `-g`) and rewrites the
187//!     old central directory in place, leaving what it appended after it
188//!     (a write failure there can even leave no readable archive). A
189//!     failed `-T -g` is undefined behaviour in the C (`fseeko` on the
190//!     closed stream: Apple exits 5 after a blank line); stuffr answers
191//!     with the restore line, exit 8. `-T -g` tests the copy, so a `-TT`
192//!     command is given the temporary's name, not the archive's.
193//! 14. zip opens the output to check that it can write it with `fopen
194//!     "wb"` (truncating it) when it is `--out`'s or the archive has no
195//!     entries, and then removes an archive without entries; stuffr opens
196//!     it without truncating and removes nothing it did not create. So a
197//!     run that fails or is interrupted leaves an `--out` target or an
198//!     entry-less archive as it was (zip leaves the first empty and the
199//!     second gone). And on success, an entry-less archive that is a
200//!     symbolic or hard link: zip, having removed the link, renames a new
201//!     file into its place (the link's target left empty); stuffr writes
202//!     through the link, as `replace` does for any linked archive. Pinned by
203//!     `update::tests::a_failed_run_leaves_the_output_as_it_was`.
204//! 15. Ruling Z3: a failing `-T` in Ubuntu's build closes the temporary's
205//!     stream twice (`ziperr` after `fclose(y)`) and glibc aborts on the
206//!     double free (exit 5, a blank line, glibc's message); stuffr answers
207//!     as Apple's build does in both flavours: `test of t.zip FAILED`, `zip
208//!     error: Zip file invalid, could not spawn unzip, or wrong unzip
209//!     (original files unmodified)`, exit 8, the temporary removed.
210//! 16. A sandboxed run ([`run_in`]) spawns no `unzip` and no `-TT`
211//!     command: `-T` fails there as without an `unzip` on `PATH`. The
212//!     interrupt handler writes `Interrupted (aborting)` to `mesg` only; a
213//!     `-lf` log does not get it.
214//! 17. Ruling Z16: `-sf -m` (`--show-files` with move) lists and ends
215//!     without writing an archive, and zip's `finish()` then deletes the
216//!     files of the entries the names marked, though nothing holds them.
217//!     stuffr deletes nothing after `-sf`.
218//! 18. Ruling Z5: zip reads the answers to its questions about split parts
219//!     with `fgets` and never looks at what it returns. At the end of
220//!     standard input its buffer keeps what it held (uninitialised at the
221//!     first question), and `-FF` (both builds; also `-F` and a copy of a
222//!     split archive whose part is missing) asks again for ever, gigabytes
223//!     of prompts; so does Ubuntu's `-sp`. stuffr never loops: the end of
224//!     standard input at a question about a part to READ answers `q`
225//!     (abort: as an explicit `q`, measured on both references: `-FF` goes
226//!     on and writes what it has, `could not find split` and exit 0 when
227//!     the part was the one to scan, `Interrupted (Could not find split)`,
228//!     exit 9, when an entry was being copied), and at `-sp`'s question
229//!     about where to WRITE the next split it answers ENTER (that question
230//!     has no way out but an interrupt; ENTER is what Apple's build does).
231//! 19. `-FF`, undefined behaviour of the C, not reproduced: the central
232//!     extra field of an entry `-FF` found by its local header is the local
233//!     one copied with `strcpy`, which stops at the first NUL (almost always
234//!     the second byte of the first block's size) and leaves the rest of
235//!     the `malloc`ed field as it was: zeros on Apple, heap garbage that
236//!     changes from run to run on Ubuntu. stuffr copies up to and with the
237//!     first NUL and writes zeros after it (Apple's bytes). The entry's
238//!     `lflg`, never set, is read as 0 (it only sizes `tempzn`); a data
239//!     descriptor signature split across two 16 KiB reads (the C then
240//!     writes `kk - 3`, wrapped, bytes) is not taken for one; a Zip64 extra
241//!     field shorter than its sizes is read as zeros past its end.
242//! 20. Ruling Z21: `-F`/`-FF` whose `--out` is the damaged archive under
243//!     another name (`./a.zip`, a hard or symbolic link to it) is refused
244//!     as the same name is (`--out path must be different than in path`,
245//!     exit 16), before anything is written: zip compares the names only
246//!     and renames its lossy salvage over the archive it salvaged from.
247//! 21. Ruling Z23: when what did not fit in a split cannot be written to
248//!     the next one ("Not enough room on disk"), `-sp` (`split_method` 2)
249//!     lets the user choose another disk and opens another split, for ever
250//!     on a full disk; stuffr warns as `-sp` does and ends with `-s`'s
251//!     answer, `zip error: Output file write failure (Not enough room on
252//!     disk)`, exit 14.
253//! 22. Ruling Z24: `-sp` with an archive name that has no directory: zip
254//!     takes the name part as empty, so an answer (a directory, a bare
255//!     name) makes the next split's name from a name shorter than `zip`
256//!     and indexes before its buffer. stuffr takes such a name as one in
257//!     the current directory: `zip -s 64k -sp t.zip big` answered `o/`
258//!     writes `t.z01` here, then `o/t.z02`, ..., `o/t.zip`.
259//! 23. Ruling Z25: `-z` whose first comment line is empty copies `\r\n`
260//!     into a one-byte block (`strcpy(p, "\r\n")`): Ubuntu's build dies in
261//!     glibc's buffer-overflow check (exit 9 through zip's SIGABRT
262//!     handler); Apple's writes the comment. stuffr writes it in both
263//!     flavours, as Apple's build does.
264//! 24. `zipcloak` (crypt.c's `zipcloak()` and `zipbare()`) ignores
265//!     `readlocal()`'s failure (a local header that cannot be found or
266//!     read) and goes on with an uninitialised local header; stuffr ends the
267//!     run there with that failure's code (`zipcloak error: ... (was
268//!     encrypting an entry)` or `(was decrypting an entry)`), the temporary
269//!     removed and the archive untouched. A split archive under `-O` meets
270//!     it at its first entry: zipcloak's `readlocal()` (the UTIL build's)
271//!     looks for every entry in the last part only, warns `Did not find
272//!     entry for <name>`, and Ubuntu's build then dies of SIGSEGV
273//!     (`Interrupted (aborting)`, exit 9, or killed by the signal from its
274//!     handler; measured with a terminal in Docker); stuffr warns the same
275//!     and ends with `Zip file structure invalid`, exit 3. (Without `-O` both
276//!     refuse a split archive: `cannot update a split archive`, exit 16.)
277//!     A central name longer than the local one makes `readlocal()` write
278//!     past the local name's buffer;
279//!     stuffr keeps the local name as it is (a shorter central name cuts it
280//!     with a NUL, as the C does).
281//! 25. `zipnote` and `zipsplit` never set `out_path`, so `readzipfile()`'s
282//!     `strcmp(in_path, out_path)` over a NULL ends in SIGSEGV for an
283//!     archive that does not exist (or is named ``) and for a split archive
284//!     (a `.zip` that says it is one of several): both references' handler
285//!     prints `Interrupted (aborting)` and exits 9. stuffr answers the same
286//!     (`readzip::null_out_crash`); the signal is not raised.
287//! 26. Ruling Z4: Apple's `zipnote -w` closes the input stream twice (the
288//!     second `fclose` closes the temporary's) and always fails, exit 10,
289//!     `Bad file descriptor` then `Temporary file failure`, with the
290//!     archive untouched. That is undefined behaviour; stuffr takes Ubuntu's
291//!     patched answer (patch 07) in both flavours: `-w` works. Pinned by
292//!     `compat_zip_helpers::zipnote_w_is_the_same_in_both_flavours`
293//!     (stuffr's two flavours agree and exit 0) and, on Ubuntu, against the
294//!     reference. A comment's length wraps at 65536 as the C's `ush` does.
295//! 27. `zipsplit`: a `%` in the archive's name is a conversion in the C's
296//!     name template (`sprintf(name, template, n)`, undefined behaviour);
297//!     stuffr takes it as a character. After an error writing the index
298//!     under `-p` and a yes to "redo entire disk?", the C goes back to the
299//!     wrong disk (its
300//!     loop variable holds the failure flag); stuffr redoes disk 1. An
301//!     interrupt removes the parts only when `-b`'s directory is shorter
302//!     than half of `PATH_MAX` (the handler's static holds that much;
303//!     longer arms nothing and the parts stay).
304//! 28. Ubuntu's `UNICODE_SUPPORT` (`unicode.rs`), undefined behaviour of the
305//!     C not reproduced. Where a conversion fails and the C reads through
306//!     the NULL it got (a bit-11 name zip's UTF-8 decoder refuses; a
307//!     Unicode Path field whose UTF-8 name it refuses; `-UN escape` over a
308//!     found name the locale cannot convert) or copies the entry's
309//!     not-yet-set name (a Unicode Path field with no name, read from the
310//!     central directory or by `readlocal`), the reference dies of SIGSEGV
311//!     and its handler answers `Interrupted (aborting)`, exit 9; stuffr
312//!     gives that answer without the crash (`unicode::segv`), as rulings
313//!     Z10/Z13 do. A Unicode Path field shorter than its header says is
314//!     read as zeros past its end (the C reads past it); an old field of
315//!     another length than the new one is taken out and the new one
316//!     appended (the C's copy of what follows it runs four bytes long).
317//!     Patch 12 (`mbstowcs` told the buffer holds `strlen + 1` wide
318//!     characters, which glibc's fortify takes for an overflow and aborts
319//!     on, for every name that is not ASCII) and patch 13 (`-TT`'s command
320//!     buffer one byte short) are Ubuntu's own fixes: both flavours take the
321//!     patched, defined behaviour (Apple has neither code path). Pinned,
322//!     stuffr-only, by `compat_zip_common::ub_rulings`; the crash answers
323//!     are also compared with the reference (`compat_zip_ubuntu`).
324//! 29. `-Z bzip2` (Ubuntu): zipup.c tests whether an entry is text over the
325//!     whole 16 KiB input buffer, past the bytes read, and the buffer is
326//!     allocated once per run and never cleared, so the test sees what an
327//!     earlier entry left there (reproduced: a short text file after a
328//!     longer text file is text). The block's first contents are
329//!     uninitialised in the C; stuffr takes them as zeros (a short first
330//!     text file is binary, `iattr` 0, as measured). Pinned by
331//!     `compat_zip_common::ub_rulings`.
332//! 30. Ruling Z32: an interrupt (SIGINT, SIGTERM) while the new archive
333//!     replaces the old one (`fileio.c:replace`: the archive unlinked, then
334//!     the temporary renamed; or, for a linked archive or a temporary on
335//!     another device such as `-b`'s, the archive truncated and the
336//!     temporary copied into it) makes zip's handler remove the temporary,
337//!     which leaves the archive gone or cut short and no whole copy
338//!     anywhere. stuffr holds both signals back for the length of the
339//!     replace (`zip`, its split parts and `-F`/`-FF`, `zipcloak`, `zipnote
340//!     -w`; `-g`'s restore state is cleared in the same window) and answers
341//!     them as zip does once the archive is whole: `Interrupted
342//!     (aborting)`, exit 9, the new archive in place. A long cross-device
343//!     copy answers Ctrl-C when it ends. When the replace fails after the
344//!     archive was unlinked or cut (ruling Z34), the temporary is the only
345//!     whole copy: it is kept under its own name, where zip's handler would
346//!     remove it. Pinned by
347//!     `tests::an_interrupt_during_replace_leaves_a_whole_archive` and
348//!     `tests::an_interrupt_after_a_failed_replace_keeps_the_temporary`.
349//! 31. Hardening (ruling T5 of the 2026-10-31 security audit): zip never
350//!     writes through a symbolic link at an output name that is not the
351//!     archive it read. Info-ZIP follows any link there (`fopen`, then
352//!     `fileio.c:replace`'s copy for a symlinked archive): a dangling one
353//!     creates the file it names, a live one at `-O`/`--out` truncates its
354//!     target. stuffr refuses, before any work, a link at `-O`/`--out`
355//!     (also `-F`/`-FF`'s output and `zipcloak -O`) whatever it points at,
356//!     and a link at the archive's own name unless it leads to a non-empty
357//!     regular file (an archive zip read): dangling, or naming an empty
358//!     file, a directory or a device. The refusal is `zip I/O error: Too
359//!     many levels of symbolic links`, `zip error: Could not create output
360//!     file (NAME)`, exit 15, the link kept, no temporary left. An archive
361//!     reached through a link to a non-empty file is still updated through
362//!     it, as Info-ZIP updates it (`replace`'s copy), and `replace` checks
363//!     again (split parts as `-O` names); `zipnote -w` shares it. Why: a
364//!     planted link must not redirect where an archive is written.
365//! 32. Hardening (ruling T5): `zipsplit` opens its index and each part
366//!     `O_NOFOLLOW`. zipsplit truncates whatever is at the name
367//!     (`fopen(.., "w")`), writing through a symlink there, live or
368//!     dangling; stuffr fails the open (`zipsplit error: Too many levels of
369//!     symbolic links`, `Could not create output file (NAME)`, exit 15) and
370//!     its cleanup leaves the link, which it did not make. A regular file
371//!     at a part's name is still truncated, as zipsplit truncates it.
372//! 33. Hardening (ruling T6 of the 2026-10-31 security audit): the `-lf`
373//!     log's command line shows `-P`'s password as `********` (eight
374//!     asterisks, whatever its length), in every form zip accepts (`-P pw`,
375//!     `-Ppw`, `-P=pw`, `-qPpw`, `--password pw`, `--password=pw` and its
376//!     abbreviations, from `ZIPOPT`/`ZIP` too); zip writes it as given. A
377//!     new log is created 0600 (zip: `0666 & ~umask`), an existing one
378//!     keeps its mode, and the log, `-la`'s included, is opened
379//!     `O_NOFOLLOW`: a symlink at its name, live or dangling, is `could not
380//!     open logfile`, exit 16, where zip writes through it. `-sc`'s listing
381//!     still shows the password, as zip's does (it goes to the terminal,
382//!     not to a file). zip and zipcloak keep the password, given or typed,
383//!     only in a holder that zeroes it when dropped (ruling T6b), and the
384//!     traditional-encryption keys derived from it (`infozip::crypt::Keys`)
385//!     are zeroed when dropped too. Only those are: the process's own
386//!     arguments (`argv`), and the parser's copies of them (`z.o.args`,
387//!     which `-sc` and `-R`'s hint print), still hold the `-P` value and
388//!     are not zeroed.
389//! 34. Hardening (ruling T7a of the 2026-10-31 security audit): zip's
390//!     handler answers every INT, TERM, ABRT, BUS, ILL and SEGV with
391//!     `Interrupted (aborting)`, exit 9. stuffr's answers a signal it sent
392//!     itself (its own `abort()`: a double panic, an allocation failure, a
393//!     corrupted heap) and, on Linux, a SEGV, BUS or ILL the kernel raised
394//!     for a real fault by removing the temporary (and zipsplit's parts)
395//!     and dying of the signal at its default action, without the line: a
396//!     crash in stuffr stays a crash. A signal another process sends
397//!     (`kill -SEGV` too) keeps zip's answer. On macOS, which reports a
398//!     `kill -SEGV`/`-BUS`/`-ILL` exactly as a fault, a real fault still
399//!     answers as zip does. A stack overflow dies of the signal with no
400//!     message (the handler has no stack to run on).
401
402use std::collections::BTreeMap;
403use std::ffi::{OsStr, OsString};
404use std::io::{self, BufWriter, Write};
405use std::path::Path;
406use std::process::ExitCode;
407use std::sync::atomic::AtomicU64;
408use std::sync::{Arc, Mutex};
409
410use super::CountedRun;
411use super::host::{self, Host, SANDBOX_OUTPUT_CAP, Sandbox};
412
413pub(crate) mod cli;
414pub(crate) mod fix;
415pub(crate) mod flavour;
416mod messages;
417pub(crate) mod readzip;
418pub(crate) mod scan;
419pub(crate) mod sink;
420pub(crate) mod split;
421pub(crate) mod ttyio;
422pub(crate) mod unicode;
423pub(crate) mod unix;
424pub(crate) mod update;
425pub(crate) mod write;
426pub(crate) mod zipcloak;
427pub(crate) mod zipfile;
428pub(crate) mod zipnote;
429pub(crate) mod zipsplit;
430pub(crate) mod zipup;
431
432pub use flavour::Flavour;
433use messages::{self as m, printf};
434
435/// The environment variable that overrides [`default_flavour`].
436const FLAVOR_VAR: &str = "STUFFR_ZIP_FLAVOR";
437
438/// The exit codes the family can return (`ziperr.h`, the plan's Global
439/// Constraints), for the `compat` fuzz target.
440#[doc(hidden)]
441pub const EXIT_CODES: &[i32] = &[
442    0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19,
443];
444
445/// How a run ends: the exit code (`ziperr()`'s, `EXIT()`'s, or a screen
446/// asked for with exit 0).
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub struct Exit {
449    pub code: i32,
450}
451
452/// The flavour a build uses when `STUFFR_ZIP_FLAVOR` is unset or empty:
453/// Apple on macOS and the BSDs, Ubuntu everywhere else, Windows included.
454pub fn default_flavour() -> Flavour {
455    if cfg!(any(
456        target_os = "macos",
457        target_os = "freebsd",
458        target_os = "netbsd",
459        target_os = "openbsd",
460        target_os = "dragonfly"
461    )) {
462        Flavour::Apple
463    } else {
464        Flavour::Ubuntu
465    }
466}
467
468/// The flavour `STUFFR_ZIP_FLAVOR`'s value selects: `apple` or `ubuntu`,
469/// the default when unset or empty, and `Err(value)` for anything else.
470pub(crate) fn flavour_from(value: Option<OsString>) -> Result<Flavour, OsString> {
471    match value {
472        None => Ok(default_flavour()),
473        Some(v) if v.is_empty() => Ok(default_flavour()),
474        Some(v) if v == "apple" => Ok(Flavour::Apple),
475        Some(v) if v == "ubuntu" => Ok(Flavour::Ubuntu),
476        Some(v) => Err(v),
477    }
478}
479
480/// One run's state: zip's globals (`noisy`, `mesg`, `zipfile` and the
481/// rest are in `o` and here), the message layer and the host.
482pub(crate) struct Zr<'a> {
483    /// The process, or a sandbox's stand-ins for it.
484    pub(crate) host: Host,
485    /// Which reference this run behaves as.
486    pub(crate) flavour: Flavour,
487    /// `getenv`: the process's environment, or a sandbox's stand-in.
488    pub(crate) env: &'a dyn Fn(&str) -> Option<OsString>,
489    /// Standard output, flushed after every message.
490    pub(crate) out: BufWriter<Box<dyn Write + Send>>,
491    /// Standard error.
492    pub(crate) err: &'a mut dyn Write,
493    /// The options (zip.c's globals; the helpers read `noisy` here too).
494    pub(crate) o: cli::Options,
495    /// `mesg` is standard error (the archive goes to standard output, or a
496    /// helper keeps standard output for data).
497    pub(crate) mesg_err: bool,
498    /// `mesg_line_started`: a message began a line it has not ended.
499    pub(crate) mesg_line_started: bool,
500    /// The log file (`-lf`), once opened.
501    pub(crate) log: Option<Box<dyn Write>>,
502    /// `logfile_line_started`
503    pub(crate) log_line_started: bool,
504    /// `seen_doubledash`: the first `--` was met.
505    pub(crate) seen_doubledash: bool,
506    /// The program, as `ziperr` of a helper names it.
507    pub(crate) program: &'static str,
508    /// `-g` is writing (zip.c's `tempzip == zipfile`): `ziperr` then says
509    /// it is restoring this archive.
510    pub(crate) restoring: Option<Vec<u8>>,
511    /// zip's `current_in_disk`: the part of a split archive being read.
512    pub(crate) current_in_disk: u64,
513    /// zip's `total_disks`: the parts of the archive being read.
514    pub(crate) total_disks: u64,
515    /// zip's `skip_this_disk`: 1 + the part `-F` was told to skip (0: none).
516    pub(crate) skip_this_disk: u64,
517    /// Standard input as zip's prompts read it (`fgets(..., stdin)`: one
518    /// stdio stream for every prompt of the run), once first asked for.
519    pub(crate) prompt: Option<split::Prompt>,
520    /// `mesg_line_started`, shared with the split writer, which writes its
521    /// questions itself.
522    pub(crate) line_cell: std::rc::Rc<std::cell::Cell<bool>>,
523    /// zip's `using_utf8` (Ubuntu's `UNICODE_SUPPORT`): `en_US.UTF-8`
524    /// loads, so names convert as UTF-8 and new ones get bit 11
525    /// (`unicode::utf8_locale`); always false for Apple.
526    pub(crate) using_utf8: bool,
527}
528
529impl<'a> Zr<'a> {
530    pub(crate) fn new(
531        host: Host,
532        flavour: Flavour,
533        program: &'static str,
534        env: &'a dyn Fn(&str) -> Option<OsString>,
535        err: &'a mut dyn Write,
536    ) -> Zr<'a> {
537        let out = host.out_writer(None);
538        let using_utf8 = flavour::unicode_support(flavour) && unicode::utf8_locale(&host);
539        Zr {
540            host,
541            flavour,
542            env,
543            out,
544            err,
545            o: cli::Options::new(),
546            mesg_err: false,
547            mesg_line_started: false,
548            log: None,
549            log_line_started: false,
550            seen_doubledash: false,
551            program,
552            restoring: None,
553            current_in_disk: 0,
554            total_disks: 1,
555            skip_this_disk: 0,
556            prompt: None,
557            line_cell: std::rc::Rc::new(std::cell::Cell::new(false)),
558            using_utf8,
559        }
560    }
561
562    /// What putlocal/putcentral need of `UNICODE_SUPPORT` (Ubuntu only):
563    /// `using_utf8` and `-UN UTF8`.
564    pub(crate) fn unicode_out(&self) -> Option<(bool, bool)> {
565        flavour::unicode_support(self.flavour).then_some((self.using_utf8, self.o.utf8_force))
566    }
567
568    /// `mesg_line_started = v`, mirrored for the interrupt handler (which
569    /// prints a newline first when a line was started).
570    pub(crate) fn set_line(&mut self, v: bool) {
571        self.mesg_line_started = v;
572        self.line_cell.set(v);
573        host::zip_mesg_state(&self.host, self.mesg_err, v);
574    }
575
576    /// Text written to standard output directly (`printf`, `puts`).
577    pub(crate) fn stdout_write(&mut self, b: &[u8]) {
578        let _ = self.out.write_all(b);
579        let _ = self.out.flush();
580    }
581
582    /// Text written to `mesg` (`fprintf(mesg, ...)` + `fflush`).
583    pub(crate) fn mesg_write(&mut self, b: &[u8]) {
584        if self.mesg_err {
585            let _ = self.err.write_all(b);
586            let _ = self.err.flush();
587        } else {
588            self.stdout_write(b);
589        }
590    }
591
592    /// Text written to the log file, when there is one.
593    pub(crate) fn log_write(&mut self, b: &[u8]) {
594        // A sandbox charges the log's bytes to the output budget, like any
595        // other file; a write the budget cannot hold is lost, as a failed
596        // `fprintf` to the log is (its result is never looked at).
597        if let Host::Sandbox(s) = &self.host
598            && self.log.is_some()
599            && crate::compat::host::take(&s.budget, b.len() as u64).is_err()
600        {
601            return;
602        }
603        if let Some(l) = self.log.as_mut() {
604            let _ = l.write_all(b);
605            let _ = l.flush();
606        }
607    }
608
609    /// zip.c:zipwarn: `\tzip warning: a b` to `mesg` (when noisy) and to
610    /// the log.
611    pub(crate) fn zipwarn(&mut self, a: &[u8], b: &[u8]) {
612        if self.program != "zip" {
613            // the helpers' zipwarn: `zipcloak warning: a b`, always
614            let mut line = Vec::new();
615            printf(
616                &mut line,
617                m::HELPER_WARNING,
618                &[self.program.as_bytes(), a, b],
619            );
620            self.mesg_write(&line);
621            return;
622        }
623        let mut line = Vec::new();
624        printf(&mut line, m::ZIP_WARNING, &[a, b]);
625        if self.o.noisy {
626            if self.mesg_line_started {
627                self.mesg_write(b"\n");
628            }
629            self.mesg_write(&line);
630            self.set_line(false);
631        }
632        if self.log.is_some() {
633            if self.log_line_started {
634                self.log_write(b"\n");
635            }
636            self.log_write(&line);
637            self.log_line_started = false;
638        }
639    }
640
641    /// zip.c:zipmessage: `a` and `b` as one line to `mesg` (when noisy) and
642    /// the log.
643    pub(crate) fn zipmessage(&mut self, a: &[u8], b: &[u8]) {
644        let mut line = a.to_vec();
645        line.extend_from_slice(b);
646        line.push(b'\n');
647        if self.o.noisy {
648            if self.mesg_line_started {
649                self.mesg_write(b"\n");
650            }
651            self.mesg_write(&line);
652            self.set_line(false);
653        }
654        if self.log.is_some() {
655            if self.log_line_started {
656                self.log_write(b"\n");
657            }
658            self.log_write(&line);
659            self.log_line_started = false;
660        }
661    }
662
663    /// zip.c:zipmessage_nl: `a` to `mesg` (when noisy) and the log, then,
664    /// with `nl`, a newline if a line was started.
665    // port: zip.c:zipmessage_nl
666    pub(crate) fn zipmessage_nl(&mut self, a: &[u8], nl: bool) {
667        if self.o.noisy {
668            if !a.is_empty() {
669                self.mesg_write(a);
670                self.set_line(true);
671            }
672            if nl && self.mesg_line_started {
673                self.mesg_write(b"\n");
674                self.set_line(false);
675            }
676        }
677        if self.log.is_some() {
678            if !a.is_empty() {
679                self.log_write(a);
680                self.log_line_started = true;
681            }
682            if nl && self.log_line_started {
683                self.log_write(b"\n");
684                self.log_line_started = false;
685            }
686        }
687    }
688
689    /// zip.c:ziperr: the message for `code` and `h`, to `mesg` and the log;
690    /// the exit it ends the run with is returned.
691    // port: zip.c:ziperr
692    pub(crate) fn ziperr(&mut self, code: i32, h: &[u8]) -> Exit {
693        self.ziperr_with(code, h, None)
694    }
695
696    /// [`Zr::ziperr`] for a code whose message also says why the system
697    /// refused (`ZE_S_PERR`: `zip I/O error: <strerror>`).
698    pub(crate) fn ziperr_os(&mut self, code: i32, h: &[u8], e: &io::Error) -> Exit {
699        self.ziperr_with(code, h, Some(host::strerror(e)))
700    }
701
702    fn ziperr_with(&mut self, code: i32, h: &[u8], why: Option<Vec<u8>>) -> Exit {
703        if self.program != "zip" {
704            // the helpers' ziperr: `perror("zipcloak error")`, then the line
705            if let Some(why) = &why {
706                let mut l = Vec::new();
707                printf(&mut l, m::HELPER_PERROR, &[self.program.as_bytes(), why]);
708                let _ = self.err.write_all(&l);
709                let _ = self.err.flush();
710            }
711            return self.helper_err(code, h);
712        }
713        if self.mesg_line_started {
714            self.mesg_write(b"\n");
715            self.set_line(false);
716        }
717        if self.log.is_some() && self.log_line_started {
718            self.log_write(b"\n");
719            self.log_line_started = false;
720        }
721        let text = usize::try_from(code)
722            .ok()
723            .and_then(|c| m::ZIPERRORS.get(c))
724            .copied()
725            .unwrap_or("");
726        let mut line = Vec::new();
727        printf(&mut line, m::ZIP_ERROR, &[text.as_bytes(), h]);
728        let mut io_line = Vec::new();
729        if let Some(why) = &why {
730            printf(&mut io_line, m::ZIP_IO_ERROR, &[why]);
731            self.mesg_write(&io_line);
732        }
733        self.mesg_write(&line);
734        if self.log.is_some() {
735            if why.is_some() {
736                io_line.push(b'\n');
737                self.log_write(&io_line);
738            }
739            self.log_write(&line);
740            self.log_line_started = false;
741        }
742        if let Some(name) = self.restoring.take() {
743            // -g: the C rewrites the old central directory in place;
744            // stuffr's archive was never touched (deviation 13)
745            let mut l = Vec::new();
746            printf(&mut l, m::ATTEMPTING_RESTORE, &[&name]);
747            self.mesg_write(&l);
748            self.log_write(&l);
749        }
750        Exit { code }
751    }
752
753    /// The helpers' `ziperr` (`zipcloak error: text (h)`, to `mesg`).
754    pub(crate) fn helper_err(&mut self, code: i32, h: &[u8]) -> Exit {
755        let text = usize::try_from(code)
756            .ok()
757            .and_then(|c| m::ZIPERRORS.get(c))
758            .copied()
759            .unwrap_or("");
760        let mut line = Vec::new();
761        printf(
762            &mut line,
763            m::HELPER_ERROR,
764            &[self.program.as_bytes(), text.as_bytes(), h],
765        );
766        self.mesg_write(&line);
767        Exit { code }
768    }
769}
770
771/// A broken-down local time (`struct tm`, the fields zip reads).
772#[derive(Debug, Clone, Copy, PartialEq, Eq)]
773pub(crate) struct Tm {
774    /// The full year (`tm_year + 1900`).
775    pub(crate) year: i64,
776    /// 0 to 11.
777    pub(crate) mon: u32,
778    pub(crate) mday: u32,
779    pub(crate) hour: u32,
780    pub(crate) min: u32,
781    pub(crate) sec: u32,
782    /// 0 is Sunday.
783    pub(crate) wday: u32,
784}
785
786/// `localtime(&t)`: the process's zone under `Host::Real` on unix, UTC for a
787/// sandbox (and for any other target).
788pub(crate) fn localtime(host: &Host, t: i64) -> Tm {
789    #[cfg(unix)]
790    if matches!(host, Host::Real) {
791        // SAFETY: `tm` is plain data that a successful `localtime_r` fills
792        // in whole; both pointers are valid for the call.
793        let tt = t as libc::time_t;
794        let mut tm: libc::tm = unsafe { std::mem::zeroed() };
795        if !unsafe { libc::localtime_r(&tt, &mut tm) }.is_null() {
796            return Tm {
797                year: i64::from(tm.tm_year) + 1900,
798                mon: tm.tm_mon as u32 % 12,
799                mday: tm.tm_mday as u32,
800                hour: tm.tm_hour as u32,
801                min: tm.tm_min as u32,
802                sec: tm.tm_sec as u32,
803                wday: tm.tm_wday as u32 % 7,
804            };
805        }
806    }
807    let _ = host;
808    // UTC, from the civil calendar
809    let days = t.div_euclid(86_400);
810    let secs = t.rem_euclid(86_400);
811    let z = days + 719_468;
812    let era = z.div_euclid(146_097);
813    let doe = z - era * 146_097;
814    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
815    let y = yoe + era * 400;
816    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
817    let mp = (5 * doy + 2) / 153;
818    let d = doy - (153 * mp + 2) / 5 + 1;
819    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
820    let year = if mo <= 2 { y + 1 } else { y };
821    Tm {
822        year,
823        mon: (mo - 1) as u32,
824        mday: d as u32,
825        hour: (secs / 3600) as u32,
826        min: (secs / 60 % 60) as u32,
827        sec: (secs % 60) as u32,
828        wday: ((days + 4).rem_euclid(7)) as u32,
829    }
830}
831
832/// `asctime(localtime(t))` with its newline: `Sun Sep 16 01:03:52 1973\n`.
833/// A sandbox's zone is UTC; so is any other target's.
834pub(crate) fn asctime(host: &Host, t: i64) -> String {
835    const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
836    const MONTHS: [&str; 12] = [
837        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
838    ];
839    let tm = localtime(host, t);
840    format!(
841        "{} {} {:2} {:02}:{:02}:{:02} {}\n",
842        DAYS[tm.wday as usize], MONTHS[tm.mon as usize], tm.mday, tm.hour, tm.min, tm.sec, tm.year
843    )
844}
845
846/// The current time as [`asctime`] shows it.
847pub(crate) fn asctime_now(z: &Zr) -> String {
848    let t = std::time::SystemTime::now()
849        .duration_since(std::time::UNIX_EPOCH)
850        .map_or(0, |d| d.as_secs() as i64);
851    asctime(&z.host, t)
852}
853
854/// Entry point for `zip`, `zipcloak`, `zipnote` and `zipsplit`; `name` is
855/// the canonical invoked name, `argv0` the program as invoked (`-sc` shows
856/// it) and `args` the arguments after it.
857pub fn run(name: &'static str, argv0: &OsStr, args: Vec<OsString>) -> ExitCode {
858    // Deviation 1: as the other families.
859    host::reset_sigpipe();
860    let env = |k: &str| std::env::var_os(k);
861    let mut err = io::stderr();
862    let flavour = match flavour_from(env(FLAVOR_VAR)) {
863        Ok(f) => f,
864        Err(v) => {
865            printf(
866                &mut err,
867                m::FLAVOR_REFUSED,
868                &[name.as_bytes(), v.as_encoded_bytes()],
869            );
870            return ExitCode::from(1);
871        }
872    };
873    let argv0 = if argv0.is_empty() {
874        name.as_bytes()
875    } else {
876        argv0.as_encoded_bytes()
877    };
878    let code = main(flavour, name, argv0, &args, &env, Host::Real, &mut err);
879    // The exit status is the code's low byte, as `exit()` makes it (every
880    // code here is below 256).
881    ExitCode::from(code as u8)
882}
883
884/// [`run`] without the process: operands resolve against `dir`, standard
885/// input is `stdin` (a pipe of bytes), standard output and error are
886/// captured and returned with the exit code, neither is a terminal, the
887/// environment is not read (`ZIP` and `ZIPOPT` are unset;
888/// `STUFFR_ZIP_FLAVOR` is not consulted), no signal is touched, and
889/// standard output and output files share the sandbox's output budget.
890/// For the fuzz target and the tests; not a stable interface.
891#[doc(hidden)]
892pub fn run_in(
893    dir: &Path,
894    name: &str,
895    args: Vec<OsString>,
896    stdin: &[u8],
897    flavour: Flavour,
898) -> (i32, Vec<u8>, Vec<u8>) {
899    let (code, out, err, _) = run_in_counted(flavour, dir, name, &args, stdin);
900    (code, out, err)
901}
902
903/// [`run_in`] in the shape the other families' sandboxed entries have, also
904/// returning what each input read (empty until a task counts them).
905#[doc(hidden)]
906pub fn run_in_counted(
907    flavour: Flavour,
908    dir: &Path,
909    name: &str,
910    args: &[OsString],
911    stdin: &[u8],
912) -> CountedRun {
913    let name: &'static str = match name {
914        "zipcloak" => "zipcloak",
915        "zipnote" => "zipnote",
916        "zipsplit" => "zipsplit",
917        _ => "zip",
918    };
919    let stdout = Arc::new(Mutex::new(Vec::new()));
920    let reads = Arc::new(Mutex::new(BTreeMap::new()));
921    let host = Host::Sandbox(Sandbox {
922        dir: dir.to_path_buf(),
923        stdin: Some(stdin.to_vec()),
924        stdout: stdout.clone(),
925        budget: Arc::new(AtomicU64::new(SANDBOX_OUTPUT_CAP)),
926        reads: reads.clone(),
927    });
928    let mut err = Vec::new();
929    let env = |_: &str| None;
930    let code = main(flavour, name, name.as_bytes(), args, &env, host, &mut err);
931    let out = std::mem::take(&mut *stdout.lock().unwrap_or_else(|p| p.into_inner()));
932    let reads = std::mem::take(&mut *reads.lock().unwrap_or_else(|p| p.into_inner()));
933    (code, out, err, reads)
934}
935
936/// The run below the process boundary. Returns the exit code.
937fn main(
938    flavour: Flavour,
939    name: &'static str,
940    argv0: &[u8],
941    args: &[OsString],
942    env: &dyn Fn(&str) -> Option<OsString>,
943    host: Host,
944    err: &mut dyn Write,
945) -> i32 {
946    let mut z = Zr::new(host, flavour, name, env, err);
947    let args: Vec<Vec<u8>> = args.iter().map(|a| a.as_encoded_bytes().to_vec()).collect();
948    if name == "zipcloak" {
949        let code = match cli::parse_cloak(&mut z, argv0, &args) {
950            Err(Exit { code }) => code,
951            Ok(o) => zipcloak::run(&mut z, o).code,
952        };
953        let _ = z.out.flush();
954        return code;
955    }
956    if name == "zipnote" {
957        let code = match cli::parse_note(&mut z, &args) {
958            Err(Exit { code }) => code,
959            Ok(o) => zipnote::run(&mut z, o).code,
960        };
961        let _ = z.out.flush();
962        return code;
963    }
964    if name == "zipsplit" {
965        let code = match cli::parse_split(&mut z, &args) {
966            Err(Exit { code }) => code,
967            Ok(o) => zipsplit::run(&mut z, o).code,
968        };
969        let _ = z.out.flush();
970        return code;
971    }
972    let code = match cli::parse_zip(&mut z, argv0, &args) {
973        Err(Exit { code }) => code,
974        // A `zip` command line the checks accepted: the names and the archive.
975        Ok(()) => scan::run(&mut z).code,
976    };
977    let _ = z.out.flush();
978    code
979}
980
981#[cfg(test)]
982mod tests;