fix(archive): tar and zip archive a directory, and -C re-bases the operands after it - #738
Merged
Merged
Conversation
…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.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
💡 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".
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two reported
tarbugs, plus the identical defect inzip.Bug 1:
tar -ccould not archive a directorytar -cf dir.tar dexited 1 withtar: <hostpath>/d: Is a directory. Every operand went straight toread_bytes, with no isdir check and no recursion, sotar -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, andIs a directoryis not something tar says.zip -r <dir>had exactly the same defect (zip: /data/d: No such file or directory), sincezip_cmdalso read every operand withread_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
IsADirectoryErrorcan no longer reach the user.Bug 2:
tar -Cwas ignoredtar -czf /work/out1.tar.gz -C /work/check my_paperfailed astar: paths span multiple mounts (/, /work/), cross-mount not supported, and-C /work check/my_papersaidNo such file or directoryimmediately afterls -ldhad printed the directory. Same defect class asunzip -pin #725.Fixed one layer lower than that one.
-Cis 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.-fstill 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:walk_find/walkFind, so an entry is classified throughstatand never by name;readdirreports;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:
tar-hzip-r-yA directory is its own member in both, so an empty one survives a round trip, and
tar -x/unzipnowmkdirfor one.Mount boundaries
MountViewis how a command sees them, offered the wayLinkViewis: name amountsparameter, 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-systemwording, because descending would archive by accident exactly whatMountRootPolicynow refuses on purpose: a mount root in a source slot fortar -c,zipandcp. Only positional operands are tested, sotar -xf a.tar -C /mntstays legal whiletar -cf a.tar /mntdoes not, and only create mode counts, so a-t/-xmember 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-operandCannot statplus one trailer (exit 2, the other operands still archive), a-Cit 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,
-jjunks to the basename and drops directory entries entirely,-xis anchored on the whole stored name (d/sub/*matches,sub/*does not) where tar's--excludeis unanchored, an unreachable operand iszip warning: name not matched:and does not stop the run, and a run that matched nothing printszip error: Nothing to do!, exits 12, and writes no archive.-qsilences the warnings but never that error.Deliberate divergences, documented in place: siblings are sorted rather than emitted in readdir order (the same choice
dualready makes);zip -xtakes one pattern per occurrence, since the spec has no variadic option value and-x a -x bsays the same thing; and theadding:line carries no(deflated N%)suffix, since the ratio depends on the compressor and would differ between the two languages.Verified
--all-filespasses; spec drift none; spec parity 93/93.The integ run earned its place: it caught a TS-only bug the unit tests could not.
walkFindanswers in mount-relative keys (it stands in for a backend find op) while Python'swalk_findanswers 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" } }typesurvives 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_basewas missing fromgen-specs.tsearlier 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, ontar, 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.
tarandzipwere absent fromNO_FOLLOW_COMMANDS, so the router rewrote the operand through the link table first:tar -cf o.tar linkstored a regular file holding the target's 6 bytes where GNU storeslrwxrwxrwx link -> d/a.txtat 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 noDEREFERENCE_FLAGSentry:-hand-yare the planner's to read.Only the last
-Cwas checked.tar -cf m.tar -C missing x -C good y.txtreportedx: Cannot statand still wrote an archive holdingy.txt, where GNU chdirs at each-Cand 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
seenset doing detection thatnamespace.followalready does properly under a hop limit. Deleting it archives both names, as GNU and Info-ZIP do; catching theCycleErrorthatresolveraises turns a genuine cycle into one fatal problem per member with GNU'sToo 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
-tand-xan operand names something inside the archive, sotar -tf a.tar datawas refused as busy whendatahappened 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 -tignores member selectors and lists the whole archive, where GNU filters and saysNot found in archive. Worth its own issue.