Skip to content

fix(archive): tar and zip archive a directory, and -C re-bases the operands after it - #738

Merged
zechengz merged 5 commits into
mainfrom
fix/tar-create-dirs
Aug 9, 2026
Merged

zechengz merged 5 commits into
mainfrom
fix/tar-create-dirs

Conversation

@zechengz

@zechengz zechengz commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Two reported tar bugs, plus the identical defect in zip.

Bug 1: tar -c could not archive a directory

tar -cf dir.tar d exited 1 with tar: <hostpath>/d: Is a directory. Every operand went straight to read_bytes, with no isdir check and no recursion, so tar -czf archive.tar.gz somedir/ (the most common tar invocation there is) could not work. Two further leaks in that one error: it printed the host path, breaking the sandbox illusion, and Is a directory is not something tar says.

zip -r <dir> had exactly the same defect (zip: /data/d: No such file or directory), since zip_cmd also read every operand with read_bytes.

Both now walk. Create is a two-phase pass in each: decide every member, then write. That ordering is what lets an exclusion prune a whole subtree and keeps the ordering stable, and it is why the two refusals that produce no archive can happen before anything is written.

An unreadable operand is now reported in virtual path space with the archiver's own wording, so a backend's raw IsADirectoryError can no longer reach the user.

Bug 2: tar -C was ignored

tar -czf /work/out1.tar.gz -C /work/check my_paper failed as tar: paths span multiple mounts (/, /work/), cross-mount not supported, and -C /work check/my_paper said No such file or directory immediately after ls -ld had printed the directory. Same defect class as unzip -p in #725.

