Browse Source

G-124 achieved: dependency-free zip reading in punk::zip (punkshell 0.26.0)

punk::zip could write a zip and split an executable-prefixed one, but nothing in the tree
could READ a zip without zipfs, vfs::zip or tcllib's zipfile::decode - a system package
punkshell does not vendor, with exactly one consumer: make.tcl's zipfs-less kit
extraction. Measured at drafting: msys2's /usr/bin/tclsh8.6 has no tcllib, so a bake
driven from that host could not lift the runtime's own zip (its tcl_library) into the kit
and the artifact did not boot.

punk::zip 0.1.1 -> 0.2.0 adds three commands, stock Tcl only. archive_info reports where
the archive begins inside its host file and which offset convention it uses; members
walks the central directory and returns one dict per entry without decompressing
anything; unzip extracts all or part of it with every member's crc32 verified,
directories materialized, and the writer's 2MB whole-member-vs-streaming threshold
mirrored. All three accept a plain .zip or a zip attached to an executable or script
prefix, so "what is inside this kit / runtime / modpod" is now answerable on a runtime
without zipfs.

The offset derivation is stated once and shared: offsetbase = cdiroffset - diroffset.
Positive means an external preamble with archive-relative offsets, zero means the offsets
are file-relative (or there is no preamble); dataoffset is the split point either way.
Because every surface reads the ORIGINAL file at that base, the file-relative shape stops
being a special case - the broken split-off intermediate is never produced.
tclsh90b4_piperepl.exe is the store's one file-relative artifact: the old
split-then-tcllib path still fails on it with exactly "Bad zip file. Bad closure.", and
it now extracts all 841 members crc-verified in 735ms.

extract_preamble consumes that machinery instead of carrying its own scan, and two
defects went with the refactor: it read 28 bytes of a 30-byte local file header, and it
derived the file-relative base from the FIRST central directory record only (its own
'#todo! loop through all cdr file headers'). The shared walk takes the minimum over ALL
records and then validates that a PK\3\4 really sits there. Its five puts stdout debug
lines are gone.

Refusals are decided for every selected member before any file is opened, so an
unsupported archive cannot leave a half-populated directory: encrypted entries, zip64
size/offset fields, unknown compression methods, crc mismatch (partial file removed) and
member paths that would escape the target are each named. Store and deflate only,
mirroring the writer's own primitives.

make.tcl's zipfs-less extraction now calls punk::zip::unzip on the original runtime and no
longer creates the extracted_<runtime>.zip intermediate. No punkshell code requires
zipfile::decode any more. Two categories of hit remain and are deliberate: tcllib's own
zip/decode.tcl shipped as a LIBRARY inside some .vfs payloads, and one guarded optional
probe inside the vendored third-party ooxml1.10/ooxml.tcl.

First tests punk::zip has ever had (src/tests/modules/punk/zip/), covering the three
archive shapes from one source tree, the introspection output, the named refusals, and
the mkzip -> read round trip - the first coverage that what punkshell WRITES is readable.

Verification, all 2026-07-26. Reference host for the dependency-free claim: msys2
tclsh8.6 8.6.12, probed as zipfs / vfs::zip / zipfile::decode all ABSENT - suite driven
there harness-free, 28 passed, 2 skipped (the zipfs-gated cross-checks only), 0 failed.
30/30 under Tcl 9.0.3, 28 + 2 skipped under native 8.6. Full source-tree suite after the
change: 1142 tests, 1 failure - core/tcl exec-14.3, the documented baseline. Independent
cross-check: 868 members of tclsh905.exe extracted byte-identical against the same
archive mounted with tcl zipfs. Bake demonstration: 'bake -confirm 0 punk905' driven by
the msys2 tclsh8.6 (host=msys-x86_64, target=win32-x86_64) produced
extracted_tclsh905.exe/{tcl_library,lib} with no intermediate .zip, and the deployed kit
boots with tcl_library at //zipfs:/app/tcl_library, 95 encodings, msgcat 1.7.1, http
2.10.2 - and reads itself with the new reader.

Trap worth keeping: zlib stream inflate's 'get' returns only what the stream has already
produced, so one get per put silently truncates a large member to the first few chunks
(measured 327680 of 5048890 bytes on both 8.6.12 and 9.0.3). The inner drain loop is
load-bearing, and the >2MB streaming test exists to catch its removal.

Standing question answered and pinned: zipfs does NOT misidentify punk::zip::mkzip
directories. It keys on the trailing slash (which punk::zip::walk always emits), not the
stored permission bits, exactly as the current cli-999999.0a1.0.tm wording says - the
older, less precise wording survives only in frozen module snapshots under
src/vfs/mkzipfix.vfs and src/vfs/project.vfs. The real difference from zipfs mkzip is
that zipfs writes no directory entries at all.

The achieved flip archives the entry and the detail file, and sweeps the live tier: G-034
(the reader is its extract-rather-than-mount alternative, plus the measured finding that
make.tcl on 8.6 cannot mount its own templates modpod), G-101 (the read half of a
zipfs-less 8.6 container is solved), G-125 (the gate now has a named failure reason to
report), G-126 (the member-dict keys and fixture set its parity suite must reproduce, and
a back-pointer to G-128 whose only recorded bridge was this goal), G-128 (archive_info
answers "is this artifact safe to move the overlay of?" in one call).

One acceptance clause is reported for review rather than claimed silently: "NO
zipfile::decode requirement remains anywhere in the shipped tree" is satisfied for
punkshell's own code, with the vendored-third-party residue named above.

The archived detail file's diff is a rename plus the flip's evidence write-up.

Assisted-by: harness=claude; primary-model=claude-opus-5[1m]; api-location=anthropic.com
master
Julian Noble 5 days ago
parent
commit
78d9bf238a
  1. 2
      ARCHITECTURE.md
  2. 28
      CHANGELOG.md
  3. 4
      GOALS-archive.md
  4. 4
      GOALS.md
  5. 13
      goals/G-034-modpod-codeinterp-tcl86.md
  6. 12
      goals/G-101-tcl86-kit-container-strategy.md
  7. 11
      goals/G-125-unbootable-kit-deploy-gate.md
  8. 17
      goals/G-126-punkzip-accelerator.md
  9. 11
      goals/G-128-portable-pe-resource-stamping.md
  10. 99
      goals/archive/G-124-punkzip-reader.md
  11. 2
      punkproject.toml
  12. 3
      src/AGENTS.md
  13. 25
      src/make.tcl
  14. 964
      src/modules/punk/zip-999999.0a1.0.tm
  15. 7
      src/modules/punk/zip-buildversion.txt
  16. 1
      src/tests/modules/AGENTS.md
  17. 562
      src/tests/modules/punk/zip/testsuites/zip/zipreader.test

2
ARCHITECTURE.md

@ -81,7 +81,7 @@ Two layers with a deliberate dependency direction (the class never depends on th
## Build, provenance and kits
- **`src/make.tcl` is the single build entry.** Key subcommands: `modules`/`libs`/`packages` (repo-root build outputs), `vfscommonupdate` (regenerate `_vfscommon.vfs`), `bake` (kit assembly into `bin/`; optional kit names confine the run to those kits - G-121), `bakelist` (report the configured kit matrix with runtime/vfs presence and deployed state), `bin`, `bakehouse` (packages + bake for a clean checkout; `project`/`vfs` remain as deprecated aliases - G-112 rename, achieved), `bootsupport` (snapshot refresh), `vendorupdate`, `check`, `workflow`, `projectversion`. Dispatch and help dogfood `punk::args` (G-030, achieved) and degrade to plain scan/help when bootsupport `punk::args` is stale (`PUNKBOOT_PLAIN=1` forces the degraded mode). Source: `src/AGENTS.md`.
- **Host vs target platform (G-122).** The kit surfaces separate what the driving tclsh IS from what a bake is FOR. Target-keyed: the `bin/runtime/<tier>` store a runtime is read from, `.exe` suffixing of runtimes and kit outputs, presence checks, and the pre-deploy process sweep's tooling (`tasklist`/`taskkill` vs `ps`/`kill`, skipped when the target's processes cannot exist on this host). Host-keyed: copy commands, path handling, filesystem case rules, prompts. The default target is the host canon except for cygwin-family hosts (msys2/cygwin-runtime tclsh - `tcl_platform(platform)` `unix` on windows), which target `win32-x86_64`; a `mapvfs.config` entry may declare its own target and is then addressed in that platform's tier. `make.tcl check` prints the derivation. Zip-type kits assemble without zipfs in the driving tcl (raw-runtime split + `punk::zip::mkzip` + concatenation). Sources: `src/AGENTS.md`, `src/runtime/AGENTS.md`, `src/modules/punk/platform-999999.0a1.0.tm`.
- **Host vs target platform (G-122).** The kit surfaces separate what the driving tclsh IS from what a bake is FOR. Target-keyed: the `bin/runtime/<tier>` store a runtime is read from, `.exe` suffixing of runtimes and kit outputs, presence checks, and the pre-deploy process sweep's tooling (`tasklist`/`taskkill` vs `ps`/`kill`, skipped when the target's processes cannot exist on this host). Host-keyed: copy commands, path handling, filesystem case rules, prompts. The default target is the host canon except for cygwin-family hosts (msys2/cygwin-runtime tclsh - `tcl_platform(platform)` `unix` on windows), which target `win32-x86_64`; a `mapvfs.config` entry may declare its own target and is then addressed in that platform's tier. `make.tcl check` prints the derivation. Zip-type kits assemble without zipfs in the driving tcl (raw-runtime split + `punk::zip::mkzip` + concatenation), and since G-124 they EXTRACT without it too: `punk::zip` reads a zip - plain, or attached to an executable under either offset convention - with stock Tcl only, so a bake needs neither zipfs nor tcllib to carry a runtime's `tcl_library` into the kit. Sources: `src/AGENTS.md`, `src/runtime/AGENTS.md`, `src/modules/punk/platform-999999.0a1.0.tm`, `src/modules/punk/zip-999999.0a1.0.tm`.
- **punkcheck** records every install/delete as events in per-folder `.punkcheck` directories - the basis for skip/copy change detection, superseded-module pruning and provenance. Single OO record lifecycle (G-094, achieved) with atomic saves and advisory event-scoped locking for concurrent writers (G-095, achieved).
- **Provenance gates.** Build/promotion commands warn on uncommitted `src/` changes (column-0 `PROVENANCE-WARNING:` token, `-dirty-abort` for strict mode); `vendorupdate` warns for dirty source-project checkouts.
- **Buildsuites and the kit family.** `src/buildsuites/suite_tcl90/` builds Tcl/Tk/tcllib from source with a pinned zig toolchain and produces the runtime kit family (plain / punk / bi) plus artifact metadata (G-096-G-117 era: see archived goals G-096, G-098, G-102, G-103, G-107); artifacts publish to the punkbin repo that `bin/punk-runtime.cmd` fetches from. `src/buildsuites/suite_tcl86/` is the 8.6 sibling (G-099 + G-100, both achieved): a Tcl 8.6 windows runtime (static + dynamic shells, `tcl86t.dll`, on-disk lib tree - no zipfs, so no self-contained kit) with thread 2.8 + tclvfs + Tk 8.6 + tklib + tcllib(+tcllibc critcl accelerators) companions, gated against the 8.6 core/thread/tclvfs testsuites with tcllib/tklib on a record tier and an opt-in `test-tk`; punkshell's own runtests is censused on that runtime at parity with a same-day native-8.6 baseline. Suite child shells scrub `TCL<major>_<minor>_TM_PATH` as well as TCLLIBPATH/TCL_LIBRARY/TK_LIBRARY - without it a census measures the machine's module trees. The 8.6 kit container strategy remains in-flux - see "In-flux areas".

28
CHANGELOG.md

@ -5,6 +5,34 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to
Entries are newest-first; one bullet per notable change. See the root `AGENTS.md`
"Project Versioning" section for the bump policy.
## [0.26.0] - 2026-07-26
- punk::zip can READ a zip archive using only stock Tcl - no zipfs, no vfs::zip, no tcllib
(G-124). Three new shell-reachable commands: `punk::zip::members` lists what is inside an
archive without extracting it (per-entry name, directory-vs-file, sizes, compression
method, mtime, crc and the stored attributes), `punk::zip::unzip` extracts all or part of
it to a directory with every member's crc32 verified, and `punk::zip::archive_info`
reports where the archive begins inside its host file and which offset convention it
uses. All three work on a plain .zip and on a zip attached to an executable or script
prefix - a punk kit, a tclsh carrying a zipfs image, a zip-based .tm modpod - so
"what is in this thing" is now answerable on a runtime without zipfs.
- Reading an executable-prefixed archive no longer depends on splitting it first. The base
offset is derived from the central directory and the members are read from the original
file, which makes the archive-relative and file-relative offset conventions
indistinguishable to callers. A file-relative artifact (`tclsh90b4_piperepl.exe` in the
runtime store) previously split into a .zip that tcllib rejected with
"Bad zip file. Bad closure."; it now reads and extracts.
- make.tcl's zipfs-less kit extraction consumes that reader instead of tcllib's
`zipfile::decode`, which was a system package punkshell does not vendor. A tclsh with
neither zipfs nor tcllib (msys2's `/usr/bin/tclsh8.6`) can now carry a runtime's
`tcl_library` into the kit it bakes, where before the bake warned and produced an
artifact that could not initialise.
- Unsupported archives fail by name rather than emitting partial output: encrypted members,
zip64 archives, unknown compression methods, crc mismatches and member paths that would
escape the target directory are each refused before anything is written.
- First test coverage for punk::zip (`src/tests/modules/punk/zip/`), including the
mkzip -> read round trip that verifies what punkshell writes is readable.
## [0.25.0] - 2026-07-26
- make.tcl host/target platform split (G-122): everything a bake EMITS is now keyed by the

4
GOALS-archive.md

@ -198,3 +198,7 @@ Acceptance: from the pinned zig and fetched sources the suite produces a Tk 8.6
### G-122 [achieved 2026-07-26] make.tcl host/target platform split: bake by target, not host personality → detail: goals/archive/G-122-host-target-platform-split.md
Scope: src/make.tcl (target-platform derivation; store folder, exe suffixing, kit naming, presence checks and process-sweep tooling keyed by target; platform-canon inline copy; zipfs-less zip-type assembly fallback); src/modules/punk/platform-999999.0a1.0.tm (stable canon tags for cygwin-family hosts, help platforms doc); src/runtime/mapvfs.config (per-entry target platform via minimal compatible extension - format ownership coordinated with G-024); bin/runtime/<platform>/ store tiers (as consumed; fixture tier for tests); src/tests/shell/testsuites/punkexe/ (characterization)
Acceptance: an msys/cygwin-runtime tclsh driving bakelist/bake on windows reports - and for a changed kit, bakes and deploys - the same win32-x86_64 kit set with identical names and store addressing as a native tclsh, with the pre-deploy process sweep using windows tooling for win32 targets regardless of host personality; punk::platform and the make.tcl inline canon map MSYS_NT/MINGW64_NT/CYGWIN_NT/UCRT64-class hosts to stable documented tags (MSYSTEM-variance documented, no windows build number in the tag) and 'help platforms' lists them; a mapvfs entry declaring a non-default target platform lists in bakelist with its own tier's store presence and bakes to a correctly suffixed artifact, verified against a fixture store tier (a real third-party runtime is not required); a zipfs-less driving tcl (8.6) assembles a zip-type kit via runtime split + punk::zip::mkzip + concatenation (archive-start-relative offsets) and the artifact boots on a zipfs-capable runtime; the linuxulator expectation (a linux-personality tclsh on FreeBSD 14/15 addresses the linux-x86_64 store unchanged) is recorded here with verification deferred to the planned linux-kit-on-FreeBSD trial; native-windows behaviour stays characterization-stable (existing punkexe maketcl tests pass unchanged).
### G-124 [achieved 2026-07-26] Dependency-free zip reading in punk::zip: archives, runtime-prefixed executables, and a scripter-facing introspection surface → detail: goals/archive/G-124-punkzip-reader.md
Scope: src/modules/punk/zip-999999.0a1.0.tm (reader + introspection procs, base-offset derivation factored out of extract_preamble for shared use, buildversion); src/make.tcl (zipfs-less kit extraction path as first consumer - the zipfile::decode requirement is removed, not made optional); src/tests/modules/punk/zip/ (new testsuite - mkzip round-trip, prefixed-archive cases, introspection, unsupported-input behaviour); bootsupport + src/vfs/_vfscommon.vfs copies via established sync channels
Acceptance: punk::zip extracts to a target directory from (a) a plain zip, (b) an executable-prefixed archive with archive-relative offsets and (c) one with file-relative offsets - bin/runtime/win32-x86_64/tclsh90b4_piperepl.exe is the recorded (c) case, on which the current split-then-tcllib path fails with "Bad zip file. Bad closure." - producing CRC-verified byte-identical members with directory entries materialized as directories, on a tclsh with no zipfs, no vfs::zip and no tcllib reachable (the interpreter used is recorded here); an introspection call lists members without extracting, returning per-entry name, size, compressed size, method, mtime, crc and directory-vs-file, over the same three input shapes, and both surfaces carry punk::args definitions with worked examples so 'i punk::zip::<proc>' documents them; unsupported inputs (zip64, encrypted, unknown compression method) fail naming the reason rather than emitting partial or garbled output; make.tcl's zipfs-less kit extraction consumes the new surface and NO zipfile::decode requirement remains anywhere in the shipped tree, verified by a tree grep, and demonstrated by an 8.6 zipfs-less bake of a zip kit on a tcllib-less tclsh (msys2 /usr/bin/tclsh8.6 as reference host) producing an artifact that boots with its tcl_library present; a new src/tests/modules/punk/zip/ suite covers a mkzip -> read round trip asserting names, content, directory-vs-file classification and stored attributes - recording the answer to the standing zipfs dir-misidentification question noted in punk::mix::cli and src/vfs/mkzipfix.vfs - plus the three prefixed-archive cases, the introspection output, and the unsupported-input failures; punk::zip buildversion minor-bumped with its changelog line.

4
GOALS.md

@ -390,10 +390,6 @@ Scope: src/scriptapps/punk-runtime.ps1 + src/scriptapps/bin/punk-runtime.bash +
Detail: goals/G-123-thirdparty-runtime-tiers.md
### G-124 [proposed] Dependency-free zip reading in punk::zip: archives, runtime-prefixed executables, and a scripter-facing introspection surface
Scope: src/modules/punk/zip-999999.0a1.0.tm (reader + introspection procs, base-offset derivation factored out of extract_preamble for shared use, buildversion); src/make.tcl (zipfs-less kit extraction path as first consumer - the zipfile::decode requirement is removed, not made optional); src/tests/modules/punk/zip/ (new testsuite - mkzip round-trip, prefixed-archive cases, introspection, unsupported-input behaviour); bootsupport + src/vfs/_vfscommon.vfs copies via established sync channels
Detail: goals/G-124-punkzip-reader.md
### G-125 [proposed] A kit that cannot boot is not deployed: extraction failure gates the bake instead of warning past it
Scope: src/make.tcl (kit extraction outcome handling, deploy step gating, the no-extraction BUILD-WARNING as the current behaviour being replaced); src/tests/shell/testsuites/punkexe/ (characterization of the gate); src/AGENTS.md (bake failure-mode documentation)
Detail: goals/G-125-unbootable-kit-deploy-gate.md

13
goals/G-034-modpod-codeinterp-tcl86.md

@ -7,7 +7,12 @@ Acceptance: `dev module.templates` in an interactive 8.6 punk shell (`shell` sub
## Notes
- Related: G-124 - a dependency-free zip READER in punk::zip (no zipfs, no vfs::zip, no
tcllib) is a concrete candidate for the "or use an alternative modpod mount path there"
branch of this goal's Acceptance: extract the modpod rather than mount it. Recorded
2026-07-26 at G-124's drafting; it does not narrow this goal's options.
- Related: G-124 (achieved 2026-07-26 - see goals/archive/G-124-punkzip-reader.md) - the
dependency-free zip READER in punk::zip (no zipfs, no vfs::zip, no tcllib) now exists as
`punk::zip::members` / `punk::zip::unzip`, and is a concrete candidate for the "or use an
alternative modpod mount path there" branch of this goal's Acceptance: extract the modpod
rather than mount it. It does not narrow this goal's options. Measured while achieving
it: on msys2's tclsh8.6 `make.tcl` cannot mount its OWN `punk::mix::templates` modpod -
"Unable to load vfs::zip package to mount module templates-0.2.0 (and zipfs not available
either)", non-fatal but the package reports `broken` - which is this goal's failure in
the build tooling rather than the code interp.

12
goals/G-101-tcl86-kit-container-strategy.md

@ -45,10 +45,14 @@ unblocked; activation is the user's call.
## Notes
- Related: G-124 - an 8.6 container has no zipfs by definition, so whichever container
this goal picks needs a reader that works without it; that goal supplies the
dependency-free pure-Tcl one (and G-126 an optional accelerator). Recorded 2026-07-26
at G-124's drafting.
- Related: G-124 (achieved 2026-07-26 - see goals/archive/G-124-punkzip-reader.md) - an 8.6
container has no zipfs by definition, so whichever container this goal picks needs a
reader that works without it. That reader now exists and is verified on 8.6 with no
zipfs, no vfs::zip and no tcllib: `punk::zip::members` lists an archive and
`punk::zip::unzip` extracts it, including from an executable-prefixed image under either
offset convention (`punk::zip::archive_info` reports which). If this goal picks a
zip-shaped container, the read half is already solved (and G-126 would add an optional
accelerator).
- Related: G-116 (suite-built tcltls with a zig-built crypto backend) - the
payload question is shared: whatever container this goal picks carries the
battery set, and G-116's bi-family payload contract is generation-shaped, not

11
goals/G-125-unbootable-kit-deploy-gate.md

@ -61,8 +61,15 @@ cannot initialise at all.
## Notes
- Related: G-124 - removes the most common cause (no zip reader on a tcllib-less tclsh).
This gate is independent of it and remains valuable afterwards.
- Related: G-124 (achieved 2026-07-26 - see goals/archive/G-124-punkzip-reader.md) - removed
the most common cause: a tcllib-less tclsh can now read a runtime's attached zip, and a
bake from msys2's tclsh8.6 carries `tcl_library` into the kit instead of warning past it.
This gate is independent of that and remains valuable: the class survives (a runtime with
no attached payload, an extraction that fails for any other reason, a future container
type), and the build's response to it is still "warn and deploy". Note for the gate's
design: the reader now RAISES with a named reason (encrypted, zip64, unknown compression
method, crc mismatch, offsets that do not describe the file) where the old path returned
an opaque tcllib error, so the gate has a usable failure reason to report.
- Related: G-122 (achieved) - promoted the no-extraction notice to a recapped
BUILD-WARNING and recorded the field incident that motivates this goal; see
goals/archive/G-122-host-target-platform-split.md.

17
goals/G-126-punkzip-accelerator.md

@ -98,8 +98,21 @@ state rather than a working tree.
## Notes
- Depends on: G-124 - the pure-Tcl floor and the fixture set this goal's parity suite
reuses. This goal is additive to it and must not weaken it.
- Depends on: G-124 (achieved 2026-07-26 - see goals/archive/G-124-punkzip-reader.md) - the
pure-Tcl floor and the fixture set this goal's parity suite reuses. This goal is additive
to it and must not weaken it. What landed and what this goal must match:
`punk::zip::archive_info` / `punk::zip::members` / `punk::zip::unzip` in punk::zip 0.2.0,
with the member-dict keys the parity suite has to reproduce (name, isdirectory, size,
csize, method, methodname, mtime, dostime, crc, offset, fileoffset, flags, encrypted,
attributes, iattributes, madeby, hostsystem, version, comment) and the fixture set now
built in `src/tests/modules/punk/zip/testsuites/zip/zipreader.test` (the three archive
shapes from one source tree, plus hand-built raw zips for the refusal cases via that
file's `rawzip` helper). Recorded pure-Tcl timing to beat: 841 members of the
file-relative `tclsh90b4_piperepl.exe` extracted crc-verified in 735ms on Tcl 9.0.3.
- Related: G-128 - back-pointer added at the G-124 archive sweep (2026-07-26), where G-124
was the pair's only recorded bridge. The overlap is direct and named in G-128's own
Scope: it is the second vendored zig tool and shares THIS goal's make.tcl tool build step
and pinned `bin/tools/zig*` toolchain, so the build step designed here has to serve both.
- Related: G-005 - zig-based build infrastructure for binary dependencies from source;
this is a small, self-contained instance of that direction and the first zig build in
the tree that is not a Tcl runtime.

11
goals/G-128-portable-pe-resource-stamping.md

@ -86,8 +86,15 @@ the parked RT_VERSION stamping wants, since the project version is known late.
by which the vendored twapi could eventually stop being load-bearing.
- Related: G-127 - a win32 kit cross-baked from a non-windows host is precisely the case
twapi cannot serve; this is what makes those kits complete rather than icon-less.
- Related: G-124 - shares the archive-relative vs file-relative offset distinction: that
goal reads a zip at a derived base offset, this one moves one and must not break it.
- Related: G-124 (achieved 2026-07-26 - see goals/archive/G-124-punkzip-reader.md) - shares
the archive-relative vs file-relative offset distinction: that goal reads a zip at a
derived base offset, this one moves one and must not break it. It leaves this goal a
ready-made instrument for both halves of that requirement:
`punk::zip::archive_info <file>` answers "is this artifact safe to move the overlay of?"
in one call (`offsetstyle` archive vs file, plus `dataoffset`), and re-reading the
stamped artifact with `punk::zip::members`/`unzip` proves the payload survived. Survey
recorded there 2026-07-26 over the whole runtime store: 15 archive-relative, 1
file-relative (tclsh90b4_piperepl.exe), 4 with no attached zip.
- Related: G-023 / G-025 / G-117 (achieved) - the parked RT_VERSION stamping this tool is
structured for would carry the project version and become a further provenance surface;
G-057's Notes already require those to be reconciled into one story.

99
goals/G-124-punkzip-reader.md → goals/archive/G-124-punkzip-reader.md

@ -1,6 +1,6 @@
# G-124 Dependency-free zip reading in punk::zip
Status: proposed
Status: achieved 2026-07-26
Scope: src/modules/punk/zip-999999.0a1.0.tm (reader + introspection procs, base-offset derivation factored out of extract_preamble for shared use, buildversion); src/make.tcl (zipfs-less kit extraction path as first consumer - the zipfile::decode requirement is removed, not made optional); src/tests/modules/punk/zip/ (new testsuite - mkzip round-trip, prefixed-archive cases, introspection, unsupported-input behaviour); bootsupport + src/vfs/_vfscommon.vfs copies via established sync channels
Goal: punkshell can read a zip archive - including one prefixed by an executable, with either archive-relative or file-relative offsets - using only stock Tcl, and can do so as a documented utility a Tcl script programmer would reach for: list what is inside without extracting, extract all or part of it, on any tclsh with no zipfs, no vfs::zip and no tcllib. make.tcl's zipfs-less kit extraction stops depending on tcllib's zipfile::decode (absent from e.g msys2's /usr/bin/tclsh8.6) and stops depending on a split intermediate whose offsets can be wrong, and punk::zip's writer gains the round-trip verification it has never had.
Acceptance: punk::zip extracts to a target directory from (a) a plain zip, (b) an executable-prefixed archive with archive-relative offsets and (c) one with file-relative offsets - bin/runtime/win32-x86_64/tclsh90b4_piperepl.exe is the recorded (c) case, on which the current split-then-tcllib path fails with "Bad zip file. Bad closure." - producing CRC-verified byte-identical members with directory entries materialized as directories, on a tclsh with no zipfs, no vfs::zip and no tcllib reachable (the interpreter used is recorded here); an introspection call lists members without extracting, returning per-entry name, size, compressed size, method, mtime, crc and directory-vs-file, over the same three input shapes, and both surfaces carry punk::args definitions with worked examples so 'i punk::zip::<proc>' documents them; unsupported inputs (zip64, encrypted, unknown compression method) fail naming the reason rather than emitting partial or garbled output; make.tcl's zipfs-less kit extraction consumes the new surface and NO zipfile::decode requirement remains anywhere in the shipped tree, verified by a tree grep, and demonstrated by an 8.6 zipfs-less bake of a zip kit on a tcllib-less tclsh (msys2 /usr/bin/tclsh8.6 as reference host) producing an artifact that boots with its tcl_library present; a new src/tests/modules/punk/zip/ suite covers a mkzip -> read round trip asserting names, content, directory-vs-file classification and stored attributes - recording the answer to the standing zipfs dir-misidentification question noted in punk::mix::cli and src/vfs/mkzipfix.vfs - plus the three prefixed-archive cases, the introspection output, and the unsupported-input failures; punk::zip buildversion minor-bumped with its changelog line.
@ -89,6 +89,10 @@ available to any script the shell runs - and punkshell today has no answer at al
- Related: G-110 - adjacent, not a dependency: it studies caching of EXTRACTED shared
libs, where extraction today is done by zipfs/vfs::zip.
- Related: G-126 - the optional zig accelerator that sits behind this pure-Tcl floor.
- Related: G-128 (recorded at activation) - shares the archive-relative vs file-relative
offset distinction from the other side: this goal READS a zip at a derived base offset,
that one MOVES a prefixed executable's resources and must not invalidate the offsets a
reader derives.
- Related: G-125 - the deploy gate that turns an extraction failure into a failed kit
rather than a deployed-but-unbootable one. Independent of this goal, which removes the
most common cause of that failure.
@ -104,3 +108,96 @@ available to any script the shell runs - and punkshell today has no answer at al
`src/vfs/mkzipfix.vfs`'s copy records that this "confuses zipfs when reading - it
misidentifies dirs as files". The reader makes that assertable in-tree for the first
time; record the finding here.
ANSWERED 2026-07-26 (Tcl 9.0.3): the permission bits are not the problem and zipfs does
NOT misidentify punk::zip::mkzip directories. A mkzip archive mounted under zipfs reports
its directory entries as `file isdirectory` 1 / `file type` directory. What zipfs keys on
is the TRAILING SLASH, exactly as the current `cli-999999.0a1.0.tm` wording says
("Directory ident in zipfs relies on folders ending with trailing slash ... it can't use
permissions/attributes alone"); `punk::zip::walk` always emits one, so the classification
holds. The older, less precise wording survives only in frozen module snapshots under
`src/vfs/mkzipfix.vfs` and `src/vfs/project.vfs` (cli-0.3/0.3.1) - stale copies, not live
guidance. The real difference from `zipfs mkzip` is that zipfs writes NO directory
entries at all (its members carry external attributes 0x00000000 and version-made-by
0x0014); punk::zip::mkzip writes them with 0x41ff0010 (unix 0o040777 in the high word,
FAT directory bit in the low byte) and version-made-by 0x0017 (host 0 = MS-DOS/FAT).
Pinned by `zipreader_roundtrip_mkzip` (attributes) and the zipfs-gated
`zipreader_zipfs_sees_mkzip_directories`.
## Progress
2026-07-26 - implemented; every Acceptance clause verified. Detail:
- `punk::zip` 0.1.1 -> 0.2.0. New public surface: `archive_info` (geometry + offset-style
derivation), `members` (central-directory introspection, no decompression), `unzip`
(crc-verified extraction of all or part of an archive). Private machinery: `Eocd_scan` /
`Eocd_try` / `Cdir_records` / `Archive_read` / `Extract_member` plus `Dos_to_timet`,
`Decode_text`, `Is_directory_entry`, `Method_name`, `Select_members`, `Safe_member_path`,
`Member_unsupported_reason`, `Open_archive`.
- `extract_preamble` now consumes `Archive_read` instead of carrying its own scan; its
five `puts stdout` debug lines are gone. Two defects went with the refactor: it read 28
bytes of a 30-byte local file header, and it derived the file-relative base offset from
the FIRST central directory record only (its own `#todo!`). The shared walk takes the
minimum over ALL records and then validates that a `PK\3\4` really sits there.
- Offset derivation, stated once: `offsetbase = cdiroffset - diroffset`. Positive means an
external preamble with archive-relative offsets; zero means the offsets are file-relative
(or there is no preamble). `dataoffset` (the split point) is `offsetbase` in the first
case and the minimum recorded member offset in the second. Reported as `offsetstyle`
`archive` | `file` | `plain` | `none`.
- Store and deflate only, mirroring the writer's primitives (`zlib inflate` /
`zlib stream inflate`, `zlib crc32`), with the writer's 2MB whole-member-vs-streaming
threshold. TRAP found and pinned: `zlib stream inflate`'s `get` returns only what the
stream has already produced, so one `get` per `put` silently truncates a large member to
the first few chunks (measured: 327680 of 5048890 bytes on both 8.6.12 and 9.0.3). The
inner drain loop is load-bearing.
- Refusals are decided for every selected member BEFORE any file is opened, so an
unsupported archive cannot leave a half-populated directory. Encrypted (GPBF bit 0/6),
zip64 (saturated EOCD fields or per-member 0xFFFFFFFF), unknown compression method, and
member paths that escape the target are each named. crc failure deletes the partial file.
- `make.tcl`'s zipfs-less extraction now calls `punk::zip::unzip` on the ORIGINAL runtime
and no longer creates the `extracted_<runtime>.zip` intermediate. No punkshell code
requires `zipfile::decode` any more (tree grep). Two categories of hit remain and are
deliberate: tcllib's own `zip/decode.tcl` shipped as a LIBRARY inside some `.vfs`
payloads, and one guarded optional probe inside the vendored third-party
`ooxml1.10/ooxml.tcl` - neither is a punkshell requirement.
### Verification records (2026-07-26)
- Reference host for the dependency-free claim: msys2 `tclsh8.6.exe` 8.6.12
(`C:/Users/sleek/scoop/apps/msys2/current/usr/bin/tclsh8.6.exe`), probed as
zipfs ABSENT, `vfs::zip` ABSENT, `zipfile::decode` ABSENT. Full suite driven there
harness-free: 28 passed, 2 skipped (the zipfs-gated cross-checks only), 0 failed.
- `src/tests/modules/punk/zip/testsuites/zip/zipreader.test`: 30/30 under Tcl 9.0.3,
28 passed + 2 zipfs-skipped under native Tcl 8.6. Full source-tree suite after the
change: 1142 tests, 1123 passed, 18 skipped, 1 failed - `core/tcl exec-14.3`, the
documented pre-existing baseline failure.
- The three shapes, verified on real artifacts as well as built fixtures. Every runtime in
`bin/runtime/win32-x86_64` classified: 15 archive-relative, 1 file-relative
(`tclsh90b4_piperepl.exe`, dataoffset 2873856, 841 members), 4 with no zip at all (the
tclkits - starkits, not zipfs images). The recorded (c) case extracts all 841 members
crc-verified in 735ms and yields a working `tcl_library/init.tcl`; the old
split-then-tcllib path on the same file still fails with exactly
"Bad zip file. Bad closure.", reproduced this session.
- Independent cross-check: 868 members of `tclsh905.exe` extracted by punk::zip compare
byte-identical against the same archive mounted with tcl zipfs.
- Bake demonstration: `make.tcl bake -confirm 0 punk905` driven by the msys2 tclsh8.6
(host=msys-x86_64, target=win32-x86_64). The extraction produced
`src/_build/extracted_tclsh905.exe/{tcl_library,lib}` with no intermediate .zip, the kit
assembled by concatenation, and the deployed `bin/punk905.exe` boots: tcl_library at
`//zipfs:/app/tcl_library` with init.tcl, 95 encodings incl. cp437, msgcat 1.7.1,
http 2.10.2. The kit also reads ITSELF with the new reader (3642 members,
offsetstyle archive, dataoffset 2320384).
- `i punk::zip::members` / `i punk::zip::unzip` / `i punk::zip::archive_info` render
through `punk::ns::cmdhelp` with their worked examples intact.
### Findings for other goals
- G-034 / G-101: on the msys2 tclsh8.6, `make.tcl` cannot mount its own
`punk::mix::templates` modpod - "Unable to load vfs::zip package to mount module
templates-0.2.0 (and zipfs not available either)". Non-fatal (the package reports
`broken` and the build continues), and it is exactly the failure `punk::zip::unzip` can
now serve as the extract-rather-than-mount alternative.
- Unrelated make.tcl limitation surfaced while syncing bootsupport: `make.tcl bootsupport`
cannot replace a bootsupport modpod that the running make.tcl has itself mounted -
`BOOTSUPPORT module update FAILED: ... templates-0.2.0.tm (invalid argument)`. A plain
tclsh copies the identical file without complaint. Worked around by hand this session;
candidate goal material, not addressed here.

2
punkproject.toml

@ -1,4 +1,4 @@
[project]
name = "punkshell"
version = "0.25.0"
version = "0.26.0"
license = "BSD-2-Clause"

3
src/AGENTS.md

@ -74,7 +74,8 @@ Recovery after a wrong path guess:
- Kit bakes consume the punk-runtime WORKING COPIES under `bin/runtime/<platform>/`: `make.tcl bake`/`bakehouse` emit a `BUILD-WARNING:` (recapped at end of run) when a wrapped runtime's beside-toml revision is older than an `-r<N>` artifact present in the same folder - the forgot-to-switch-back guard for deliberate `punk-runtime use <old-rN>` testing excursions. Heed it before trusting freshly baked family kits; `bin/punk-runtime.cmd use <artifact-r<N>>` (`.exe` suffix optional) re-materializes the working copy.
- `tclsh src/make.tcl bakelist ?kitname ...?` (G-121) reports the kit outputs configured in `src/runtime/mapvfs.config`: kit name, kit type, runtime (with presence in the runtime store), vfs folder, and the deployed state of `bin/<kit>` vs the `src/_build` build product (`current|stale|absent|nobuild`), with anomalies (`runtime=missing`, `vfs=missing`, `rtrev=r<cur><r<max>` materialization staleness) and the cross-target marker `target=<platform>` in a trailing notes column. A nonexistent store tier is flagged loudly per tier actually referenced (header `(FOLDER MISSING)` for the default tier + stderr derivation; bake's no-runtimes exit self-diagnoses the same way). Name arguments filter the report and add a per-kit detail block (resolved store tier, target and provenance of the target, paths/sizes/mtimes). `make.tcl bake ?kitname ...?` bakes and deploys only the named kits - other kits' `_build`/`bin` artifacts and punkcheck records are untouched and the vfslibs phase narrows to the named kits' vfs folders; an unknown name errors before any build, listing the configured names; flags go before kit names (`make.tcl bake -confirm 0 punk91`). Bare `bake` processes all configured kits as before. Both surfaces consume the shared parsed-mapping helpers (`::punkboot::lib::mapvfs_*`) rather than the file format, so the G-024 config-format work swaps the reader underneath. Piped characterization: `src/tests/shell/testsuites/punkexe/maketclbakelist.test`.
- **Host vs target platform (G-122).** Everything a bake EMITS is keyed by the artifact's TARGET platform, never by the driving tclsh's personality: the `bin/runtime/<tier>` store the runtime is read from, `.exe` suffixing of runtime files and kit outputs, the presence checks behind `runtime=missing`, and whether the pre-deploy process sweep uses `tasklist`/`taskkill` or `ps`/`kill`. Host semantics - copy commands, path handling, filesystem case rules, prompts - stay keyed to the host. The default target is the host's own platform canon, EXCEPT a cygwin-family host (an msys2/cygwin-runtime tclsh, which reports `tcl_platform(platform)` `unix` on windows and canonizes as `msys-x86_64`/`cygwin-x86_64`), which targets `win32-x86_64`: such a host now drives the identical kit set, names and store addressing as a native tclsh. `tclsh src/make.tcl check` prints the derivation on one `platform (G-122): host=... target=... store=... exe-suffix=... process-tooling=...` line. A mapvfs entry may declare its own target platform (4th element - see `src/runtime/AGENTS.md`), which is then read from that platform's tier, named with that platform's executable convention, and skipped by the process sweep (its processes are not visible to this host). Traps this closes, all field-verified 2026-07-26: msys `ps` cannot see a natively-launched kit (only `tasklist` can), and msys2 rewrites arguments that look like absolute posix paths when spawning a native windows program - so `taskkill /PID <n>` arrived as `taskkill C:/<msysroot>/PID <n>`; native-windows command lines therefore go through `::punkboot::exec_nativeargs` (sets `MSYS2_ARG_CONV_EXCL=*`, inert elsewhere). Also note a cygwin-family tclsh is a POSIX Tcl: name the script the way that shell spells paths (`tclsh /c/repo/.../src/make.tcl`, not `C:/repo/...`, which it resolves relative to the cwd) - make.tcl diagnoses the windows-spelling case. Piped characterization: `src/tests/shell/testsuites/punkexe/maketclplatform.test` (the cygwin-host tests self-gate on finding and probing a real msys/cygwin tclsh; `PUNK_MSYS_TCLSH` names one explicitly).
- Zip-type kit assembly does not require zipfs in the driving tcl (G-122): with `tcl::zipfs::mkimg` present it is used as before, otherwise the image is assembled by concatenation (raw-runtime split + `punk::zip::mkzip` + append, the same `::punkboot::assemble_zipcat_image` helper the `zipcat` kit type uses). Both mount identically - tcl zipfs reads the archive with archive-start-relative offsets. Caveat for zipfs-less hosts: EXTRACTING the runtime's own attached zip (needed to carry its `tcl_library` into the kit) uses tcllib's `zipfile::decode`, which is a system package and is NOT vendored - a tclsh without tcllib (e.g msys2's `/usr/bin/tclsh8.6`) cannot take that path and the bake emits a recapped `BUILD-WARNING` that the kit will get no `tcl_library`. Heed that warning: the build still produces and deploys an artifact.
- Zip-type kit assembly does not require zipfs in the driving tcl (G-122): with `tcl::zipfs::mkimg` present it is used as before, otherwise the image is assembled by concatenation (raw-runtime split + `punk::zip::mkzip` + append, the same `::punkboot::assemble_zipcat_image` helper the `zipcat` kit type uses). Both mount identically - tcl zipfs reads the archive with archive-start-relative offsets.
- Neither is EXTRACTING the runtime's own attached zip (needed to carry its `tcl_library` into the kit): `punk::zip` >= 0.2.0 reads a zip with stock Tcl only - no zipfs, no vfs::zip, no tcllib (G-124, the former tcllib `zipfile::decode` dependency is gone). The zipfs-less bake path splits off the executable prefix with `punk::zip::extract_preamble` and then reads the MEMBERS from the original runtime with `punk::zip::unzip`, never from the split-off intermediate: a runtime whose zip offsets are file-relative (the historical `zipfs mkimg` convention - `tclsh90b4_piperepl.exe` in the store is one) splits into a .zip whose offsets still count from the removed prefix, which plain zip readers reject. Reading the whole file at the derived base offset makes the two offset conventions indistinguishable to callers; `punk::zip::archive_info <file>` reports which one a given artifact uses. Verified 2026-07-26 by baking a zip kit from msys2's `/usr/bin/tclsh8.6` (no zipfs, no vfs::zip, no tcllib) and booting the result with its `tcl_library` present.
- Use `punk make.tcl bakehouse` or `punk902z make.tcl bakehouse` inside Punk shell when building binaries through Punk (the deprecated `project` alias still maps). Driving make.tcl from a built punk executable is supported for informational/update subcommands and for kit builds of *other* kits — the kit whose deployed executable is running the build is skipped with a warning (it cannot be replaced while running, and the pre-deploy process sweep must not kill the build itself; the sweep also excludes the build's own pid in all cases). Rebuild that kit from tclsh or a different kit.
- **Punk-exe-hosted make.tcl runs in a pre-loaded interp, not a virgin one** — the kit's script-mode boot has already loaded much of the punk stack (punk, punk::lib, punk::repl, punk::console, punk::du, flagfilter, struct::set ...) and set process state (app-punkscript forces `::tcl_interactive 0` for script semantics; libunknown/packagepreference are active). Consequences make.tcl must (and now does) handle explicitly: `package require` of an already-provided package is a no-op, so anything make.tcl breaks in the interp (the accelerator-reload block forgets+destroys sha1/md5/struct::* — it re-requires what was loaded), and anything computed at package-load time (punk::repl's `::tcl_interactive` probe — the shell branch recomputes it before `repl::start`), must be restored deliberately rather than relying on later loads to re-fire. Also note the copies that run in this mode are the *kit's* pre-loaded modules, not the bootsupport snapshots make.tcl's paths would otherwise prefer — silent provenance mixing when versions diverge. When adding interp-surgery or load-time-state assumptions to make.tcl, test under both `tclsh src/make.tcl ...` and `<punkexe> src/make.tcl ...`.
- Binary images are platform-specific; build on each target platform rather than expecting a cross-platform flag.

25
src/make.tcl

@ -5937,20 +5937,22 @@ foreach vfstail $vfs_tails {
file copy -force $building_runtime $raw_runtime
}
} else {
#The driving tcl we are calling with doesn't have zipfs - can't mount
#The driving tcl we are calling with doesn't have zipfs - can't mount.
#punk::zip reads the attached archive with stock Tcl only (G-124),
#so this path needs no zipfs, no vfs::zip and no tcllib.
package require punk::zip
puts stderr "WARNING: tcl shell '[info nameofexecutable]' being used to build doesn't have zipfs - falling back to punk::zip::extract_preamble"
set extractedzipfile $buildfolder/extracted_$runtime_fullname.zip
puts stdout "tcl shell '[info nameofexecutable]' being used to build doesn't have zipfs - reading the runtime's attached archive with punk::zip"
set extractedzipfolder $buildfolder/extracted_$runtime_fullname
file delete $raw_runtime
file delete $extractedzipfile
file delete -force $extractedzipfolder
if {![catch {
punk::zip::extract_preamble $building_runtime $raw_runtime $extractedzipfile
package require zipfile::decode
zipfile::decode::open $extractedzipfile
set archiveinfo [zipfile::decode::archive]
zipfile::decode::unzip $archiveinfo $extractedzipfolder
#Split off the executable prefix - the kit assembly needs it as a
#file. The MEMBERS are then read from the ORIGINAL runtime at its
#derived base offset: a runtime whose offsets are file-relative
#splits into a .zip no plain zip reader accepts, and reading the
#whole file sidesteps that intermediate entirely.
punk::zip::extract_preamble $building_runtime $raw_runtime
punk::zip::unzip -return none -- $building_runtime $extractedzipfolder
} extracterr]} {
set extraction_done 1
set extract_kit_type $extract_kit_try
@ -5958,10 +5960,7 @@ foreach vfstail $vfs_tails {
merge_over $extractedzipfolder $targetvfs
} else {
#Do not swallow the reason: without the runtime's own
#zip contents the built kit has no tcl_library. Note
#zipfile::decode is a system tcllib package, not a
#vendored one - a tclsh without tcllib (e.g the msys2
#/usr/bin/tclsh8.6) cannot take this path at all.
#zip contents the built kit has no tcl_library.
puts stderr "zipfs-less extraction of $runtime_fullname FAILED: $extracterr"
}
}

964
src/modules/punk/zip-999999.0a1.0.tm

@ -165,9 +165,69 @@ tcl::namespace::eval punk::zip {
#}]
set s [clock format $time_t -format {%Y %m %e %k %M %S}]
scan $s {%d %d %d %d %d %d} year month day hour min sec
expr {(($year-1980) << 25) | ($month << 21) | ($day << 16)
expr {(($year-1980) << 25) | ($month << 21) | ($day << 16)
| ($hour << 11) | ($min << 5) | ($sec >> 1)}
}
#Inverse of Timet_to_dos. DOS timestamps carry no timezone, and Timet_to_dos
#formats in local time - so this scans in local time to keep the pair symmetric.
#Entries stored with an unrepresentable date (zero, or corrupt) yield 0.
proc Dos_to_timet {dosdatetime} {
set dosdate [expr {($dosdatetime >> 16) & 0xFFFF}]
set dostime [expr {$dosdatetime & 0xFFFF}]
set year [expr {(($dosdate >> 9) & 0x7F) + 1980}]
set month [expr {($dosdate >> 5) & 0x0F}]
set day [expr {$dosdate & 0x1F}]
set hour [expr {($dostime >> 11) & 0x1F}]
set minute [expr {($dostime >> 5) & 0x3F}]
set second [expr {($dostime & 0x1F) << 1}]
if {$month < 1 || $month > 12 || $day < 1 || $day > 31 || $hour > 23 || $minute > 59 || $second > 59} {
return 0
}
set stamp [format {%04d %02d %02d %02d %02d %02d} $year $month $day $hour $minute $second]
if {[catch {clock scan $stamp -format {%Y %m %d %H %M %S}} timet]} {
return 0
}
return $timet
}
#Compression method ids as they appear in a zip central directory.
#punk::zip READS 0 (store) and 8 (deflate); the rest are named so an
#unsupported archive is refused by name rather than by number.
variable methodnames
set methodnames [dict create {*}{
0 store
1 shrink
2 reduce1
3 reduce2
4 reduce3
5 reduce4
6 implode
8 deflate
9 deflate64
10 pkware-implode
12 bzip2
14 lzma
16 cmpsc
18 terse
19 lz77
20 zstd-deprecated
93 zstd
94 mp3
95 xz
96 jpeg
97 wavpack
98 ppmd
99 aes
}]
proc Method_name {method} {
variable methodnames
if {[dict exists $methodnames $method]} {
return [dict get $methodnames $method]
}
return method-$method
}
punk::args::define {
@id -id ::punk::zip::walk
@cmd -name punk::zip::walk -help\
@ -281,6 +341,314 @@ tcl::namespace::eval punk::zip {
return $result
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Archive structure - the shared reader machinery.
#
# Every reading surface (extract_preamble, archive_info, members, unzip) goes
# through Archive_read, so a plain zip and a zip attached to an executable are
# one code path, and the archive-relative vs file-relative offset convention is
# decided in exactly one place.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#Locate and validate the End Of Central Directory record.
#Scans candidate PK\5\6 signatures from the end of the file backwards - a plain
#executable can contain that byte sequence in its data, so a candidate is only
#accepted if it describes a single-disk directory that really begins with PK\1\2.
#The first pass also requires the record to end exactly at EOF (comment length
#consistent); a second pass drops that so archives with trailing junk still read.
proc Eocd_scan {chan filesize} {
set info [dict create {*}{
status nozip
reason {no end-of-central-directory record found}
eocdoffset -1
cdiroffset -1
cdirsize 0
count 0
diroffset 0
offsetbase 0
comment {}
}]
dict set info filesize $filesize
if {$filesize < 22} {
return $info
}
set tailstart [expr {$filesize < 65559 ? 0 : $filesize - 65559}]
chan seek $chan $tailstart start
set tail [read $chan]
set candidates [list]
set idx [string length $tail]
while {1} {
set p [string last "\x50\x4b\x05\x06" $tail [expr {$idx - 1}]]
if {$p < 0} {
break
}
lappend candidates [expr {$p + $tailstart}]
set idx $p
}
foreach strict {1 0} {
foreach eocdoffset $candidates {
set try [Eocd_try $chan $filesize $eocdoffset $strict]
if {[dict get $try status] ne "reject"} {
return $try
}
}
}
return $info
}
proc Eocd_try {chan filesize eocdoffset strict} {
if {$eocdoffset + 22 > $filesize} {
return [dict create status reject]
}
chan seek $chan $eocdoffset start
set rec [read $chan 22]
binary scan $rec issssiis sig disknbr cdirdisk numondisk totalnum cdirsize diroffset commentlen
set disknbr [expr {$disknbr & 0xFFFF}]
set cdirdisk [expr {$cdirdisk & 0xFFFF}]
set numondisk [expr {$numondisk & 0xFFFF}]
set totalnum [expr {$totalnum & 0xFFFF}]
set commentlen [expr {$commentlen & 0xFFFF}]
set cdirsize [expr {$cdirsize & 0xFFFFFFFF}]
set diroffset [expr {$diroffset & 0xFFFFFFFF}]
#single-disk archives only - anything else is a spanned archive or a false positive
if {$disknbr != 0 || $cdirdisk != 0 || $numondisk != $totalnum} {
return [dict create status reject]
}
if {$strict && $eocdoffset + 22 + $commentlen != $filesize} {
return [dict create status reject]
}
set info [dict create {*}{
status ok
reason {}
} filesize $filesize {*}{
} eocdoffset $eocdoffset {*}{
} cdirsize $cdirsize {*}{
} count $totalnum {*}{
} diroffset $diroffset {*}{
}]
#A saturated field means the real value lives in a zip64 record we do not read.
if {$totalnum == 0xFFFF || $cdirsize == 0xFFFFFFFF || $diroffset == 0xFFFFFFFF} {
dict set info status unsupported
dict set info reason "zip64 archive - punk::zip reads single-disk zip32 archives only"
dict set info cdiroffset -1
dict set info offsetbase 0
dict set info comment ""
return $info
}
set cdiroffset [expr {$eocdoffset - $cdirsize}]
if {$cdiroffset < 0} {
return [dict create status reject]
}
if {$totalnum > 0} {
chan seek $chan $cdiroffset start
if {[read $chan 4] ne "\x50\x4b\x01\x02"} {
return [dict create status reject]
}
} elseif {$cdirsize != 0} {
return [dict create status reject]
}
#The recorded directory offset is either archive-relative (the directory sits
#that far past a preamble) or file-relative (it IS the file position). The
#difference between where the directory actually is and where the record says
#it is therefore gives the preamble length directly; negative means neither
#reading holds and this candidate is not a real EOCD.
set offsetbase [expr {$cdiroffset - $diroffset}]
if {$offsetbase < 0} {
return [dict create status reject]
}
set comment ""
if {$commentlen > 0} {
chan seek $chan [expr {$eocdoffset + 22}] start
set comment [read $chan $commentlen]
}
dict set info cdiroffset $cdiroffset
dict set info offsetbase $offsetbase
dict set info comment $comment
return $info
}
punk::args::define {
@id -id ::punk::zip::Cdir_records
@cmd -name punk::zip::Cdir_records\
-summary\
"Parse every central directory record of an archive"\
-help\
"Walk ALL central directory file headers and return one member dict per
entry, in stored order. Sizes and offsets come from the central
directory, which sidesteps data descriptors entirely.
See punk::zip::members for the member dict keys."
@values -min 2 -max 2
chan -help "open binary channel positioned anywhere in the archive"
info -type dict -help "archive dict from Eocd_scan with status ok"
}
proc Cdir_records {chan info} {
set cdiroffset [dict get $info cdiroffset]
set cdirsize [dict get $info cdirsize]
set count [dict get $info count]
set offsetbase [dict get $info offsetbase]
if {$count == 0} {
return [list]
}
chan seek $chan $cdiroffset start
set cd [read $chan $cdirsize]
if {[string length $cd] != $cdirsize} {
error "punk::zip: central directory truncated - wanted $cdirsize bytes at $cdiroffset, got [string length $cd]"
}
set members [list]
set pos 0
for {set i 0} {$i < $count} {incr i} {
if {$pos + 46 > $cdirsize} {
error "punk::zip: central directory truncated - record [expr {$i + 1}] of $count starts beyond the directory"
}
binary scan $cd @${pos}issssssiiisssssii sig madeby version flags method dostime dosdate crc csize size namelen extralen commentlen disknbr iattr eattr offset
if {$sig != 33639248} {
error "punk::zip: bad central directory record [expr {$i + 1}] of $count - expected signature PK\\1\\2"
}
set madeby [expr {$madeby & 0xFFFF}]
set version [expr {$version & 0xFFFF}]
set flags [expr {$flags & 0xFFFF}]
set method [expr {$method & 0xFFFF}]
set dostime [expr {$dostime & 0xFFFF}]
set dosdate [expr {$dosdate & 0xFFFF}]
set namelen [expr {$namelen & 0xFFFF}]
set extralen [expr {$extralen & 0xFFFF}]
set commentlen [expr {$commentlen & 0xFFFF}]
set iattr [expr {$iattr & 0xFFFF}]
set crc [expr {$crc & 0xFFFFFFFF}]
set csize [expr {$csize & 0xFFFFFFFF}]
set size [expr {$size & 0xFFFFFFFF}]
set eattr [expr {$eattr & 0xFFFFFFFF}]
set offset [expr {$offset & 0xFFFFFFFF}]
set namestart [expr {$pos + 46}]
set rawname [string range $cd $namestart [expr {$namestart + $namelen - 1}]]
set commentstart [expr {$namestart + $namelen + $extralen}]
set rawcomment [string range $cd $commentstart [expr {$commentstart + $commentlen - 1}]]
set packedtime [expr {($dosdate << 16) | $dostime}]
set name [Decode_text $rawname $flags]
set hostsystem [expr {($madeby >> 8) & 0xFF}]
lappend members [dict create {*}{
} name $name {*}{
} isdirectory [Is_directory_entry $name $size $eattr $hostsystem] {*}{
} size $size {*}{
} csize $csize {*}{
} method $method {*}{
} methodname [Method_name $method] {*}{
} mtime [Dos_to_timet $packedtime] {*}{
} dostime $packedtime {*}{
} crc $crc {*}{
} offset $offset {*}{
} fileoffset [expr {$offsetbase + $offset}] {*}{
} flags $flags {*}{
} encrypted [expr {($flags & 0x41) != 0}] {*}{
} attributes $eattr {*}{
} iattributes $iattr {*}{
} madeby $madeby {*}{
} hostsystem $hostsystem {*}{
} version $version {*}{
} comment [Decode_text $rawcomment $flags] {*}{
}]
incr pos [expr {46 + $namelen + $extralen + $commentlen}]
}
return $members
}
#Member names and comments are utf-8 when general purpose bit 11 is set
#(punk::zip::mkzip always sets it), and cp437 otherwise.
proc Decode_text {bytes flags} {
if {$bytes eq ""} {
return ""
}
if {$flags & 0x800} {
return [encoding convertfrom utf-8 $bytes]
}
if {[catch {encoding convertfrom cp437 $bytes} decoded]} {
set decoded [encoding convertfrom iso8859-1 $bytes]
}
return $decoded
}
#A trailing slash is the portable marker; the FAT directory attribute and the
#unix S_IFDIR mode bits are accepted as secondary evidence for archives that
#store directory entries without one.
proc Is_directory_entry {name size eattr hostsystem} {
if {[string index $name end] eq "/"} {
return 1
}
if {$hostsystem == 3 && (($eattr >> 16) & 0xF000) == 0x4000} {
return 1
}
if {$size == 0 && ($eattr & 0x10)} {
return 1
}
return 0
}
punk::args::define {
@id -id ::punk::zip::Archive_read
@cmd -name punk::zip::Archive_read\
-summary\
"Read the structure of a zip archive on an open channel"\
-help\
"Derive the archive geometry and read every central directory record.
Returns a 2-element dict: 'info' (see punk::zip::archive_info for the
keys) and 'members' (see punk::zip::members).
Never raises for a file that simply is not an archive - callers decide
what a non-ok status means. Does raise for an archive whose central
directory is truncated or malformed."
@values -min 1 -max 1
chan -help "open channel - reconfigured to binary translation by this call"
}
proc Archive_read {chan} {
chan configure $chan -translation binary
chan seek $chan 0 end
set filesize [tell $chan]
set info [Eocd_scan $chan $filesize]
if {[dict get $info status] ne "ok"} {
#the whole file is preamble as far as any splitting caller is concerned
dict set info dataoffset $filesize
dict set info offsetstyle none
return [dict create info $info members {}]
}
set members [Cdir_records $chan $info]
set offsetbase [dict get $info offsetbase]
set minoffset ""
foreach m $members {
set o [dict get $m offset]
if {$minoffset eq "" || $o < $minoffset} {
set minoffset $o
}
}
if {$minoffset eq ""} {
set minoffset 0
}
if {$offsetbase > 0} {
#external preamble, archive-relative offsets: the archive begins exactly
#where its own offsets say it does
set dataoffset $offsetbase
set offsetstyle archive
} else {
#file-relative offsets (or no preamble at all): the archive begins at the
#topmost local file header the directory points to. Looking at ALL records
#rather than just the first is what makes this reliable - records are in no
#guaranteed order.
set dataoffset $minoffset
set offsetstyle [expr {$minoffset > 0 ? "file" : "plain"}]
}
dict set info dataoffset $dataoffset
dict set info offsetstyle $offsetstyle
if {[llength $members]} {
#the derivation is only trustworthy if a local file header really sits
#where the first record says it does
chan seek $chan [expr {$offsetbase + $minoffset}] start
if {[read $chan 4] ne "\x50\x4b\x03\x04"} {
dict set info status unsupported
dict set info reason "no local file header at derived archive position [expr {$offsetbase + $minoffset}] - offsets do not describe this file"
}
}
return [dict create info $info members $members]
}
#if there is an external preamble - extract that. (if there is also an internal preamble - ignore and consider part of the archive-data)
#Otherwise extract an internal preamble.
#if neither -?
@ -336,96 +704,28 @@ tcl::namespace::eval punk::zip {
}
chan seek $inzip 0 end
set insize [tell $inzip] ;#faster (including seeks) than calling out to filesystem using file size - but should be equivalent
chan seek $inzip 0 start
#only scan last 64k - cover max signature size?? review
if {$insize < 65559} {
set tailsearch_start 0
} else {
set tailsearch_start [expr {$insize - 65559}]
}
chan seek $inzip $tailsearch_start start
set scan [read $inzip]
#EOCD - End Of Central Directory record
set start_of_end [string last "\x50\x4b\x05\x06" $scan]
puts stdout "==>start_of_end: $start_of_end"
if {$start_of_end == -1} {
#no zip eocdr - consider entire file to be the zip preamble
set baseoffset $insize
} else {
set filerelative_eocd_posn [expr {$start_of_end + $tailsearch_start}]
chan seek $inzip $filerelative_eocd_posn
set cdir_record_plus [read $inzip] ;#can have trailing data
binary scan $cdir_record_plus issssiis eocd(signature) eocd(disknbr) eocd(ctrldirdisk) \
eocd(numondisk) eocd(totalnum) eocd(dirsize) eocd(diroffset) eocd(comment_len)
#rule out a false positive from within a nonzip (e.g plain exe)
#There exists for example a PK\5\6 in a plain tclsh, but it doesn't appear to be zip related.
#It doesn't seem to occur near the end - so perhaps not an issue - but we'll do some basic checks anyway
#we only support single disk - so we'll validate a bit more by requiring disknbr and ctrldirdisk to be zeros
#todo - just search for Pk\5\6\0\0\0\0 in the first place? //review
if {$eocd(disknbr) + $eocd(ctrldirdisk) != 0} {
#review - should keep searching?
#for now we assume not a zip
set baseoffset $insize
} else {
#use the central dir size to jump back tko start of central dir
#determine if diroffset is file or archive relative
set filerelative_cdir_start [expr {$filerelative_eocd_posn - $eocd(dirsize)}]
puts stdout "---> [read $inzip 4]"
if {$filerelative_cdir_start > $eocd(diroffset)} {
#'external preamble' easy case
# - ie 'archive' offset - (and one of the reasons I prefer archive-offset - it makes finding the 'prefix' easier
#though we are assuming zip offsets are not corrupted
set baseoffset [expr {$filerelative_cdir_start - $eocd(diroffset)}]
} else {
#'internal preamble' hard case
# - either no preamble - or offsets have been adjusted to be file relative.
#we could scan from top (ugly) - and with binary prefixes we could get false positives in the data that look like PK\3\4 headers
#we could either work out the format for all possible executables that could be appended (across all platforms) and understand where they end?
#or we just look for the topmost PK\3\4 header pointed to by a CDR record - and assume the CDR is complete
#step one - read all the CD records and find the highest pointed to local file record (which isn't necessarily the first - but should get us above most if not all of the zip data)
#we can't assume they're ordered in any particular way - so we in theory have to look at them all.
set baseoffset "unknown"
chan seek $inzip $filerelative_cdir_start start
#binary scan $cdir_record_plus issssiis eocd(signature) eocd(disknbr) eocd(ctrldirdisk) \
# eocd(numondisk) eocd(totalnum) eocd(dirsize) eocd(diroffset) eocd(comment_len)
#load the whole central dir into cdir
#todo! loop through all cdr file headers - find highest offset?
#tclZipfs.c just looks at first file header in Central Directory
#looking at all entries would be more robust - but we won't work harder than tclZipfs.c for now //REVIEW
set cdirdata [read $inzip $eocd(dirsize)]
binary scan $cdirdata issssssiiisssssii cdir(signature) cdir(_vermadeby) cdir(_verneeded) cdir(gpbitflag) cdir(compmethod) cdir(lastmodifiedtime) cdir(lastmodifieddate)\
cdir(uncompressedcrc32) cdir(compressedsize) cdir(uncompressedsize) cdir(filenamelength) cdir(extrafieldlength) cdir(filecommentlength) cdir(disknbr)\
cdir(internalfileattributes) cdir(externalfileatributes) cdir(relativeoffset)
#since we're in this branch - we assume cdir(relativeoffset) is from the start of the file
chan seek $inzip $cdir(relativeoffset)
#let's at least check that we landed on a local file header..
set local_file_header_beginning [read $inzip 28]; #local_file_header without the file name and extra field
binary scan $local_file_header_beginning isssssiiiss lfh(signature) lfh(_verneeded) lfh(gpbitflag) lfh(compmethod) lfh(lastmodifiedtime) lfh(lastmodifieddate)\
lfh(uncompressedcrc32) lfh(compressedsize) lfh(uncompressedsize) lfh(filenamelength) lfh(extrafieldlength)
#dec2hex 67324752 = 4034B50 = PK\3\4
puts stdout "1st local file header sig: $lfh(signature)"
if {$lfh(signature) == 67324752} {
#looks like a local file header
#use our cdir(relativeoffset) as the start of the zip-data (//review - possible embedded password + end marker preceeding this)
set baseoffset $cdir(relativeoffset)
}
}
puts stdout "filerel_cdirstart: $filerelative_cdir_start recorded_offset: $eocd(diroffset)"
}
}
puts stdout "baseoffset: $baseoffset"
#Archive_read does the whole derivation: it finds the EOCD (rejecting the
#PK\5\6 byte sequences that occur in plain executables), decides whether the
#recorded offsets are archive-relative or file-relative, and walks ALL central
#directory records to find the topmost local file header in the file-relative
#case. dataoffset is the split point either way.
#expect CDFH PK\1\2
#above the CD - we expect a bunch of PK\3\4 records - (possibly not all of them pointed to by the CDR)
#above that we expect: *possibly* a stored password with trailing marker - then the prefixed exe/script
if {![string is integer -strict $baseoffset]} {
error "unable to determine zip baseoffset of file $infile"
#above the CD - we expect a bunch of PK\3\4 records - (possibly not all of them pointed to by the CDR)
#above that we expect: *possibly* a stored password with trailing marker - then the prefixed exe/script
set info [dict get [Archive_read $inzip] info]
switch -- [dict get $info status] {
ok {
set baseoffset [dict get $info dataoffset]
}
nozip {
#no zip eocdr - consider entire file to be the zip preamble
set baseoffset $insize
}
default {
close $inzip
error "unable to determine zip baseoffset of file $infile - [dict get $info reason]"
}
}
if {$baseoffset < $insize} {
@ -435,7 +735,12 @@ tcl::namespace::eval punk::zip {
chan copy $inzip $pout -size $baseoffset
close $pout
if {$outfile_zip ne ""} {
#todo - if it was internal preamble - need to adjust offsets to fix the split off zipfile
#A file-relative archive splits into a .zip whose offsets still count
#from the removed preamble - readers that only accept a bare .zip choke
#on it. punk::zip's own reader never needs the split: point it at the
#ORIGINAL file and the derived base offset makes the shape irrelevant.
#Rewriting the offsets here stays open for callers that must hand a
#plain .zip to something else.
set zout [open $outfile_zip w]
fconfigure $zout -encoding iso8859-1 -translation binary
chan copy $inzip $zout
@ -454,6 +759,485 @@ tcl::namespace::eval punk::zip {
}
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Reading - archive_info / members / unzip
#
# Stock Tcl only: no zipfs, no vfs::zip, no tcllib. Works on a plain .zip and on
# a zip attached to an executable or script prefix, under either offset
# convention, because everything reads the ORIGINAL file at a derived base
# offset rather than a split-off intermediate.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#open + read structure + close, for the surfaces that take a filename
proc Open_archive {zipfile requirezip} {
if {![file exists $zipfile]} {
error "punk::zip: no such file '$zipfile'"
}
set chan [open $zipfile rb]
if {[catch {Archive_read $chan} arc erropts]} {
close $chan
return -options $erropts $arc
}
set info [dict get $arc info]
set status [dict get $info status]
if {$requirezip && $status ne "ok"} {
close $chan
switch -- $status {
nozip {
error "punk::zip: '$zipfile' is not a zip archive - [dict get $info reason]"
}
default {
error "punk::zip: cannot read '$zipfile' - [dict get $info reason]"
}
}
}
return [dict set arc chan $chan]
}
#glob selection shared by members and unzip. Patterns are matched against the
#member name both as stored and with any trailing slash removed, so a pattern
#like lib/* selects the lib/ directory entry as well as its contents.
proc Select_members {members globs excludes} {
set selected [list]
foreach m $members {
set name [dict get $m name]
set bare [string trimright $name /]
set matched 0
foreach g $globs {
if {[string match $g $name] || [string match $g $bare]} {
set matched 1
break
}
}
if {!$matched} {
continue
}
set excluded 0
foreach e $excludes {
if {[string match $e $name] || [string match $e $bare]} {
set excluded 1
break
}
}
if {!$excluded} {
lappend selected $m
}
}
return $selected
}
#Member names are archive data, not trusted paths: a name that escapes the target
#directory is refused rather than followed. Backslashes are normalised to forward
#slashes (some writers emit them) so the check cannot be side-stepped on windows.
proc Safe_member_path {name} {
set segments [list]
foreach seg [split [string map {\\ /} $name] /] {
if {$seg eq "" || $seg eq "."} {
continue
}
if {$seg eq ".."} {
error "punk::zip: refusing member '$name' - parent-directory segment would escape the target directory"
}
lappend segments $seg
}
if {![llength $segments]} {
error "punk::zip: refusing member '$name' - empty path"
}
if {[file pathtype [lindex $segments 0]] ne "relative"} {
error "punk::zip: refusing member '$name' - not a relative path"
}
return $segments
}
#Reasons punk::zip cannot extract a member. Checked for every selected member
#BEFORE anything is written, so an unsupported archive produces an error instead
#of a partly-populated target directory.
proc Member_unsupported_reason {m} {
if {[dict get $m encrypted]} {
return "entry is encrypted - punk::zip cannot decrypt zip entries"
}
if {[dict get $m size] == 0xFFFFFFFF || [dict get $m csize] == 0xFFFFFFFF || [dict get $m offset] == 0xFFFFFFFF} {
return "entry uses zip64 size/offset fields - punk::zip reads zip32 entries only"
}
set method [dict get $m method]
if {$method ni {0 8}} {
return "entry uses compression method $method ([dict get $m methodname]) - punk::zip reads stored (0) and deflated (8) entries only"
}
return ""
}
#Read one member's data from the archive and write it to target.
#Sizes and offsets come from the central directory, so entries written with a
#data descriptor (streamed, sizes zero in the local header) read correctly.
proc Extract_member {chan m target verify} {
set name [dict get $m name]
set fileoffset [dict get $m fileoffset]
set csize [dict get $m csize]
set size [dict get $m size]
set method [dict get $m method]
chan seek $chan $fileoffset start
set lfh [read $chan 30]
if {[string length $lfh] != 30} {
error "punk::zip: truncated local file header for member '$name' at offset $fileoffset"
}
binary scan $lfh isssssiiiss lsig lversion lflags lmethod ltime ldate lcrc lcsize lsize lnamelen lextralen
#dec2hex 67324752 = 4034B50 = PK\3\4
if {$lsig != 67324752} {
error "punk::zip: no local file header for member '$name' at offset $fileoffset - archive offsets do not describe this file"
}
set lnamelen [expr {$lnamelen & 0xFFFF}]
set lextralen [expr {$lextralen & 0xFFFF}]
chan seek $chan [expr {$fileoffset + 30 + $lnamelen + $lextralen}] start
set out [open $target wb]
set crc 0
set written 0
try {
if {$size < 0x00200000} {
#small member - one chunk, mirroring Addentry's 2MB write threshold
set data ""
if {$csize > 0} {
set data [read $chan $csize]
if {[string length $data] != $csize} {
error "punk::zip: truncated data for member '$name' - wanted $csize bytes, got [string length $data]"
}
if {$method == 8} {
set data [zlib inflate $data]
}
}
set crc [zlib crc32 $data]
set written [string length $data]
puts -nonewline $out $data
} else {
#large member - stream, to avoid holding the whole thing in memory
set strm ""
if {$method == 8} {
set strm [zlib stream inflate]
}
set remaining $csize
while {$remaining > 0} {
set want [expr {$remaining > 65536 ? 65536 : $remaining}]
set chunk [read $chan $want]
if {[string length $chunk] != $want} {
error "punk::zip: truncated data for member '$name' - archive ends mid-entry"
}
incr remaining -$want
if {$strm eq ""} {
set crc [zlib crc32 $chunk $crc]
incr written [string length $chunk]
puts -nonewline $out $chunk
continue
}
if {$remaining == 0} {
$strm put -finalize $chunk
} else {
$strm put $chunk
}
#a single get returns only what the stream has already produced -
#drain until empty or the tail of the member is silently dropped
while {1} {
set plain [$strm get 65536]
if {$plain eq ""} {
break
}
set crc [zlib crc32 $plain $crc]
incr written [string length $plain]
puts -nonewline $out $plain
}
}
if {$strm ne ""} {
$strm close
}
}
close $out
set out ""
if {$written != $size} {
error "punk::zip: size mismatch for member '$name' - central directory says $size bytes, decompressed to $written"
}
if {$verify && ($crc & 0xFFFFFFFF) != [dict get $m crc]} {
error "punk::zip: crc mismatch for member '$name' - archive records [dict get $m crc], extracted data is [expr {$crc & 0xFFFFFFFF}]"
}
} on error {result erropts} {
if {$out ne ""} {
catch {close $out}
}
#never leave unverified bytes behind
catch {file delete -- $target}
return -options $erropts $result
}
return $written
}
punk::args::define {
@id -id ::punk::zip::archive_info
@cmd -name punk::zip::archive_info\
-summary\
"Report the zip-archive geometry of a file"\
-help\
"Report where the zip archive inside a file begins and which offset
convention it uses, without extracting anything.
The file may be a plain .zip or a zip attached to an executable or script
prefix - a punk kit, a tclsh carrying a zipfs image, a zip-based .tm
modpod. This is the derivation punk::zip::members, punk::zip::unzip and
punk::zip::extract_preamble all share.
Does not raise for a file that simply is not an archive - read 'status'.
Returned dict keys:
status ok | nozip | unsupported
reason why status is not ok
filesize size of the whole file
offsetstyle plain | archive | file | none
plain - a bare zip, no prefix
archive - prefixed, offsets counted from the zip
file - prefixed, offsets counted from the file
none - no archive found
dataoffset where the zip data starts, ie the prefix length
offsetbase add this to a member's stored offset for a file position
eocdoffset file position of the end-of-central-directory record
cdiroffset file position of the central directory
cdirsize size of the central directory
count number of members
diroffset central directory offset as recorded in the archive
comment archive comment
Examples:
#which convention is this runtime using?
dict get [punk::zip::archive_info bin/punk91.exe] offsetstyle
#how big is the executable prefix?
dict get [punk::zip::archive_info bin/punk91.exe] dataoffset
#is there an archive attached at all? (status is ok when there is)
dict get [punk::zip::archive_info some.exe] status
"
@values -min 1 -max 1
zipfile -type existingfile -help\
"Path of a zip archive, or of a file with one attached"
}
proc archive_info {args} {
set argd [punk::args::parse $args withid ::punk::zip::archive_info]
set zipfile [dict get $argd values zipfile]
set arc [Open_archive $zipfile 0]
close [dict get $arc chan]
return [dict get $arc info]
}
punk::args::define {
@id -id ::punk::zip::members
@cmd -name punk::zip::members\
-summary\
"List the members of a zip archive without extracting"\
-help\
"Read the central directory of a zip archive and return one dict per
member, in stored order, without decompressing anything.
The archive may be a plain .zip or a zip attached to an executable or
script prefix - see punk::zip::archive_info. Requires only stock Tcl: no
zipfs, no vfs::zip, no tcllib.
Entries punk::zip cannot EXTRACT (encrypted, zip64, an unsupported
compression method) are still listed - the refusal belongs to
punk::zip::unzip, which names the reason.
Each member dict carries:
name member path as stored (directories keep a trailing /)
isdirectory 1 for a directory entry, else 0
size uncompressed size in bytes
csize compressed size in bytes
method compression method id (0 store, 8 deflate)
methodname symbolic name for that method
mtime modification time as a unix timestamp (0 if unusable)
dostime the packed DOS date/time exactly as stored
crc crc32 of the uncompressed data
offset local header offset as stored in the central directory
fileoffset absolute position of that local header in the file
flags general purpose bit flag
encrypted 1 if the entry is encrypted
attributes external file attributes as stored
iattributes internal file attributes as stored
madeby version-made-by field
hostsystem host system code (upper byte of madeby - 0 fat, 3 unix)
version version needed to extract
comment per-member comment
Examples:
#everything inside a kit
punk::zip::members bin/punk91.exe
#just the tcl_library scripts
punk::zip::members bin/punk91.exe tcl_library/*.tcl
#total uncompressed size
set bytes 0
foreach m [punk::zip::members some.zip] {incr bytes [dict get \$m size]}
#names only, files not directories
set names [list]
foreach m [punk::zip::members some.zip] {
if {![dict get \$m isdirectory]} {lappend names [dict get \$m name]}
}
"
@opts
-exclude -default {} -help\
"List of glob expressions matched against member names.
A member matching any of them is left out of the result."
-- -type none -optional 1 -help\
"End of options marker"
@values -min 1 -max -1
zipfile -type existingfile -help\
"Path of a zip archive, or of a file with one attached"
globs -default {*} -multiple 1 -help\
"Glob patterns matched against member names with 'string match'.
A member is included if it matches any of them.
The whole stored name is matched, so * spans path separators:
*.txt selects sub/deeper/notes.txt as well as top.txt.
Directory entries match with or without their trailing slash."
}
proc members {args} {
set argd [punk::args::parse $args withid ::punk::zip::members]
set zipfile [dict get $argd values zipfile]
set globs [dict get $argd values globs]
set excludes [dict get $argd opts -exclude]
set arc [Open_archive $zipfile 1]
close [dict get $arc chan]
return [Select_members [dict get $arc members] $globs $excludes]
}
punk::args::define {
@id -id ::punk::zip::unzip
@cmd -name punk::zip::unzip\
-summary\
"Extract a zip archive to a directory"\
-help\
"Extract all or part of a zip archive into targetdir, verifying each
member's crc32 as it is written. Directory entries are created as
directories; intermediate directories of a selected file are created
whether or not the archive stores an entry for them.
The archive may be a plain .zip or a zip attached to an executable or
script prefix - see punk::zip::archive_info. Requires only stock Tcl: no
zipfs, no vfs::zip, no tcllib.
Every selected member is checked for extractability BEFORE anything is
written, so an encrypted, zip64 or unknown-compression archive fails
naming the reason instead of leaving a half-populated directory. A
member whose path would escape targetdir is refused the same way.
Returns the list of member names extracted (see -return).
Examples:
#whole archive
punk::zip::unzip some.zip /tmp/out
#lift a runtime's tcl_library out of the executable it is attached to
punk::zip::unzip bin/punk91.exe /tmp/rt tcl_library/*
#everything except the docs, keeping timestamps
punk::zip::unzip -exclude {doc/*} -- some.zip /tmp/out
"
@opts
-exclude -default {} -help\
"List of glob expressions matched against member names.
A member matching any of them is not extracted."
-overwrite -default 1 -type boolean -help\
"Whether an existing file in targetdir may be replaced.
With -overwrite 0 an existing target is an error, raised before
anything is written."
-mtime -default 1 -type boolean -help\
"Whether to restore each member's stored modification time.
Directory times are applied after their contents are written."
-verify -default 1 -type boolean -help\
"Whether to check each extracted member against the crc32 stored in
the archive. A mismatch is an error and the partial file is removed."
-return -default list -choices {list pretty none} -help\
"list - return the extracted member names (default)
pretty - format that list for terminal display
none - return the empty string"
-- -type none -optional 1 -help\
"End of options marker"
@values -min 2 -max -1
zipfile -type existingfile -help\
"Path of a zip archive, or of a file with one attached"
targetdir -type directory -help\
"Directory to extract into. Created if it does not exist."
globs -default {*} -multiple 1 -help\
"Glob patterns matched against member names with 'string match'.
A member is extracted if it matches any of them.
The whole stored name is matched, so * spans path separators:
*.txt selects sub/deeper/notes.txt as well as top.txt.
Directory entries match with or without their trailing slash."
}
proc unzip {args} {
set argd [punk::args::parse $args withid ::punk::zip::unzip]
set zipfile [dict get $argd values zipfile]
set targetdir [dict get $argd values targetdir]
set globs [dict get $argd values globs]
set excludes [dict get $argd opts -exclude]
set overwrite [dict get $argd opts -overwrite]
set restoremtime [dict get $argd opts -mtime]
set verify [dict get $argd opts -verify]
set arc [Open_archive $zipfile 1]
set chan [dict get $arc chan]
set extracted [list]
set dirtimes [list]
try {
set selected [Select_members [dict get $arc members] $globs $excludes]
#preflight - nothing is written until every selected member is known good
set targets [list]
foreach m $selected {
set reason [Member_unsupported_reason $m]
if {$reason ne ""} {
error "punk::zip::unzip: cannot extract '[dict get $m name]' from $zipfile - $reason"
}
set target [file join $targetdir {*}[Safe_member_path [dict get $m name]]]
if {!$overwrite && !([dict get $m isdirectory]) && [file exists $target]} {
error "punk::zip::unzip: '$target' already exists and -overwrite is 0"
}
lappend targets $target
}
file mkdir $targetdir
foreach m $selected target $targets {
set name [dict get $m name]
if {[dict get $m isdirectory]} {
file mkdir $target
if {$restoremtime && [dict get $m mtime]} {
lappend dirtimes $target [dict get $m mtime]
}
} else {
file mkdir [file dirname $target]
Extract_member $chan $m $target $verify
if {$restoremtime && [dict get $m mtime]} {
catch {file mtime $target [dict get $m mtime]}
}
}
lappend extracted $name
}
} finally {
close $chan
}
#directory times last - writing their contents would have reset them
foreach {dir mtime} $dirtimes {
catch {file mtime $dir $mtime}
}
switch -exact -- [dict get $argd opts -return] {
pretty {
if {[info commands showlist] ne ""} {
return [plist -channel none extracted]
}
return $extracted
}
none {
return ""
}
default {
return $extracted
}
}
}
punk::args::define {

7
src/modules/punk/zip-buildversion.txt

@ -1,3 +1,8 @@
0.1.1
0.2.0
#First line must be a semantic version number
#all other lines are ignored.
#0.2.0 - dependency-free reading (G-124): archive_info/members/unzip read a plain zip or an
# executable-prefixed archive under either offset convention using stock Tcl only - no
# zipfs, no vfs::zip, no tcllib. Base-offset derivation factored out of extract_preamble
# into shared Archive_read machinery which walks ALL central directory records instead
# of only the first; extract_preamble's debug output to stdout removed.

1
src/tests/modules/AGENTS.md

@ -47,5 +47,6 @@ Unit tests for editable source modules under `src/modules/`, `src/modules_tcl8/`
- `punk/mix/` — punk::mix::cli tests (prune helpers, punkcheck virtual sources), punk::mix::commandset::repo fossil move/rename characterization tests (`testsuites/repo/`, FOSSIL_HOME-isolated; GAP-marked tests pin behaviour G-022 will change), punk::mix::commandset::loadedlib tests (`testsuites/loadedlib/libsearch.test`: 'dev lib.search' match semantics via -return list — wrap-glob default, =exact prefix, case rules, explicit globs, version aggregation — plus the loadedlib 0.2.0 contract: deep discovery by default (deep .tm modules found without -refresh, registration persists), -refresh = genuine re-scan (epoch incr + rediscovery picks up .tm files added to already-scanned dirs), and highlight working without the shell-global a+ alias; shared provisioned child interp sourcing the source-tree libunknown directly — see the file's ORDERING NOTE), and the MULTISHELL polyglot build machinery (`testsuites/scriptwrap/`, split 2026-07-19 per G-092 so no single file dominates the -jobs parallel floor - tests moved verbatim: `multishell.test` = scriptset wrap via the punk.multishell.cmd template with structure/LF-only pins plus platform-gated execution smoke (cmd.exe→powershell payload on windows, sh payload on unix or via the `wsllinux` capability constraint from `src/tests/testsupport/wslprobe.tcl` - staged to the WSL distro's native filesystem, G-059); `multishell_wrapverify.test` = checkfile 512-byte label validation of a fresh wrap; `multishell_wrapdeterminism.test` = byte-identical re-wrap pin; `runtimecmd_checkfile.test` = checkfile + LF contract of the committed bin/runtime.cmd; `runtimecmd_roundtrip.test` = the runtime scriptset round-trip byte-identity pin)
- `punk/lib/` — punk::lib tests (`testsuites/lib/`): range/index/parse/compat/interp_sync utilities, G-058 static-baseline seeding (`staticseed.test`: interp_sync_package_paths/snapshot_package_paths propagate a simulated ::punkboot static baseline and seed `load {} <prefix>` ifneeded mappings; no-op without a baseline), and the repl command-completeness engine (`commandcomplete.test`: punk::lib::system::incomplete pending-opener stacks - the info-complete quoting quirk progression (`set x "{*}{"` standalone vs in-proc-body), single openers, tabs, escapes, incomplete<->info-complete parity property; pre-repl-refactor characterization, see goals/G-044 detail preserve-list)
- `punk/packagepreference/` — punk::packagepreference tests (`testsuites/packagepreference/`): G-058 static-vs-bundled policy (`staticpolicy.test`: require of a baseline package triggers the index scan before resolution so a newer bundled copy wins, static beats older bundled, exact requires of bundled versions stay reachable, missing static mappings get seeded)
- `punk/zip/` — punk::zip tests (`testsuites/zip/zipreader.test`, G-124 - the module's first tests): the dependency-free reader over the three archive shapes (bare zip, executable-prefixed with archive-relative offsets, executable-prefixed with file-relative offsets) - archive_info's offsetstyle/dataoffset derivation incl. the nozip verdict for a plain binary carrying a stray PK\5\6, members' per-entry introspection (classification, sizes, method, crc, mtime, stored attributes) and glob/exclude selection, unzip's byte-identical CRC-verified extraction incl. the >2MB streaming path and partial extraction; the named refusals (encrypted, unknown compression method, zip64, crc mismatch, path escape) each asserting that no partial output is left behind; and the mkzip->read round trip - the first coverage that what punkshell WRITES is readable. Runs entirely on stock Tcl (verified on msys2's tclsh8.6 with no zipfs, no vfs::zip and no tcllib); two zipfs-gated tests cross-check against a real zipfs mount and record the answer to the standing directory-classification question (zipfs keys on the trailing slash, not the stored permission bits - so punk::zip::mkzip directories mount as directories). The real-runtime pin self-gates on `bin/runtime/win32-x86_64/tclsh90b4_piperepl.exe`, which is untracked
- `punk/libunknown/` — .tm same-version shadowing pin-tests (`testsuites/shadowing/`): tcl::tm::add prepend rule, head-of-tm-list wins exact-version ties, version beats order, punk::libunknown parity — shipped behaviour depends on these (runtests tm ordering, punk_main package-mode precedence, G-033); mixed .tm/pkgIndex.tcl characterization is goal G-035; discovery/epoch-cache characterization (`testsuites/discovery/discovery.test`): sibling .tm registration happens only at the requested namespace depth (deeper modules invisible to 'package names' until requested), register_all_tm deep discovery (all-depths registration, per-epoch cached no-op, head-of-tm-list precedence parity), 'package epoch' command shape, trace-driven epoch increments on tm/auto_path changes, the epoch-index short-circuit (a .tm added to an already-scanned dir needs 'package epoch incr'), and the pkgIndex.tcl sourcing-scope contract (source_pkgindex, fixed 2026-07-11 - formerly a GAP pin of the global-'dir' clobber): user global 'dir' survives pkg-unknown fallthrough, index scripts see $dir and reach the real ::auto_path (tcllib extension pattern), and their stray unqualified sets no longer leak to ::. Suite conventions learned the hard way: child probes source the SOURCE-TREE libunknown directly by path — 'package require punk::libunknown' tie-breaks the same-version copies (src/modules vs bootsupport) by tm path order, which favours bootsupport in the testinterp and machine env paths in children; and probe scripts must avoid global variable names the handlers use (notably 'dir') — that clobbering misdirected a fixture write into the real Tcl install during test development

562
src/tests/modules/punk/zip/testsuites/zip/zipreader.test

@ -0,0 +1,562 @@
package require tcltest
package require punk::zip
#added 2026-07-26 (agent, G-124) - first tests punk::zip has ever had. Covers the
#dependency-free reader (archive_info / members / unzip) over the three archive shapes,
#the mkzip->read round trip that verifies what punk::zip WRITES is readable, and the
#named refusals for inputs the reader does not support. The whole file runs on stock Tcl
#with no zipfs, no vfs::zip and no tcllib; the zipfs-gated tests are cross-checks only.
namespace eval ::testspace {
namespace import ::tcltest::*
variable BASE [makeDirectory g124_zipreader]
testConstraint zipfsavailable [expr {[info commands ::tcl::zipfs::mount] ne ""}]
#the recorded file-relative runtime named in G-124's Acceptance. Not in the repo
#(bin/ build outputs are untracked) so the pin self-gates on its presence.
variable PIPEREPL [file normalize [file join [file dirname [info script]] .. .. .. .. .. .. .. bin runtime win32-x86_64 tclsh90b4_piperepl.exe]]
testConstraint piperepl_runtime [file exists $PIPEREPL]
proc fwrite {path content} {
file mkdir [file dirname $path]
set fd [open $path wb]
puts -nonewline $fd $content
close $fd
}
proc fread {path} {
set fd [open $path rb]
set data [read $fd]
close $fd
return $data
}
#Hand-built zip writer for fixtures punk::zip::mkzip deliberately cannot produce -
#member names that escape the target, encryption flags, unknown compression methods.
#Store method only; every field is caller-controllable.
proc rawzip {path entries {prefix ""}} {
set fd [open $path wb]
puts -nonewline $fd $prefix
set base [tell $fd]
set cd ""
set count 0
foreach e $entries {
set name [dict get $e name]
set data [dict get $e content]
set method [expr {[dict exists $e method] ? [dict get $e method] : 0}]
set flags [expr {[dict exists $e flags] ? [dict get $e flags] : 0x800}]
set madeby [expr {[dict exists $e madeby] ? [dict get $e madeby] : 0x0017}]
set eattr [expr {[dict exists $e eattr] ? [dict get $e eattr] : 0}]
set crc [zlib crc32 $data]
set size [string length $data]
set at [expr {[tell $fd] - $base}]
puts -nonewline $fd [binary format a4sssiiiiss PK\03\04 20 $flags $method 0 $crc $size $size [string length $name] 0]
puts -nonewline $fd $name
puts -nonewline $fd $data
append cd [binary format a4ssssiiiisssssii PK\01\02 $madeby 20 $flags $method 0 $crc $size $size [string length $name] 0 0 0 0 $eattr $at]
append cd $name
incr count
}
set cdat [expr {[tell $fd] - $base}]
puts -nonewline $fd $cd
puts -nonewline $fd [binary format a4ssssiis PK\05\06 0 0 $count $count [string length $cd] $cdat 0]
close $fd
}
#member dicts keyed by name, for assertions that name what they look at
proc bynames {members} {
set d [dict create]
foreach m $members {
dict set d [dict get $m name] $m
}
return $d
}
proc membernames {members} {
set names [list]
foreach m $members {
lappend names [dict get $m name]
}
return [lsort $names]
}
# -- --- --- fixtures --- --- --
# One source tree, packed three ways: a plain zip, and the same archive attached to a
# binary prefix under each of the two offset conventions. The prefix deliberately
# contains a PK\5\6 byte sequence - a plain executable can, and the EOCD scan must
# not be fooled by it.
variable SRC [file join $BASE srctree]
variable PREAMBLE [file join $BASE preamble.bin]
variable PLAIN [file join $BASE plain.zip]
variable ARCREL [file join $BASE arcrel.bin]
variable FILEREL [file join $BASE filerel.bin]
variable BIGTEXT ""
file delete -force $SRC
file mkdir $SRC/sub/deeper
fwrite $SRC/hello.txt "hello world\n"
fwrite $SRC/sub/nested.tcl "puts nested\n"
#binary member - every byte value, so a translation bug cannot pass unnoticed
set fixturebytes ""
for {set i 0} {$i < 256} {incr i} {
append fixturebytes [binary format c $i]
}
fwrite $SRC/binary.dat $fixturebytes
#over the 2MB threshold Addentry/Extract_member switch to streaming
for {set i 0} {$i < 40000} {incr i} {
append BIGTEXT "line $i of a member large enough to force the streaming path\n"
}
fwrite $SRC/sub/deeper/big.txt $BIGTEXT
fwrite $PREAMBLE [string repeat "PREAMBLE-BYTES-\x00\x01\x02\x50\x4b\x05\x06" 4096]
punk::zip::mkzip -return none -directory $SRC -- $PLAIN *
punk::zip::mkzip -return none -offsettype archive -runtime $PREAMBLE -directory $SRC -- $ARCREL *
punk::zip::mkzip -return none -offsettype file -runtime $PREAMBLE -directory $SRC -- $FILEREL *
variable common {
set result [list]
}
# -- --- --- archive_info: the three shapes --- --- --
test zipreader_info_plain {a bare zip reports the plain offset style and no prefix}\
-setup $common -body {
variable PLAIN
set info [punk::zip::archive_info $PLAIN]
lappend result [dict get $info status] [dict get $info offsetstyle]\
[dict get $info dataoffset] [dict get $info offsetbase] [dict get $info count]
}\
-result {ok plain 0 0 6}
test zipreader_info_archiverelative {an executable-prefixed archive with archive-relative offsets reports the prefix length}\
-setup $common -body {
variable ARCREL ; variable PREAMBLE
set info [punk::zip::archive_info $ARCREL]
lappend result [dict get $info status] [dict get $info offsetstyle]\
[expr {[dict get $info dataoffset] == [file size $PREAMBLE]}]\
[expr {[dict get $info offsetbase] == [file size $PREAMBLE]}]
}\
-result {ok archive 1 1}
test zipreader_info_filerelative {an executable-prefixed archive with file-relative offsets reports the prefix length with a zero offset base}\
-setup $common -body {
variable FILEREL ; variable PREAMBLE
set info [punk::zip::archive_info $FILEREL]
lappend result [dict get $info status] [dict get $info offsetstyle]\
[expr {[dict get $info dataoffset] == [file size $PREAMBLE]}]\
[dict get $info offsetbase]
}\
-result {ok file 1 0}
test zipreader_info_notanarchive {a file with no archive - including a stray PK\5\6 in its data - reports nozip rather than erroring}\
-setup $common -body {
variable PREAMBLE
set info [punk::zip::archive_info $PREAMBLE]
lappend result [dict get $info status] [dict get $info offsetstyle]\
[expr {[dict get $info dataoffset] == [file size $PREAMBLE]}]
}\
-result {nozip none 1}
# -- --- --- members: introspection without extraction --- --- --
test zipreader_members_names {every member of the archive is listed, directories keeping their trailing slash}\
-setup $common -body {
variable PLAIN
membernames [punk::zip::members $PLAIN]
}\
-result {binary.dat hello.txt sub/ sub/deeper/ sub/deeper/big.txt sub/nested.tcl}
test zipreader_members_all_shapes_agree {the same archive listed through all three shapes yields identical member names}\
-setup $common -body {
variable PLAIN ; variable ARCREL ; variable FILEREL
set a [membernames [punk::zip::members $PLAIN]]
set b [membernames [punk::zip::members $ARCREL]]
set c [membernames [punk::zip::members $FILEREL]]
lappend result [expr {$a eq $b}] [expr {$a eq $c}]
}\
-result {1 1}
test zipreader_members_directory_classification {directory entries are classified as directories and file entries as files}\
-setup $common -body {
variable PLAIN
foreach m [punk::zip::members $PLAIN] {
lappend result [dict get $m name] [dict get $m isdirectory]
}
lsort -stride 2 $result
}\
-result {binary.dat 0 hello.txt 0 sub/ 1 sub/deeper/ 1 sub/deeper/big.txt 0 sub/nested.tcl 0}
test zipreader_members_stored_attributes {stored size, method, crc and mtime are reported per member}\
-setup $common -body {
variable PLAIN ; variable SRC
set m [dict get [bynames [punk::zip::members $PLAIN]] hello.txt]
lappend result [dict get $m size] [dict get $m method] [dict get $m methodname]\
[expr {[dict get $m crc] == [zlib crc32 [fread $SRC/hello.txt]]}]\
[expr {abs([dict get $m mtime] - [file mtime $SRC/hello.txt]) <= 2}]\
[dict get $m encrypted] [dict get $m hostsystem]
}\
-result {12 0 store 1 1 0 0}
test zipreader_members_deflated_entry {a member large enough to compress is stored deflated with a smaller compressed size}\
-setup $common -body {
variable PLAIN ; variable BIGTEXT
set m [dict get [bynames [punk::zip::members $PLAIN]] sub/deeper/big.txt]
lappend result [dict get $m method] [dict get $m methodname]\
[expr {[dict get $m size] == [string length $BIGTEXT]}]\
[expr {[dict get $m csize] < [dict get $m size]}]
}\
-result {8 deflate 1 1}
test zipreader_members_globs {trailing globs select members by string match against the whole name - * spans path separators - and match directory entries with or without their trailing slash}\
-setup $common -body {
variable PLAIN
lappend result [membernames [punk::zip::members $PLAIN *.txt]]
lappend result [membernames [punk::zip::members $PLAIN sub/*]]
lappend result [membernames [punk::zip::members $PLAIN sub/deeper/]]
}\
-result {{hello.txt sub/deeper/big.txt} {sub/ sub/deeper/ sub/deeper/big.txt sub/nested.tcl} sub/deeper/}
test zipreader_members_exclude {-exclude removes matching members from the listing}\
-setup $common -body {
variable PLAIN
membernames [punk::zip::members -exclude {sub/*} -- $PLAIN]
}\
-result {binary.dat hello.txt}
test zipreader_members_does_not_extract {listing an archive decompresses nothing and writes nothing}\
-setup $common -body {
variable PLAIN ; variable BASE
set before [lsort [glob -nocomplain -directory $BASE -tails *]]
set members [punk::zip::members $PLAIN]
set after [lsort [glob -nocomplain -directory $BASE -tails *]]
lappend result [expr {$before eq $after}] [llength $members]
}\
-result {1 6}
# -- --- --- unzip: extraction over the three shapes --- --- --
proc extract_and_compare {label archive} {
variable BASE ; variable SRC
set out [file join $BASE out_$label]
file delete -force $out
set extracted [punk::zip::unzip $archive $out]
set problems [list]
foreach m [punk::zip::members $archive] {
set name [dict get $m name]
if {[dict get $m isdirectory]} {
if {![file isdirectory [file join $out [string trimright $name /]]]} {
lappend problems "directory-entry-not-materialized:$name"
}
continue
}
if {[fread [file join $SRC $name]] ne [fread [file join $out $name]]} {
lappend problems "content-differs:$name"
}
}
return [list [llength $extracted] $problems]
}
test zipreader_unzip_plain {a bare zip extracts every member byte-identically with directories materialized}\
-setup $common -body {
variable PLAIN
extract_and_compare plain $PLAIN
}\
-result {6 {}}
test zipreader_unzip_archiverelative {an archive-relative prefixed archive extracts every member byte-identically}\
-setup $common -body {
variable ARCREL
extract_and_compare arcrel $ARCREL
}\
-result {6 {}}
test zipreader_unzip_filerelative {a file-relative prefixed archive extracts every member byte-identically - the shape the split-then-decode path cannot read}\
-setup $common -body {
variable FILEREL
extract_and_compare filerel $FILEREL
}\
-result {6 {}}
test zipreader_unzip_streaming_member {a member over the 2MB streaming threshold extracts complete and byte-identical}\
-setup $common -body {
variable BASE ; variable BIGTEXT ; variable PLAIN
set out [file join $BASE out_streaming]
file delete -force $out
punk::zip::unzip -return none -- $PLAIN $out sub/deeper/big.txt
set path [file join $out sub deeper big.txt]
lappend result [expr {[string length $BIGTEXT] >= 0x00200000}]\
[expr {[file size $path] == [string length $BIGTEXT]}]\
[expr {[fread $path] eq $BIGTEXT}]
}\
-result {1 1 1}
test zipreader_unzip_partial {a glob extracts just the matching members, creating the intermediate directories they need}\
-setup $common -body {
variable BASE ; variable ARCREL
set out [file join $BASE out_partial]
file delete -force $out
lappend result [punk::zip::unzip $ARCREL $out sub/nested.tcl]
lappend result [file isdirectory [file join $out sub]]
lappend result [file exists [file join $out hello.txt]]
}\
-result {sub/nested.tcl 1 0}
test zipreader_unzip_return_modes {-return none suppresses the extracted-member list}\
-setup $common -body {
variable BASE ; variable PLAIN
set out [file join $BASE out_returnmode]
file delete -force $out
lappend result [punk::zip::unzip -return none -- $PLAIN $out]
lappend result [file exists [file join $out hello.txt]]
}\
-result {{} 1}
test zipreader_unzip_overwrite_refusal {-overwrite 0 refuses before writing anything when a target exists}\
-setup $common -body {
variable BASE ; variable PLAIN
set out [file join $BASE out_overwrite]
file delete -force $out
punk::zip::unzip -return none -- $PLAIN $out
file delete -force [file join $out sub]
try {
punk::zip::unzip -overwrite 0 -- $PLAIN $out
lappend result no-error
} on error {msg} {
lappend result [string match "*already exists and -overwrite is 0*" $msg]
}
#the refusal happened in preflight, so the deleted subtree was not re-created
lappend result [file exists [file join $out sub]]
}\
-result {1 0}
test zipreader_unzip_crc_verified {a corrupted member is refused on crc and its partial file removed}\
-setup $common -body {
variable BASE
set archive [file join $BASE corrupt.zip]
rawzip $archive [list [dict create name payload.bin content "0123456789"]]
#flip a byte of the stored member data, leaving the recorded crc alone
set data [fread $archive]
set at [string first "0123456789" $data]
set data [string replace $data $at $at "X"]
fwrite $archive $data
set out [file join $BASE out_corrupt]
file delete -force $out
try {
punk::zip::unzip $archive $out
lappend result no-error
} on error {msg} {
lappend result [string match "*crc mismatch for member 'payload.bin'*" $msg]
}
lappend result [file exists [file join $out payload.bin]]
}\
-result {1 0}
# -- --- --- refusals: inputs the reader does not support --- --- --
test zipreader_refuses_encrypted {an encrypted member is listed but not extracted, naming encryption as the reason}\
-setup $common -body {
variable BASE
set archive [file join $BASE encrypted.zip]
rawzip $archive [list [dict create name secret.txt content ciphertext flags 0x801]]
lappend result [dict get [lindex [punk::zip::members $archive] 0] encrypted]
try {
punk::zip::unzip $archive [file join $BASE out_encrypted]
lappend result no-error
} on error {msg} {
lappend result [string match "*is encrypted*" $msg]
}
lappend result [file exists [file join $BASE out_encrypted]]
}\
-result {1 1 0}
test zipreader_refuses_unknown_method {a member compressed with a method punk::zip cannot read is refused by name}\
-setup $common -body {
variable BASE
set archive [file join $BASE lzma.zip]
rawzip $archive [list [dict create name payload.bin content data method 14]]
lappend result [dict get [lindex [punk::zip::members $archive] 0] methodname]
try {
punk::zip::unzip $archive [file join $BASE out_lzma]
lappend result no-error
} on error {msg} {
lappend result [string match "*compression method 14 (lzma)*" $msg]
}
lappend result [file exists [file join $BASE out_lzma]]
}\
-result {lzma 1 0}
test zipreader_refuses_zip64 {an archive whose end record defers to zip64 fields is refused by name}\
-setup $common -body {
variable BASE
set archive [file join $BASE zip64.zip]
rawzip $archive [list [dict create name payload.bin content data]]
#saturate the recorded central-directory offset - the real value would live in
#a zip64 record punk::zip does not read
set data [fread $archive]
set data [string replace $data end-5 end-2 [binary format i 0xFFFFFFFF]]
fwrite $archive $data
lappend result [dict get [punk::zip::archive_info $archive] status]
lappend result [string match "*zip64*" [dict get [punk::zip::archive_info $archive] reason]]
try {
punk::zip::members $archive
lappend result no-error
} on error {msg} {
lappend result [string match "*zip64*" $msg]
}
}\
-result {unsupported 1 1}
test zipreader_refuses_path_escape {a member whose path would escape the target directory is listed but refused on extraction}\
-setup $common -body {
variable BASE
set archive [file join $BASE escape.zip]
rawzip $archive [list [dict create name ../escape.txt content escaped]]
lappend result [dict get [lindex [punk::zip::members $archive] 0] name]
try {
punk::zip::unzip $archive [file join $BASE out_escape]
lappend result no-error
} on error {msg} {
lappend result [string match "*parent-directory segment*" $msg]
}
lappend result [file exists [file join $BASE escape.txt]]
}\
-result {../escape.txt 1 0}
test zipreader_refuses_nonarchive {listing a file that is not an archive names the reason}\
-setup $common -body {
variable PREAMBLE
try {
punk::zip::members $PREAMBLE
lappend result no-error
} on error {msg} {
lappend result [string match "*is not a zip archive*" $msg]
}
}\
-result {1}
# -- --- --- mkzip -> read round trip --- --- --
test zipreader_roundtrip_mkzip {what punk::zip::mkzip writes, punk::zip reads back: names, content, classification and stored attributes}\
-setup $common -body {
variable BASE
set src [file join $BASE rtsrc]
file delete -force $src
file mkdir $src/branch
fwrite $src/leaf.txt "leaf content\n"
fwrite $src/branch/twig.txt "twig content\n"
set archive [file join $BASE roundtrip.zip]
file delete -force $archive
punk::zip::mkzip -return none -directory $src -- $archive *
set members [bynames [punk::zip::members $archive]]
lappend result [lsort [dict keys $members]]
#mkzip stores unix-style permission bits in the high word of the external
#attributes and the FAT attribute bits in the low byte, with the host system
#byte of version-made-by left at 0 (MS-DOS/FAT)
lappend result [format 0x%08x [dict get $members branch/ attributes]]
lappend result [format 0x%08x [dict get $members leaf.txt attributes]]
lappend result [dict get $members branch/ hostsystem]
set out [file join $BASE out_roundtrip]
file delete -force $out
punk::zip::unzip -return none -- $archive $out
lappend result [file isdirectory [file join $out branch]]
lappend result [fread [file join $out leaf.txt]]
lappend result [fread [file join $out branch/twig.txt]]
}\
-result [list {branch/ branch/twig.txt leaf.txt} 0x41ff0010 0x81b60020 0 1 "leaf content\n" "twig content\n"]
test zipreader_roundtrip_empty_and_binary_members {zero-length and full-byte-range members round trip byte-identically}\
-setup $common -body {
variable BASE
set src [file join $BASE edgesrc]
file delete -force $src
file mkdir $src
fwrite $src/empty.bin ""
set bytes ""
for {set i 0} {$i < 256} {incr i} {
append bytes [binary format c $i]
}
fwrite $src/allbytes.bin $bytes
set archive [file join $BASE edges.zip]
file delete -force $archive
punk::zip::mkzip -return none -directory $src -- $archive *
set out [file join $BASE out_edges]
file delete -force $out
punk::zip::unzip -return none -- $archive $out
lappend result [file size [file join $out empty.bin]]
lappend result [expr {[fread [file join $out allbytes.bin]] eq $bytes}]
}\
-result {0 1}
# -- --- --- zipfs cross-checks (informational, gated) --- --- --
#The standing question recorded in punk::mix::cli and src/vfs/mkzipfix.vfs was whether
#the unix-style permissions punk::zip::mkzip stores make zipfs misidentify directories
#as files. Measured 2026-07-26 on Tcl 9.0.3: it does not - zipfs reports them as
#directories. See goals/G-124 for the full finding.
test zipreader_zipfs_sees_mkzip_directories {zipfs mounts a punk::zip::mkzip archive and sees its directory entries as directories}\
-constraints {zipfsavailable} -setup $common -body {
variable BASE
set src [file join $BASE zfssrc]
file delete -force $src
file mkdir $src/branch
fwrite $src/branch/twig.txt "twig\n"
fwrite $src/leaf.txt "leaf\n"
set archive [file join $BASE zipfsq.zip]
file delete -force $archive
punk::zip::mkzip -return none -directory $src -- $archive *
set mount rtq[clock clicks]
tcl::zipfs::mount $archive $mount
try {
lappend result [file isdirectory //zipfs:/$mount/branch]
lappend result [file isfile //zipfs:/$mount/branch/twig.txt]
lappend result [file isfile //zipfs:/$mount/leaf.txt]
lappend result [lsort [glob -nocomplain -tails -directory //zipfs:/$mount *]]
} finally {
catch {tcl::zipfs::unmount $mount}
}
}\
-result {1 1 1 {branch leaf.txt}}
test zipreader_matches_zipfs_contents {punk::zip and a zipfs mount of the same archive yield identical member content}\
-constraints {zipfsavailable} -setup $common -body {
variable BASE ; variable PLAIN
set out [file join $BASE out_zipfscompare]
file delete -force $out
punk::zip::unzip -return none -- $PLAIN $out
set mount rtc[clock clicks]
tcl::zipfs::mount $PLAIN $mount
try {
foreach m [punk::zip::members $PLAIN] {
if {[dict get $m isdirectory]} {
continue
}
set name [dict get $m name]
if {[fread //zipfs:/$mount/$name] ne [fread [file join $out $name]]} {
lappend result "differs:$name"
}
}
} finally {
catch {tcl::zipfs::unmount $mount}
}
set result
}\
-result {}
# -- --- --- the recorded real-world file-relative runtime --- --- --
test zipreader_piperepl_runtime {the recorded file-relative runtime reads and extracts its own tcl_library}\
-constraints {piperepl_runtime} -setup $common -body {
variable BASE ; variable PIPEREPL
set info [punk::zip::archive_info $PIPEREPL]
lappend result [dict get $info status] [dict get $info offsetstyle]
lappend result [expr {[dict get $info count] > 100}]
set out [file join $BASE out_piperepl]
file delete -force $out
punk::zip::unzip -return none -- $PIPEREPL $out tcl_library/*
lappend result [file exists [file join $out tcl_library init.tcl]]
lappend result [expr {[file size [file join $out tcl_library init.tcl]] > 1000}]
}\
-result {ok file 1 1 1}
cleanupTests
}
Loading…
Cancel
Save