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;