Fixed one layer lower than that one. -C is not a flag the command reads once, it is a chdir for the operands typed after it, and it is cumulative. That is a property of the line, so it is declared in the spec (CommandSpec.operand_base, tar's only) and resolved by the one component that walks the line positionally: the parser tracks the base as it scans and reports it per word, and the classifier resolves each operand against it. Doing it any later is too late, because the classifier has already produced absolute PathSpecs and the router sees a phantom mount span. -f still resolves against the session cwd, which is what GNU does.

One traversal, two archivers

scan_operand / scanOperand (generic/archive/walk.*) is the whole of what tar and zip share. It merges three sources no single one can see:

  • the backend walk, reusing find's walk_find / walkFind, so an entry is classified through stat and never by name;
  • the namespace's symlinks, which no backend readdir reports;
  • the mount table, since a nested mount's keys live in another resource entirely.

It reports paths, never names, because naming is exactly where the two formats part company. The two things they disagree about in the traversal itself are parameters, so a third archiver adds a caller rather than a second walk:

descends symlink
tar always stored, unless -h
zip only under -r followed, unless -y

A directory is its own member in both, so an empty one survives a round trip, and tar -x / unzip now mkdir for one.

Mount boundaries

MountView is how a command sees them, offered the way LinkView is: name a mounts parameter, nothing else. A traversal that renders lines never needs it, because the executor's fan-out already reruns per mount and concatenates; one that emits a single binary object cannot be merged that way, which is why the archivers read the table themselves.

A descendant mount is not crossed. The mountpoint stays an entry and its contents are dropped with GNU's --one-file-system wording, because descending would archive by accident exactly what MountRootPolicy now refuses on purpose: a mount root in a source slot for tar -c, zip and cp. Only positional operands are tested, so tar -xf a.tar -C /mnt stays legal while tar -cf a.tar /mnt does not, and only create mode counts, so a -t/-x member selector is never treated as a path.

One consequence worth flagging: cd /work && tar -czf ../x.tgz . is now refused, since . resolves to the mount root.

Semantics, pinned not guessed

Everything below was probed against GNU tar 1.35 and Info-ZIP 3.0 on debian:stable-slim.

tar: the leading-slash warning, Cowardly refusing to create an empty archive (exit 2), a per-operand Cannot stat plus one trailer (exit 2, the other operands still archive), a -C it cannot enter (exit 2, no archive written), archive cannot contain itself; not dumped (exit 0).

zip, whose defaults are tar's inverted twice over: a leading slash is stripped in silence, -j junks to the basename and drops directory entries entirely, -x is anchored on the whole stored name (d/sub/* matches, sub/* does not) where tar's --exclude is unanchored, an unreachable operand is zip warning: name not matched: and does not stop the run, and a run that matched nothing prints zip error: Nothing to do!, exits 12, and writes no archive. -q silences the warnings but never that error.

Deliberate divergences, documented in place: siblings are sorted rather than emitted in readdir order (the same choice du already makes); zip -x takes one pattern per occurrence, since the spec has no variadic option value and -x a -x b says the same thing; and the adding: line carries no (deflated N%) suffix, since the ratio depends on the compressor and would differ between the two languages.

Verified

  • pre-commit clean and idempotent across two consecutive --all-files passes; spec drift none; spec parity 93/93.
  • TS core 7005, node 2272, browser 182.
  • Full Python suite: exit 0, no failures.
  • integ, both languages, exact parity: ram 2328 / disk 2293 / redis 2186 each, 0 failures (plus opfs 2188, TS only). 13 new zip cases and 15 new tar cases.

The integ run earned its place: it caught a TS-only bug the unit tests could not. walkFind answers in mount-relative keys (it stands in for a backend find op) while Python's walk_find answers in virtual paths, so members came out mangled on any prefixed mount. The unit harness mounts at root, which is exactly why it could not see it; there is now a prefixed-mount test in both languages that fails without the lift.


Second commit: the spec dumps state what a command declares

Unrelated to the archive fix, but the same files pushed me into it. Both generators emitted every field of every dataclass, so truncate, which declares one thing, spent 27 lines of spec body restating 21 defaults that read identically in all 93 files:

{ "description": null, "epilog": null, "ignore_tokens": [],
  "old_option_style": false, "operand_base": null,
  "options": [ { "choices": [], "count": false, "default": null,
    "description": null, "long": "--size", "multiple": false,
    "numeric_shorthand": false, "pair": false, "required": false,
    "short": "-s", "short_value": true, "type": "str",
    "value_optional": false } ],
  "positional": [], "rest": { "provided_by": [], "text_when": [], "type": "path" } }

is now

{ "options": [ { "long": "--size", "short": "-s", "type": "str" } ],
  "rest": { "type": "path" } }

type survives even at its default, because what a token is is the first thing a reader looks for. Defaults come from the dataclass fields in Python and from a default-constructed instance in TypeScript, not from a table either side could let drift.

This narrows the parity gate's blast radius rather than widening it. When operand_base was missing from gen-specs.ts earlier in this PR, Python emitted the key in all 93 files and TypeScript in none, so parity reported ~95 divergences to sift through. The same bug now reports one, on tar, the only command that sets it.

279 files, 22,113 lines of restated defaults gone. Regeneration verified idempotent; parity green.


Fourth commit: the five findings from the Codex review

All five were real. Each was reproduced against a live workspace before anything changed, then pinned against GNU tar 1.35 and Info-ZIP 3.0 on debian:stable-slim.

A symlink operand never reached the planner as a link. tar and zip were absent from NO_FOLLOW_COMMANDS, so the router rewrote the operand through the link table first: tar -cf o.tar link stored a regular file holding the target's 6 bytes where GNU stores lrwxrwxrwx link -> d/a.txt at size 0. It also skipped the planner's cross-mount refusal, since by then there was no link left to refuse. Both archivers now lstat, and deliberately carry no DEREFERENCE_FLAGS entry: -h and -y are the planner's to read.

Only the last -C was checked. tar -cf m.tar -C missing x -C good y.txt reported x: Cannot stat and still wrote an archive holding y.txt, where GNU chdirs at each -C and dies at the first it cannot enter. The option accumulates now and the planner walks the list, so the first bad one is fatal and no members are written. Extraction still uses the last.

Two links to one target were called a loop, and a real loop was not caught at all. These turned out to be the same mistake: an operand-wide seen set doing detection that namespace.follow already does properly under a hop limit. Deleting it archives both names, as GNU and Info-ZIP do; catching the CycleError that resolve raises turns a genuine cycle into one fatal problem per member with GNU's Too many levels of symbolic links, keeping the directory entry and exiting 2 rather than throwing out of the planner for a bare exit 1.

The mount-root refusal denied member selectors. Under -t and -x an operand names something inside the archive, so tar -tf a.tar data was refused as busy when data happened to spell a mount. Gated on create mode now, GNU's dashless first word included.

One of my own tests asserted the loop bug rather than catching it. It is replaced.

Five integ cases cover all of it end to end in both languages. Counts after: ram 2328 / disk 2293 / redis 2186 each, plus opfs 2188, zero failures, exact parity. Full Python suite exit 0; TS core 7005, node 2272.

Unrelated gap the new coverage surfaced, pinned as observed rather than wished for: mirage's tar -t ignores member selectors and lists the whole archive, where GNU filters and says Not found in archive. Worth its own issue.

…erands after it

tar -cf d.tar d exited 1 with "tar: <hostpath>/d: Is a directory": every
operand went straight to read_bytes, with no isdir check and no
recursion, so the most common tar invocation there is could not work.
zip -r <dir> had the identical defect. Both now walk.

Create is a two-phase pass in each: decide every member, then write.
Both plans are built on one traversal, scan_operand / scanOperand
(generic/archive/walk), which merges three sources no single one can
see: the backend walk (find's walk_find, so an entry is classified
through stat and never by name), the namespace's symlinks, and the
mount table. It reports paths, never names, because naming is where the
two formats part company; the two things they disagree about in the
traversal itself are parameters, so tar passes recurse=True and
dereference=-h while zip passes recurse=-r and dereference=not -y.

A directory is its own member, so an empty one survives a round trip,
and both extractors now mkdir for one. A symlink is a symlink member
under tar (SYMTYPE, target in linkname) and under zip -y (mode 0120777).
An unreadable operand is reported in virtual path space with the
archiver's own wording, because the raw IsADirectoryError was leaking
the host path behind a disk mount.

tar -C was ignored: `tar -czf /work/out.tgz -C /work/check my_paper`
failed as "paths span multiple mounts", the same defect class as unzip
-p in #725, because the operand resolved against the session cwd and
the router then saw a phantom mount span. -C is not a flag the command
reads once, it is a chdir for the operands typed after it, so it is now
declared in the spec (CommandSpec.operand_base) and resolved by the one
component that walks the line positionally: the parser reports a base
per word and the classifier resolves each operand against it.

MountView is how a command sees mount boundaries, offered the way
LinkView is (name a `mounts` parameter, nothing else). A traversal that
renders lines gets this free from the executor's fan-out; one that
emits a single binary object cannot, which is why the archivers read
the table themselves. A descendant mount is not crossed: the mountpoint
stays an entry and its contents are dropped with GNU's
--one-file-system wording, since descending would archive by accident
what MountRootPolicy now refuses on purpose for tar, zip and cp in a
source slot.

Semantics pinned against GNU tar 1.35 and Info-ZIP 3.0 on
debian:stable-slim, including Info-ZIP's inverted defaults, its
anchored -x, its silent leading-slash strip, and "Nothing to do!"
exiting 12 with no archive written.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

…aulted to

Both generators emitted every field of every dataclass, so `truncate`,
which declares one thing (`-s`/`--size` takes a string), spent 27 lines
of spec body restating 21 defaults that read identically in all 93
files. `zip` was 198 lines for five flags, 11 of the 13 keys on each
option being defaults.

Anything equal to its default is now dropped on both sides. `type`
survives even at its default, because what a token is is the first
thing a reader looks for, and `"rest": {}` says less than
`"rest": {"type": "path"}`.

The defaults come from the dataclass fields in python and from a
default-constructed instance in typescript, rather than from a table
either side could let drift. The two must drop exactly the same keys,
and the parity gate reports every command if they do not.

This narrows that gate's blast radius rather than widening it. When
`operand_base` was missing from gen-specs.ts, python emitted the key in
all 93 files and typescript in none, so parity reported ~95 divergences
to sift. The same bug now reports one, on tar, which is the only
command that sets it.

check_spec_parity's option-diff renderer keyed options by `o["long"] or
o["short"]`, which raises once an option carries only the spelling it
declared.

279 files, 22113 lines of restated defaults gone.
@zechengz

zechengz commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

…egex

childSpec measured the backend key with `.replace(/^\/+|\/+$/g, '')`.
The trailing alternative backtracks on a run of slashes, which CodeQL
flags as js/polynomial-redos, and the input is a resource path a mount
supplies.

utils/slash.ts already has stripSlash, which walks the two ends by
charCode and cannot backtrack. Use it. slash.test.ts pins it against
the regex it replaces.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4995d8519

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread python/mirage/commands/builtin/generic/archive/walk.py
Comment thread python/mirage/commands/builtin/generic/tar/create.py Outdated
Comment thread python/mirage/commands/builtin/generic/archive/walk.py Outdated
Comment thread python/mirage/commands/builtin/generic/archive/walk.py Outdated
Comment thread python/mirage/policy/builtin/mount_root.py
All five reproduced against a live workspace first, then pinned against
GNU tar 1.35 and Info-ZIP 3.0 on debian:stable-slim.

A symlink operand never reached the planner as a link. tar and zip were
absent from NO_FOLLOW_COMMANDS, so the router rewrote the operand
through the link table and `tar -cf o.tar link` stored a regular file
holding the target's bytes where GNU stores a symlink member of size 0.
It also skipped the planner's cross-mount refusal, since by then there
was no link left to refuse. Both archivers now lstat, and carry no
DEREFERENCE_FLAGS entry on purpose: -h and -y are the planner's to read.

Only the last -C was checked. `tar -cf m.tar -C missing x -C good y`
reported `x: Cannot stat` and still wrote an archive holding y, where
GNU chdirs at each -C and dies at the first it cannot enter. The option
accumulates now and the planner walks the list, so the first bad one is
fatal and no members are written.

Two links to one target were called a loop, and a real loop was not
caught at all. Both were the same mistake: an operand-wide `seen` set
doing detection the namespace already does properly under a hop limit.
Deleting it archives both names, as GNU and Info-ZIP do, and catching
the CycleError that resolve raises turns a genuine cycle into one fatal
problem per member with GNU's "Too many levels of symbolic links",
keeping the directory entry and exiting 2 instead of throwing out of
the planner for a bare exit 1.

The mount-root refusal denied member selectors. Under -t and -x an
operand names something inside the archive, so `tar -tf a.tar data`
was refused as busy when `data` happened to spell a mount. Gated on
create mode now, dashless first word included.

A test asserted the loop bug rather than catching it; it is replaced.
Five integ cases cover the lot end to end in both languages.
@zechengz
zechengz merged commit 07bca1b into main Aug 9, 2026
46 checks passed
@zechengz
zechengz deleted the fix/tar-create-dirs branch August 9, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants