diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ae4e8c8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,156 @@ +{ + "attribution": { + "commit": "", + "pr": "" + }, + "env": { + "BASH_MAX_OUTPUT_LENGTH": "16000", + "BASH_DEFAULT_TIMEOUT_MS": "300000", + "BASH_MAX_TIMEOUT_MS": "1800000", + "NO_COLOR": "1" + }, + "permissions": { + "allow": [ + "Bash(npm run *)", + "Bash(npm test*)", + "Bash(npm ls *)", + "Bash(npm pack --dry-run*)", + "Bash(npm audit*)", + "Bash(npx vitest *)", + "Bash(npx tsc *)", + "Bash(npx eslint *)", + "Bash(node scripts/*)", + "Bash(node samples/run-all.js *)", + "Bash(node dist/cli.cjs *)", + "Bash(node -e *)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git show*)", + "Bash(git describe*)", + "Bash(git grep *)", + "Bash(git ls-files*)", + "Bash(git rev-parse *)", + "Bash(git merge-base *)", + "Bash(git check-ignore *)", + "Bash(git branch --list*)", + "Bash(git branch --show-current)", + "Bash(git worktree list*)", + "Bash(ls *)", + "Bash(wc *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(sed -n *)", + "PowerShell(npm run *)", + "PowerShell(npm test*)", + "PowerShell(npm ls *)", + "PowerShell(npm pack --dry-run*)", + "PowerShell(npm audit*)", + "PowerShell(npx vitest *)", + "PowerShell(npx tsc *)", + "PowerShell(npx eslint *)", + "PowerShell(node scripts/*)", + "PowerShell(node samples/run-all.js *)", + "PowerShell(node dist/cli.cjs *)", + "PowerShell(node -e *)", + "PowerShell(git status*)", + "PowerShell(git diff*)", + "PowerShell(git log*)", + "PowerShell(git show*)", + "PowerShell(git describe*)", + "PowerShell(git grep *)", + "PowerShell(git ls-files*)", + "PowerShell(git rev-parse *)", + "PowerShell(git merge-base *)", + "PowerShell(git check-ignore *)", + "PowerShell(git branch --list*)", + "PowerShell(git branch --show-current)", + "PowerShell(git worktree list*)", + "PowerShell(ls *)", + "PowerShell(wc *)", + "PowerShell(head *)", + "PowerShell(tail *)", + "PowerShell(sed -n *)" + ], + "deny": [ + "Read(package-lock.json)", + "Read(coverage/**)", + "Read(dist/**)", + "Read(test-output/**)", + "Read(node_modules/**)", + "Read(samples/output/**)", + "Bash(npm publish*)", + "Bash(npm unpublish*)", + "Bash(npm deprecate*)", + "Bash(npm dist-tag*)", + "Bash(npm version*)", + "Bash(npx npm publish*)", + "Bash(git push --force*)", + "Bash(git push -f*)", + "Bash(git push *)", + "Bash(git push)", + "Bash(git tag -a*)", + "Bash(git tag -s*)", + "Bash(git tag -u*)", + "Bash(git tag -f*)", + "Bash(git tag -d*)", + "Bash(git tag -m*)", + "Bash(git tag --annotate*)", + "Bash(git tag --sign*)", + "Bash(git tag --force*)", + "Bash(git tag --delete*)", + "Bash(git tag v*)", + "Bash(gh pr create*)", + "Bash(gh pr edit*)", + "Bash(gh pr merge*)", + "Bash(gh pr close*)", + "Bash(gh pr comment*)", + "Bash(gh issue create*)", + "Bash(gh issue edit*)", + "Bash(gh issue close*)", + "Bash(gh issue comment*)", + "Bash(gh release *)", + "Bash(gh api *--method POST*)", + "Bash(gh api *--method PATCH*)", + "Bash(gh api *--method PUT*)", + "Bash(gh api *--method DELETE*)", + "Bash(gh api *-X *)", + "PowerShell(npm publish*)", + "PowerShell(npm unpublish*)", + "PowerShell(npm deprecate*)", + "PowerShell(npm dist-tag*)", + "PowerShell(npm version*)", + "PowerShell(npx npm publish*)", + "PowerShell(git push --force*)", + "PowerShell(git push -f*)", + "PowerShell(git push *)", + "PowerShell(git push)", + "PowerShell(git tag -a*)", + "PowerShell(git tag -s*)", + "PowerShell(git tag -u*)", + "PowerShell(git tag -f*)", + "PowerShell(git tag -d*)", + "PowerShell(git tag -m*)", + "PowerShell(git tag --annotate*)", + "PowerShell(git tag --sign*)", + "PowerShell(git tag --force*)", + "PowerShell(git tag --delete*)", + "PowerShell(git tag v*)", + "PowerShell(gh pr create*)", + "PowerShell(gh pr edit*)", + "PowerShell(gh pr merge*)", + "PowerShell(gh pr close*)", + "PowerShell(gh pr comment*)", + "PowerShell(gh issue create*)", + "PowerShell(gh issue edit*)", + "PowerShell(gh issue close*)", + "PowerShell(gh issue comment*)", + "PowerShell(gh release *)", + "PowerShell(gh api *--method POST*)", + "PowerShell(gh api *--method PATCH*)", + "PowerShell(gh api *--method PUT*)", + "PowerShell(gh api *--method DELETE*)", + "PowerShell(gh api *-X *)" + ] + } +} diff --git a/.claude/skills/release-audit/SKILL.md b/.claude/skills/release-audit/SKILL.md new file mode 100644 index 0000000..674573b --- /dev/null +++ b/.claude/skills/release-audit/SKILL.md @@ -0,0 +1,62 @@ +--- +name: release-audit +description: Pre-release audit of zipnative-cli — two parallel auditors (claims vs code and the non-negotiables; help text, the surface tables, the engine-export map, samples, veraZIP and the release surfaces), an adversarial verifier, an agent-autonomy pass and a GO/NO-GO ledger under .audit//. Run by the maintainer before every release; never invoked by the model on its own. +disable-model-invocation: true +allowed-tools: Read, Grep, Glob, Bash(npm run *), Bash(npm ls *), Bash(npx vitest *), Bash(node scripts/*), Bash(node samples/run-all.js *), Bash(node dist/cli.cjs *), Bash(node -e *), Bash(git diff*), Bash(git log*), Bash(git show*), Bash(git describe*), Bash(git grep *), Bash(git check-ignore *), Agent +argument-hint: [release-notes/vX.Y.Z.md] [previous-tag] +--- + +# Release audit + +Audit the release described by `$0` (default: the newest `release-notes/v*.md`) against everything that changed since `$1` (default: the previous `v*` tag from `git describe --tags --abbrev=0`, or the root commit for the first release). The audit produces findings, never fixes: every fix goes through the normal edit → gate loop afterwards, and the ledger records what was fixed. + +Read `ledger.md` first for the ledger and verdict formats. Each phase below hands a template to the agents it spawns; the agents return findings, you file them. + +## Ledger location + +`.audit//` — git-ignored (check `git check-ignore -v .audit//x.md` prints the rule before writing; it is NOT under `test-output/`, which Claude Code is denied to Read), so nothing here is ever committed. One Markdown file per report: brief, auditor-a, auditor-b, verifier-1, auditor-d, verifier-2, then the ledger and the verdict (formats in `ledger.md`). + +## Brief + +Before Phase A, write `.audit//brief.md`: the criteria the release is judged on, the decisions already recorded (every ADR under `docs/adr/`, the deferred items in `ROADMAP.md`, the engine-side drafts under `.github/drafts/`) and the items a previous round settled. Every agent below receives the brief with its template, so a settled item is not re-filed — a finding that shows one of those decisions is *unsafe* stays admissible, and names the decision. + +## Phase A and B — two auditors, in parallel + +Spawn both with the Agent tool in the same message (distinct angles; neither sees the other's report): + +- **Auditor A — claims vs code.** Template: `auditor-a.md`. Every claim in the release note and the top CHANGELOG entry is checked against `src/commands/`, `src/utils/`, `src/core-bridge/` and `tests/`; at least one assertion per claim is *reproduced with a command* (a test, a script, the BUILT binary `node dist/cli.cjs …`), not inferred from reading. Its brief includes the refusal posture and the non-negotiables of CLAUDE.md §Project philosophy (one runtime dependency, no ZIP parsing in `src/`, every engine import through the bridge, offline, no security default loosened, a symlink never materialised, `--deterministic` bytes never post-processed). +- **Auditor B — help text, the surface tables and the release surfaces.** Template: `auditor-b.md`. Every command's `--help` against `COMMANDS` in `src/commands/completion.ts` and the flag tables of `src/utils/flags.ts`, `schema manifest`, the engine-export map `docs/data/core-exports.json` and KB §8, `docs/data/errors.json`, the samples (`samples/run-all.js`) and the veraZIP corpus, the README, AGENTS.md and llms.txt, the publish workflow, the CHANGELOG, the release note and the governance files — every surface a human, an agent or the release pipeline reads — compared with the behaviour that actually shipped. + +Both write their report in the finding format of `ledger.md` (id, severity, claim, evidence command, observed, expected). + +## Phase C — adversarial verifier + +Spawn one verifier (template: `verifier.md`) with both reports. It re-derives every finding from scratch — re-runs the evidence command, reads the cited lines — and stamps each one `CONFIRMED | DOWNGRADED | REJECTED | DUPLICATE` with a one-line justification. Auditors have about 10 % false findings; the verifier exists to keep them out of the ledger. A finding the verifier cannot reproduce is `REJECTED`, not "probably fine". + +## Phase D — agent-autonomy pass, then verify + +**The mechanical half is `npm run gate`; do not re-derive it.** That every command of `COMMANDS` has a USAGE block naming every flag it declares, that the README and the knowledge base list the same commands and flags, that every runtime export of zipnative is mapped in `docs/data/core-exports.json` and KB §8 and re-exported by the bridge (`tests/docs/consistency.test.ts`), that `schema manifest` publishes what `--help` declares (the gate's `smoke` step), that every sample still runs (`samples`, 96 jobs) and that every archive the CLI writes is conformant (`verazip`, `verazip-validate`) is decided on every run. Cite the test or the gate step instead of re-deriving it. + +Spawn Auditor D (template: `auditor-b.md`, section "Autonomy pass") for what no test can decide: *can an agent that has only the published surfaces (`zipnative --help`, `zipnative --help`, `zipnative schema manifest | errors | `, `zipnative doctor`, the README, AGENTS.md, llms.txt and `samples/`) drive every feature the release note names without reading `src/`?* Is each sentence of the help text true, is the advice still the best advice, and after a provoked failure do `error.remedy` in the envelope and the `cli.remedy` of that code in `schema errors` tell the agent what to do next? Then a second verifier pass (template: `verifier.md`) over its findings. + +## Phase E — GO / NO-GO + +Merge the confirmed findings into the ledger, then write the verdict file (both formats are in `ledger.md`): + +- **GO** — no CONFIRMED finding of severity `blocker`; every `major` has a fix commit or an explicit maintainer waiver in the ledger. +- **NO-GO** — otherwise. List the blockers first, each with its evidence command, so the fix loop starts from the ledger, not from memory. + +Report the verdict, the counts per severity and per stamp, and the ledger path. Do not push, tag, open a PR or publish: the maintainer takes over from `GO` — squash-merges the `release/vX.Y.Z` pull request, publishes the GitHub Release (which creates the tag), and `.github/workflows/publish.yml` publishes to npm. + +## Known blind spots + +Add a check for each of these to the auditor briefs; they are where an audit of a CLI over an engine misses something. + +- Help text drifts from the tables: a flag added to `COMMANDS` (`src/commands/completion.ts`) or to `COMMAND_BOOLEAN_FLAGS` (`src/utils/flags.ts`) but described with a stale default or a false sentence in `src/index.ts` passes `tests/docs/consistency.test.ts`, which proves presence, not truth. +- The export map is generated (`npm run sync:engine`; `--check` runs in the gate only when an engine checkout is present): a map regenerated after the code but never reviewed proves consistency, not correctness. Read the `via` of each export the release adds against the command that claims it. +- `samples/run-all.js` asserts byte identity only for the deterministic and stream-parity jobs; a behaviour change on a flag no sample exercises is invisible to it. The veraZIP corpus carries no committed digest list of the previous release: byte identity with it is a comparison the maintainer prepares (`.audit//corpus-.sha256`, auditor-a.md §4). +- The non-negotiables are negative properties: no test looks everywhere for a second dependency, a `PK` signature parsed in `src/`, a socket, a symlink written to disk or a post-processed `--deterministic` byte unless one looks for it. Each needs its command (auditor-a.md §4). +- Refusal posture: a config file must not relax a check (`CONFIG_KEYS` in `src/utils/config.ts` is presentation-only), a switch value must be strict (`--flag=flase` and `ZIPNATIVE_*=flase` are usage errors), `--codec` is argv-only and a manifest needs `--allow-codec-load`. Try each from a planted `.zipnativerc.json`, the command line, the environment and a manifest task, not only from the tests. +- CI assumptions live outside the repo: Trusted Publishing needs the `npm-publish` environment, the `release: published` trigger and the npm client `publish.yml` pins by SHA-512; `tests/tools/workflows.test.ts` pins the file's shape, not the GitHub or npmjs settings. A green local gate proves nothing about the publish job. +- Auditors have ~10 % false findings — never file an unverified finding, and never let a `REJECTED` one reach the verdict. +- `.claude/settings.json` denies the shell tools `npm publish`, `git push`, `git tag` and the `gh` writes (`pr`, `issue`, `release`, `api --method POST`). An auditor never needs them: a finding is a row in a file under `.audit/`, written with Write or Edit — never a GitHub write (`.github/AGENT_RULES.md`). diff --git a/.claude/skills/release-audit/auditor-a.md b/.claude/skills/release-audit/auditor-a.md new file mode 100644 index 0000000..d953a7c --- /dev/null +++ b/.claude/skills/release-audit/auditor-a.md @@ -0,0 +1,35 @@ +# Auditor A — claims versus code + +You audit one release of zipnative-cli. Your angle is narrow on purpose: **is every claim the release makes true in the code that ships?** Another auditor covers the help text, the surface tables and the release surfaces; do not spend time there. + +## Inputs + +- The release note (`release-notes/v.md`) and the top entry of `CHANGELOG.md`. +- The brief (`.audit//brief.md`): the criteria and the decisions already recorded. A settled item is not a finding unless you show it unsafe. +- `git diff ..HEAD --stat` and the per-area diff for anything a claim points at (for a first release, the whole tree). +- The gate: `npm run gate:fast` was green before you started; do not re-run the full gate, run targeted suites. `dist/cli.cjs` is built (`npm run build` if `node dist/cli.cjs --version` fails). `npm run corpus:zip` has written the adversarial archives under `test-output/zip/` (read them only through `node dist/cli.cjs `; the directory is denied to Read). + +## Method + +1. Enumerate the claims. One line each: commands, flags, envelope fields (`code`, `zipCode`, `remedy`, `entryName`, `detail`, `config`, `diagnostics`), exit codes, the `ZIP_*` → `E_*` mapping, refusals, bounds (`--max-*`, `--max-input-size`, `--timeout`), engine (zipnative) behaviour inherited, sample or corpus changes. Number them `A-01`, `A-02`, … +2. For each claim, locate the evidence: the command module (`src/commands/.ts`), the helper (`src/utils/`), the engine symbol it reaches (`src/core-bridge/index.ts`), the test that proves it (`tests/commands/`, `tests/utils/`, `tests/integration/`, `tests/fuzz/`, `tests/docs/`, `tests/scripts/`, `tests/tools/`), the sample that demonstrates it (`samples//`). +3. **Reproduce at least one assertion per claim with a command** and paste the command and its decisive line: `npx vitest run tests/.test.ts -t ""`, `node dist/cli.cjs … --json` (read the stderr envelope and the exit code), `node samples/run-all.js --category `. A claim you could only confirm by reading is `unverified`, and says so. +4. Check the negative space — each one with a command, not a reading. The expected code of every refusal comes from `node dist/cli.cjs schema errors --json`, not from memory. + - "one runtime dependency" needs `npm ls --omit=dev --all` to list `zipnative` and nothing else, and `src/core-bridge/index.ts` to be the only module importing it (`git grep -n "from 'zipnative" src/` — `src/utils/version.ts` may read `zipnative/package.json`, nothing else). + - "no ZIP parsing in `src/`" needs `git grep -n -i -E "0x04034b50|0x02014b50|0x06054b50|PK\\x03\\x04" src/` to come back empty; the raw parsers are `scripts/validate-zip.mjs` and `tests/helpers/raw-zip-builder.ts` only. + - "offline" needs `git grep -n -E "node:(http|https|net|dns|tls|http2|dgram)|fetch\(|WebSocket" src/` to come back empty and `node scripts/gate.mjs --only bundle-check` to pass (the bundle probe denies the network built-ins). + - "no security default loosened" needs, over the corpus: `extract test-output/zip/hostile-zip-slip.zip --output-dir --json` refused with the traversal code and the directory still empty; the same for `hostile-device-name.zip`; `hostile-duplicate-paths.zip` refused under the default duplicate policy; `cat test-output/zip/neg-declared-bomb.zip --entry ` stopped by a limit; `--max-entries none` exit 2; `batch --task verify` with `--max-input-size` smaller than an input refused; `modify --help` listing no opt-out of the re-emission check and `modify` over `neg-cd-mismatch.zip` refused. + - "a symlink is never materialised" needs the symlink suites (`npx vitest run -t symlink`) and one built-binary run: an archive carrying a store-method entry with `S_IFLNK` external attributes (build it with `node -e` through the engine, or take the one `tests/helpers/raw-zip-builder.ts` builds in its suite), extracted with and without `--skip-symlinks` / `--allow-symlinks`, then `ls -l` of the output directory showing no `l` entry. + - "`--deterministic` bytes are never post-processed" needs two `create … --deterministic` runs whose SHA-256 are equal (`node -e` over both files), the pairs `npm run corpus:zip` asserts, and the archives inherited from the previous release compared against the maintainer's digest list (`.audit//corpus-.sha256`); a missing list leaves the claim `unverified`, never `holds`. + - "switch values are strict" needs `node dist/cli.cjs list x.zip --overwite` exit 2; `create -o x.zip --overwrite=flase --json` exit 2 with `E_USAGE` and the value absent from the message; `ZIPNATIVE_DRY_RUN=flase node dist/cli.cjs list x.zip` exit 2; `ZIPNATIVE_JSON=yes` behaving as `--json`. + - "a config file never relaxes a check" needs a scratch directory with a `.zipnativerc.json` setting `overwrite`, `skip-unsafe`, `max-entries`, `codec` and `allow-symlinks`, and a run of any command in it refused with `E_USAGE` naming the file and the key; then one setting only `json`, `pretty`, `quiet`, `no-color`, `progress`, `strict`, `long`, `summary`, `format`, `fields`, applied and named in the envelope's `config` field; and `--no-config` ignoring it. + - "overwrite is refused uniformly" needs `create -o existing.zip` refused without `--overwrite`, and `--overwrite` over a symlink replacing the link with its target untouched (`ls -l` before and after). + - "`--codec` is argv-only" needs `{"codec":"./x.js"}` in a config refused, and a manifest task carrying `codec` refused unless `batch --allow-codec-load` is given. + - "no autonomous GitHub write" needs `node dist/cli.cjs govern verify-issue --json` to pass on every draft under `.github/drafts/`, and `git grep -n -E "gh (pr|issue|release)" .github/workflows scripts` to show nothing that writes. +5. Check the release note's own bookkeeping: version in `package.json`, `package-lock.json` and `CITATION.cff`; the `zipnative` range in `dependencies`; `node dist/cli.cjs --version --json` printing both; one date in `CHANGELOG.md`, the release note, `CITATION.cff` and `.github/SECURITY-INSIGHTS.yml` (`last-updated`); `docs/.well-known/security.txt` `Expires` in the future; the release title. + +## Output + +Write `.audit//auditor-a.md` using the finding format of `ledger.md`. Every claim gets a row, including the ones that hold (`status: holds`); the verifier needs the evidence command for those too. Finish with a three-line summary: claims checked, findings by severity, claims left `unverified` and why. + +Do not fix anything. Do not push, tag or publish. Never put `npm publish`, `git push`, `git tag ` or a `gh` write in a shell command — `.claude/settings.json` denies them, and the audit never needs them. diff --git a/.claude/skills/release-audit/auditor-b.md b/.claude/skills/release-audit/auditor-b.md new file mode 100644 index 0000000..39f6342 --- /dev/null +++ b/.claude/skills/release-audit/auditor-b.md @@ -0,0 +1,34 @@ +# Auditor B — help text, the surface tables and the release surfaces + +You audit one release of zipnative-cli. Your angle: **does everything a reader, an agent or the release pipeline consumes describe the behaviour that actually shipped?** Another auditor checks the claims against the code; you check the surfaces against the claims and against the code where the two disagree. The brief (`.audit//brief.md`) lists the decisions already recorded; a settled item is not a finding unless you show it unsafe. + +## Surfaces to cover + +| Surface | Where | What to check | +|---|---|---| +| Help text | `src/index.ts` (`_USAGE`, `GLOBAL_USAGE`), `node dist/cli.cjs --help` | Every command of `COMMANDS` (`src/commands/completion.ts`) has a help block; every flag in `COMMANDS` and `GLOBAL_FLAGS` is described, with its real default; no flag is described that `assertInvocation` refuses; every flag the release adds says what the code does | +| Completion and schema | `src/commands/completion.ts`, `src/commands/schema.ts`, `node dist/cli.cjs schema manifest --no-config`, `schema list` | The same commands and flags as `COMMANDS`; arity, repeatable flags, path flags and operands published; the schema `$id` embeds the package version; the subject count matches the docs | +| Errors and remedies | `node dist/cli.cjs schema errors --json`, `docs/data/errors.json` | Every `ZIP_*` code and diagnostic the CLI can print is listed with its `E_*` class; every `cli.remedy` says what the thrown message implies; `raisedBy` names real commands | +| Export map | `docs/data/core-exports.json`, `docs/KNOWLEDGE_BASE.md` §8 | Regenerated (`node scripts/sync-engine-data.mjs --check` when `../zipnative` is checked out, else cite `npx vitest run tests/docs/consistency.test.ts`); the row count matches the engine's export count; nothing graded `bridged` that a command could reach; each `via` names a command that really calls the export | +| Samples | `samples/`, `node samples/run-all.js --clean` | Every job passes; every command has a `.sh` and a `.ps1` pair that say the same thing; every sample the release note names exists | +| veraZIP | `npm run validate:zip`, `scripts/generate-zip-corpus.mjs` | PASS and XFAIL counts match the docs; the corpus exercises every writer the release adds; a changed corpus is declared in the CHANGELOG | +| README, AGENTS.md, llms.txt | grep `^## ` first, read by section | Every example runs as written against `dist/cli.cjs`; the command list matches `COMMANDS`; the one-dependency, offline and "no ZIP parsing" statements are true; every file llms.txt links ships in the tarball (`node scripts/package-files.mjs`) | +| Publish workflow | `.github/workflows/publish.yml` | Triggered on `release: published` (and `workflow_dispatch`) only; guard → build → publish → attest → consumer; the `npm-publish` environment on `publish` only; `id-token: write` on `publish` and `attest` only; actions pinned by SHA; the publish gate runs in `build`; SBOMs by `npm sbom` in `attest`; `npm audit signatures` in `consumer` | +| CHANGELOG and release note | `CHANGELOG.md`, `release-notes/v.md` | Same version, same date, same claims; every user-visible change in `git diff ..HEAD` is named; Keep a Changelog types only; every narrowed input sits under `### Security` with its migration; the zipnative version the release is built on is stated | +| Governance | `SECURITY.md`, `docs/.well-known/security.txt`, `.github/SECURITY-INSIGHTS.yml`, `.github/ai-governance.json`, `.github/AGENT_RULES.md` | The same reporting channel everywhere; `Expires` in the future; no statement the workflows do not back; `node dist/cli.cjs govern policy --json` equals the JSON (`tests/utils/governance-sync.test.ts`) | + +## Method + +1. Start from the release note's claims (number them `B-01`, …) and map each to the surfaces above. A claim with no surface is a finding (`major` when the feature is public). +2. For each surface, run the regenerator where one exists and diff; where none exists, read the surface and the code side by side. Quote the line numbers. +3. Reproduce at least one assertion per surface with a command (`node samples/run-all.js --clean`, `node scripts/sync-engine-data.mjs --check`, `npx vitest run tests/docs`, `node dist/cli.cjs --help`, a `node -e` over a JSON surface). + +## Autonomy pass (Phase D) + +When invoked for Phase D, ignore the table above and answer one question: **can an agent that has only the published surfaces use every feature without reading `src/`?** For every feature in the release note, write the command you would run from `zipnative --help`, `zipnative --help`, `zipnative schema manifest | errors | `, `zipnative doctor`, the README, AGENTS.md, llms.txt and `samples/` alone, then run it against `dist/cli.cjs` with the inputs of `tests/fixtures/interop/`, `samples/input/` and `test-output/zip/` (after `npm run corpus:zip`; read those archives only through the binary). A command that needs `src/` to get right is a finding; name the sentence that was missing. Then provoke one failure per command and check that `error.remedy` in the envelope, and the `cli.remedy` of that `zipCode` in `schema errors`, tell the agent what to do next. Measure the token cost of the loop in bytes (`--summary`, `--fields`, compact `--json` against text) on the tasks the previous review measured, so the "token gain" claim is a number, not a feeling. + +## Output + +Write `.audit//auditor-b.md` (or `auditor-d.md` for the autonomy pass) in the finding format of `ledger.md`, every row with its evidence command. Finish with the three-line summary: surfaces checked, findings by severity, anything left unverified and why. + +Do not fix anything. Do not push, tag or publish. Never put `npm publish`, `git push`, `git tag ` or a `gh` write in a shell command — `.claude/settings.json` denies them, and the audit never needs them. diff --git a/.claude/skills/release-audit/ledger.md b/.claude/skills/release-audit/ledger.md new file mode 100644 index 0000000..47f877a --- /dev/null +++ b/.claude/skills/release-audit/ledger.md @@ -0,0 +1,52 @@ +# Ledger and verdict formats + +Everything lives under `.audit//` (git-ignored). Plain Markdown, one table per file, so review and grep both work. + +## Finding format (auditors) + +One row per claim or surface, including the ones that hold: + +| id | severity | claim / surface | evidence command | observed | expected | status | +|---|---|---|---|---|---|---| +| A-03 | major | "`--overwrite=flase` is `E_USAGE`, never ON" | `node dist/cli.cjs create samples/input/text -o x.zip --overwrite=flase --json` | exit 2, `E_USAGE`, the value not echoed | exit 2 before any write | holds | +| B-07 | blocker | `list --help` describes `--range` | `node dist/cli.cjs list --help` | absent | present | finding | + +- `id` — `A-`, `B-`, `D-` (autonomy pass) or `V-` (raised by a verifier), zero-padded to two digits. +- `severity` — `blocker` (a shipped claim is false, a security default is loosened, a config key relaxes a check, a symlink is materialised, `--deterministic` bytes change, a socket opens, a public surface is wrong, a gate step or the publish job would fail), `major` (a user or an agent is misled, no test catches it), `minor` (wording, ordering, a stale example that still behaves), `note` (observation, no action). +- `evidence command` — the exact command; the verifier re-runs it verbatim. A row without one is `unverified`. +- `status` — `holds`, `finding`, `unverified`. + +## Verifier format + +The auditor table plus two columns: + +| … | stamp | justification | +|---|---|---| +| … | CONFIRMED | re-ran the command; the flag is in `COMMANDS` at src/commands/completion.ts:212 and absent from `LIST_USAGE` | +| … | DOWNGRADED → minor | real, but `tests/docs/consistency.test.ts` fails on it — cannot ship silently | +| … | REJECTED | the flag is described under the shared read options; the help block lists it | +| … | DUPLICATE of B-02 | same missing sentence, different command | + +## Ledger (`ledger.md`) + +Only `CONFIRMED` and `DOWNGRADED` findings, sorted by severity then id, with two more columns: + +| id | severity | claim / surface | evidence command | fix | waiver | +|---|---|---|---|---|---| + +- `fix` — the commit hash that resolved it, or `open`. +- `waiver` — empty, or the maintainer's one-line reason for shipping with it (only `major` and below may be waived). + +Above the table: the tally per severity and per stamp, the previous tag, the HEAD hash audited, the zipnative version the release is built on, and the date. + +## Verdict (`verdict.md`) + +``` +Verdict: GO | NO-GO +Release: v HEAD: Previous: Engine: zipnative Date: +Blockers open: Majors open: Waived: +Findings: confirmed, downgraded, rejected, duplicate +Next: +``` + +Then the open blockers, one per line, each with its evidence command. diff --git a/.claude/skills/release-audit/verifier.md b/.claude/skills/release-audit/verifier.md new file mode 100644 index 0000000..e42ed6c --- /dev/null +++ b/.claude/skills/release-audit/verifier.md @@ -0,0 +1,29 @@ +# Verifier — adversarial re-derivation + +You receive one or two auditor reports for a zipnative-cli release. Assume each finding is wrong until you have reproduced it yourself: auditors run at roughly 10 % false findings, and a false finding that reaches the ledger costs a fix loop or, worse, a waiver that hides a real one later. + +## Method, per finding + +1. Re-run the evidence command exactly as written. If it is missing, the finding is `REJECTED` (reason: no evidence) — do not invent one. +2. Read the cited lines yourself (`Read` with the offset, or `git show HEAD:`), and the surrounding context the auditor may have skipped. +3. Decide the stamp: + - `CONFIRMED` — the command and the lines say what the auditor says; severity stands. + - `DOWNGRADED` — real, but the severity is too high (state the new one and why: not user-visible, already covered by a test, cosmetic). + - `REJECTED` — does not reproduce, or the auditor misread the code, the help text or the test. + - `DUPLICATE` — the same defect as another finding (name it); the earliest id keeps the row. +4. One line of justification per stamp, with the decisive observation. No paragraphs. + +## Also check + +- A finding that says "missing" for a file, a flag or a sentence: grep for it under a different name before confirming (a flag may be described in the global block of `src/index.ts`, a sentence in AGENTS.md rather than the README). +- A finding about a count or a map entry: recompute it (`node scripts/sync-engine-data.mjs --check` when `../zipnative` is checked out, otherwise `npx vitest run tests/docs/consistency.test.ts`; `node dist/cli.cjs --help` for the command count; `node dist/cli.cjs schema list` for the subjects). +- A finding about engine behaviour: call the zipnative export directly (`node -e "const z = require('zipnative'); …"` over `node_modules/zipnative`) before blaming the CLI; if the engine does the same, the finding is real but belongs to zipnative — say so in the justification and point to a draft under `.github/drafts/` (HITL: a human files it). +- A finding about bytes (a `--deterministic` pair, the corpus identity with the previous release, a reproducible build): recompute the digest yourself with `node -e` or `sha256sum`; never trust a pasted hash. +- A finding about a refusal: provoke it yourself with a fresh input of your own (a planted `.zipnativerc.json`, a hostile archive from `test-output/zip/` after `npm run corpus:zip`, a misspelled switch), so a fixture that happens to pass is not mistaken for the rule. +- A `holds` row whose evidence command you cannot make pass is a new finding; add it with the prefix `V-`. + +## Output + +Write `.audit//verifier-.md`: the auditor's table with a `stamp` and `justification` column added, then a tally per stamp and per severity. Finish with the list of `CONFIRMED` blockers, if any — that list is what Phase E reads first. + +Do not fix anything. Do not push, tag or publish. Never put `npm publish`, `git push`, `git tag ` or a `gh` write in a shell command — `.claude/settings.json` denies them, and the audit never needs them. diff --git a/.gitattributes b/.gitattributes index 9d92d7b..53fe3cf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,8 @@ samples/input/binary/** binary *.jar binary *.bin binary *.png binary + +# Generated data (scripts/sync-engine-data.mjs, scripts/package-files.mjs): collapsed in diffs +docs/data/core-exports.json linguist-generated +docs/data/errors.json linguist-generated +docs/data/package-files.json linguist-generated diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..3b54132 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,42 @@ +#!/bin/sh +# zipnative-cli — opt-in pre-commit hook (ported from pkinative-cli). +# +# Installed with `npm run hooks:install` (sets core.hooksPath to .githooks); +# removed with `npm run hooks:uninstall`. Never activated automatically. +# +# 1. Lints when a staged source file is TypeScript or an ES/CommonJS module. +# 2. Refuses a staged text file whose *staged content* contains a carriage +# return: `.gitattributes` says `* text=auto eol=lf`, so a CRLF blob in the +# index means the file was force-added or classified as binary, and it would +# reach every checkout with the wrong line endings. + +staged=$(git diff --cached --name-only --diff-filter=ACMR) +[ -z "$staged" ] && exit 0 + +if printf '%s\n' "$staged" | grep -Eq '\.(ts|mjs|cjs)$'; then + if ! npm run lint --silent; then + echo 'pre-commit: lint failed (npm run lint)' >&2 + exit 1 + fi +fi + +cr=$(printf '\r') +bad='' +for path in $staged; do + # `text: unset` marks a binary attribute (*.zip, *.bin, ...); skip those. + case "$(git check-attr text -- "$path")" in + *': text: unset') continue ;; + esac + if git show ":$path" | grep -l "$cr" >/dev/null 2>&1; then + bad="$bad + $path" + fi +done + +if [ -n "$bad" ]; then + echo "pre-commit: staged text file(s) contain CRLF line endings:$bad" >&2 + echo 'Convert to LF (your editor) and stage again.' >&2 + exit 1 +fi + +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..b2c19db --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,7 @@ +#!/bin/sh +# zipnative-cli — opt-in pre-push hook (ported from pkinative-cli). +# +# Installed with `npm run hooks:install`; removed with `npm run hooks:uninstall`. +# Runs the fast gate (typecheck:all, lint, test) so a red push is caught +# before CI. `git push --no-verify` skips it for the one push that must go. +exec node scripts/gate.mjs --fast diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fb3e014..03c31e6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,24 +4,47 @@ # Default owner for everything * @Nizoka -# CLI commands -src/commands/ @Nizoka +# The filesystem trust boundary: reads, writes, the extraction sink, signals +src/utils/io.ts @Nizoka +src/utils/sink.ts @Nizoka +src/utils/inflight.ts @Nizoka + +# The invocation contract and the configuration file (must never relax a check) +src/utils/args.ts @Nizoka +src/utils/config.ts @Nizoka +src/utils/limits.ts @Nizoka +src/utils/sizes.ts @Nizoka +src/utils/sanitize.ts @Nizoka + +# The one dynamic import of user code, the error mapping +src/utils/codecs.ts @Nizoka +src/utils/ziperr.ts @Nizoka -# Utilities and core bridge -src/utils/ @Nizoka +# The only module that imports the engine src/core-bridge/ @Nizoka -# Public entry point +# CLI commands and the entry point +src/commands/ @Nizoka src/index.ts @Nizoka -# Corpus generator + vendored veraZIP validator (raw bytes live here) +# Corpus generator, vendored veraZIP validator, gate and release checks scripts/ @Nizoka -# Documentation and samples (counts are pinned by tests/docs/) -docs/ @Nizoka -samples/ @Nizoka +# Engine-derived data, the pinned tarball list, decisions +docs/data/ @Nizoka +docs/adr/ @Nizoka + +# Policy, governance and agent configuration +SECURITY.md @Nizoka +.github/SECURITY-INSIGHTS.yml @Nizoka +.github/ai-governance.json @Nizoka +.github/AGENT_RULES.md @Nizoka # CI/CD and release .github/workflows/ @Nizoka +.github/rulesets/ @Nizoka +.github/dependabot.yml @Nizoka +.npmrc @Nizoka package.json @Nizoka +package-lock.json @Nizoka tsup.config.ts @Nizoka diff --git a/.github/SECURITY-INSIGHTS.yml b/.github/SECURITY-INSIGHTS.yml new file mode 100644 index 0000000..9f75d18 --- /dev/null +++ b/.github/SECURITY-INSIGHTS.yml @@ -0,0 +1,146 @@ +# zipnative-cli — OpenSSF Security Insights (specification 2.0.0). +# https://github.com/ossf/security-insights +# Every statement here must stay true of SECURITY.md, +# docs/.well-known/security.txt, package.json and .github/workflows/; update +# this file in the same change as any of them. +header: + schema-version: 2.0.0 + last-updated: '2026-10-20' + last-reviewed: '2026-10-20' + url: https://raw.githubusercontent.com/Nizoka/zipnative-cli/main/.github/SECURITY-INSIGHTS.yml + comment: >- + Every statement here can be read in the repository: SECURITY.md, + docs/.well-known/security.txt, package.json and the workflows under + .github/workflows/. + +project: + name: zipnative-cli + homepage: https://github.com/Nizoka/zipnative-cli + administrators: + - name: Nizoka + primary: true + social: https://github.com/Nizoka + repositories: + - name: zipnative-cli + url: https://github.com/Nizoka/zipnative-cli + comment: The command-line interface, its tests, its samples and its veraZIP conformance gate. + - name: zipnative + url: https://github.com/Nizoka/zipnative + comment: The ZIP engine the CLI wraps, its single runtime dependency, where every byte of ZIP structure is parsed and bounded. + vulnerability-reporting: + reports-accepted: true + bug-bounty-available: false + contact: + name: Nizoka + primary: true + social: https://github.com/Nizoka + security-policy: https://github.com/Nizoka/zipnative-cli/blob/main/SECURITY.md + comment: >- + Report privately, never in a public issue, through GitHub private + vulnerability reporting: + https://github.com/Nizoka/zipnative-cli/security/advisories/new. A + vulnerability in the zipnative engine itself is reported to + https://github.com/Nizoka/zipnative/security/advisories/new. The + handling timeline and the credit are in SECURITY.md. + documentation: + detailed-guide: https://github.com/Nizoka/zipnative-cli/blob/main/README.md + code-of-conduct: https://github.com/Nizoka/zipnative-cli/blob/main/CODE_OF_CONDUCT.md + release-process: https://github.com/Nizoka/zipnative-cli/blob/main/CONTRIBUTING.md#release + signature-verification: https://github.com/Nizoka/zipnative-cli/blob/main/SECURITY.md + +repository: + url: https://github.com/Nizoka/zipnative-cli + status: active + accepts-change-request: true + # Dependabot opens the automated ones (.github/dependabot.yml). + accepts-automated-change-request: true + core-team: + - name: Nizoka + primary: true + social: https://github.com/Nizoka + license: + url: https://github.com/Nizoka/zipnative-cli/blob/main/LICENSE + expression: MIT + documentation: + contributing-guide: https://github.com/Nizoka/zipnative-cli/blob/main/CONTRIBUTING.md + security-policy: https://github.com/Nizoka/zipnative-cli/blob/main/SECURITY.md + release: + automated-pipeline: true + distribution-points: + - uri: https://www.npmjs.com/package/zipnative-cli + comment: >- + npm, published by .github/workflows/publish.yml on a published GitHub + Release, through Trusted Publishing (OIDC) with provenance. + - uri: https://github.com/Nizoka/zipnative-cli/releases + comment: >- + GitHub Releases, published by the maintainer; each one triggers the + publish workflow, which attaches the registry's tarball, the SBOMs + and their Sigstore attestation. + changelog: https://github.com/Nizoka/zipnative-cli/blob/main/CHANGELOG.md + security: + assessments: + self: + comment: >- + The release review run before every release, with coverage + thresholds (vitest.config.ts), tests of the built binary, the + blocking veraZIP ISO/IEC 21320-1 gate over a CLI-written corpus with + negative canaries, adversarial archives generated in-test, and + seeded fuzz tests of the CLI-owned parsers (tests/fuzz/, run with + the rest of the suite). There is no coverage-guided fuzzing and no + third-party audit. + evidence: https://github.com/Nizoka/zipnative-cli/blob/main/SECURITY.md + tools: + - name: CodeQL + type: SAST + rulesets: [security-extended] + integration: { adhoc: false, ci: true, release: false } + results: {} + comment: TypeScript and the GitHub Actions workflows, on every push and pull request to main and weekly (codeql.yml); its results are required by the main ruleset. + - name: zizmor + type: SAST + rulesets: [default] + integration: { adhoc: false, ci: true, release: false } + results: {} + comment: Security analysis of the workflows on every change (ci.yml, workflow lint). + - name: actionlint + type: other + rulesets: [default] + integration: { adhoc: false, ci: true, release: false } + results: {} + comment: Workflow syntax and expression checks on every change (ci.yml, workflow lint). + - name: REUSE + type: other + rulesets: [default] + integration: { adhoc: false, ci: true, release: false } + results: {} + comment: Licence and copyright of every file (REUSE.toml) on every change (ci.yml, workflow lint). + - name: veraZIP + type: other + rulesets: [ISO/IEC 21320-1:2015] + integration: { adhoc: false, ci: true, release: true } + results: {} + comment: Every archive the CLI writes, validated clause by clause by a vendored, engine-independent validator, with foreign integrity tools where the runner has them, on every change (verazip.yml) and in the publish gate (publish.yml). + - name: OSV-Scanner + type: SCA + rulesets: [default] + integration: { adhoc: true, ci: true, release: false } + results: {} + comment: The lockfile against the OSV database, weekly and on demand (audit.yml). + - name: Dependabot + type: SCA + rulesets: [default] + integration: { adhoc: false, ci: true, release: false } + results: {} + comment: Weekly update pull requests for npm and GitHub Actions, after a seven-day cooldown; the engine bumps alone (.github/dependabot.yml). + - name: Dependency Review + type: SCA + rulesets: [default] + integration: { adhoc: false, ci: true, release: false } + results: {} + comment: The dependency changes of every pull request, a required check (dependency-review.yml). + - name: Scorecard + type: other + rulesets: [default] + integration: { adhoc: false, ci: true, release: false } + results: {} + comment: OpenSSF Scorecard on every push to main and weekly (scorecard.yml). diff --git a/.github/ai-governance.json b/.github/ai-governance.json index 3c25dd0..fe3a2cc 100644 --- a/.github/ai-governance.json +++ b/.github/ai-governance.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "zipnative AI Governance Configuration", "description": "Machine-readable contract governing how AI coding agents may propose issues, contributions, and changes across the zipnative ecosystem. Agents that scan repository configuration on initialization MUST honour this file.", - "version": "1.0.0", - "spec_updated": "2026-09-03", + "version": "1.1.0", + "spec_updated": "2026-10-18", "applies_to": [ "zipnative", "zipnative-cli", @@ -16,6 +16,8 @@ "autonomous_github_writes_allowed": false, "security_default_weakening_requires_human": true, "deterministic_bytes_are_semver_major": true, + "ai_attribution_trailers_allowed": false, + "configuration_relaxes_checks": false, "required_issue_fields": [ "minimal_reproduction", "environment", @@ -76,6 +78,8 @@ "zero_dependency_policy": "README.md#zero-dependency", "anti_goals": "https://github.com/Nizoka/zipnative#what-zipnative-will-not-do", "security_defaults": "SECURITY.md", - "issue_templates": [".github/ISSUE_TEMPLATE"] + "issue_templates": [ + ".github/ISSUE_TEMPLATE" + ] } } diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9ace46c..6002e22 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ ## Overview -Official CLI companion to the `zipnative` library. Exposes fifteen commands in +Official CLI companion to the `zipnative` library. Exposes 17 commands in four groups: **Create & modify** — `create` (files/dirs/stdin/manifest → deterministic ZIP; buffered, `--stream`, `--parallel`), `modify` (append-only or `--compact` edits, no recompression); **Read & extract** — `list`, `inspect` (forensic report + `--check` @@ -20,7 +20,7 @@ ZIP bytes are hand-written only in `scripts/` (corpus canaries, vendored validat `tests/helpers/raw-zip-builder.ts` (hostile fixtures), and both are engine-independent by construction. The CLI is offline, always: no command can open a socket. -**Targets:** Node.js ≥ 22. The package is a CommonJS bin (`dist/cli.cjs`, the only build +**Targets:** Node.js `^22.17.1 || >=24.4.1` (security patch floor). The package is a CommonJS bin (`dist/cli.cjs`, the only build artefact — no ESM build, no `.d.ts`, no programmatic entry point). Ubuntu 22/24 and Windows 22/24 are blocking in CI (path separators, reserved device names, case-insensitive filesystems, CRLF); macOS 22 runs too. @@ -51,7 +51,9 @@ src/ ├── index.ts # CLI entry: parse argv → global env flags → config merge → lazy dispatch → exit ├── commands/ │ ├── create.ts # Files/dirs/stdin/manifest → createZip | addStream+stream() | createParallelZip → output -│ ├── modify.ts # Archive + edits (remove→rename→replace→add→comment) → save() | saveCompact() | --in-place +│ ├── modify.ts # Archive + edits (remove→rename→replace→add→comment) → save() | saveCompact({ canonical? }) | --in-place +│ ├── canonicalize.ts # canonicalizeZip → the canonical deterministic form (no recompression), proven by analyzeDeterminism +│ ├── merge.ts # addFromReader(…, { verify: true }) from eagerly opened sources: merge / split, never laundering │ ├── list.ts # openZip (nothing decompressed) → text table | json | ndjson rows │ ├── inspect.ts # Eager open → forensic report + determinism verdict + --check gate (E_CHECK_FAILED) │ ├── cat.ts # readEntryStream / readEntryRaw → stdout or --output; CRC verified at stream end @@ -78,11 +80,15 @@ src/ │ │ # of the nearest existing ancestor before mkdir (+ re-check) → exclusive open → unlink on failure │ ├── inflight.ts # In-flight output registry; SIGINT/SIGTERM remove exactly those files, exit 130/143 │ ├── projection.ts # Agent output projection (compact JSON, --summary, --fields dot-paths) -│ ├── config.ts # `.zipnativerc.json` discovery + flag-default merge; refuses the `codec` key +│ ├── control.ts # --timeout (one AbortController, E_LIMIT) and --progress (onProgress → stderr) +│ ├── engines.ts # Minimal engines.node range evaluator (doctor, the node-floor gate step) +│ ├── sanitize.ts # escapeTerminal / escapeLine: terminal-safe TEXT output (JSON stays exact) +│ ├── rawentry.ts # create-manifest "raw" entries: decoded once, size + CRC-32 checked, then addRaw +│ ├── config.ts # `.zipnativerc.json` discovery + PRESENTATION-ONLY defaults (CONFIG_KEYS; every other key E_USAGE) │ ├── version.ts # CLI + engine version resolution (source layout and bundled dist/) │ ├── colors.ts # ANSI helper decided on stderr: NO_COLOR off, FORCE_COLOR on, TERM=dumb off, else TTY -│ ├── sizes.ts # `` (512k, 1GiB, none) and count parsing -│ ├── limits.ts # Eight --max-* flags → Partial (ZIP_LIMIT_INVALID unreachable) + --max-input-size +│ ├── sizes.ts # `` (512k, 1GiB) and count parsing — always finite (`none` refused since 1.1.0) +│ ├── limits.ts # Nine --max-* flags → Partial (ZIP_LIMIT_INVALID unreachable) + --max-input-size │ ├── diagnostics.ts # Core diagnostic sink: text warning lines | collected into the --json envelope │ ├── engine.ts # prepareEngine(): --codec modules (argv only) + node:zlib tier unless --pure-codecs │ ├── codecs.ts # `--codec ` loader — the ONLY dynamic import of user code @@ -94,7 +100,7 @@ src/ │ ├── manifest.ts # `batch --manifest` parsing: strict validation, @id refs, 1000-task cap, codec policy │ └── governance.ts # AI-governance policy + AGENT_RULES text + pure draft validator └── core-bridge/ - └── index.ts # Selective re-exports from zipnative — the ONLY import point of the engine (77 exports) + └── index.ts # Selective re-exports from zipnative — the ONLY import point of the engine (106 exports) ``` ## Entry Point Contract (`src/index.ts`) @@ -108,10 +114,14 @@ src/ (`zipnative --json`) → exit 2 "No command given". Bare `zipnative` → usage, exit 0. - Global flags are turned into env vars before dispatch: `--json` → `ZIPNATIVE_JSON=1`, `--dry-run` → `ZIPNATIVE_DRY_RUN=1`, `--quiet` → `ZIPNATIVE_QUIET=1`, `--strict` → - `ZIPNATIVE_STRICT=1`, `--pure-codecs` → `ZIPNATIVE_PURE_CODECS=1`, `--no-color` → `NO_COLOR=1`. + `ZIPNATIVE_STRICT=1`, `--pure-codecs` → `ZIPNATIVE_PURE_CODECS=1`, `--progress` → + `ZIPNATIVE_PROGRESS=1`, `--no-color` → `NO_COLOR=1`. The variables are also honoured when the caller sets them (an env-driven `--dry-run` prints no text plan under `ZIPNATIVE_JSON`). -- `.zipnativerc.json` defaults are merged unless `--no-config`; explicit flags always win. +- `.zipnativerc.json` defaults are merged unless `--no-config`; explicit flags always win. The + file is presentation only (`CONFIG_KEYS` in `utils/config.ts`, docs/adr/0001): never add an + operational key to that list — an input, an output, an opt-out, a bound or `codec` in a config + file must stay `E_USAGE`. - Commands are lazy-imported so `--help` / `--version` stay fast. - `main()` installs the process handlers once: `EPIPE` on stdout/stderr → exit 0 quietly; `SIGINT` / `SIGTERM` → remove the in-flight output files (`utils/inflight.ts`), exit 130 / 143. @@ -125,15 +135,21 @@ src/ - `parseArgs(argv: string[], { booleans }): ParsedArgs` — `booleans` is the flag table from `utils/flags.ts` (`BOOLEAN_FLAGS`, re-exported by `completion.ts`). -- `ParsedArgs = { flags: Record; positionals: string[] }` -- Supports: `--flag value`, `--flag=value`, `-f value`, `--flag` (boolean true); a flag - given twice becomes an array (`getStringFlagAll`). +- `ParsedArgs = { flags: Readonly>; positionals: string[] }` + (the record has no prototype). +- Supports: `--flag value`, `--flag=value`, `-f value`, `--flag` (boolean true); a value flag + given twice becomes an array (`getStringFlagAll`); a switch given twice, a flag both bare and + with a value, and `__proto__` / `constructor` / `prototype` are refused. Aliases are + canonicalised to their long name (`FLAG_ALIASES`) — read `'input'`, never `'i'`. - A boolean flag NEVER consumes the next token, so flags and positionals are order-independent (`--json list a.zip`, `list --long a.zip`); `--flag=false|0|no|off` is the explicit off form. A token matching `-` is always a value. Combined short flags (`-lq`) are refused with exit 2. Value short aliases: `-i -o -d -e -f`; boolean: `-q -h -V` (no `-l`). - `--` terminates flag parsing; all following tokens go into `positionals`. -- Never throws on unknown flags — they are collected as-is. +- The dispatcher runs `assertInvocation()` (`src/commands/completion.ts`) before every command + and every batch task: undeclared flags, repeated single-value flags and surplus operands are + `E_USAGE`. A new flag must be declared in `COMMANDS` (and `REPEATABLE_FLAGS` / + `OPERANDS` when relevant) or it is refused. ## Command Conventions (`src/commands/`) @@ -194,8 +210,13 @@ src/ `create --stream`) must never be routed through it. - Input JSON (manifests, drafts) is capped at 50 MB before `JSON.parse`; a `batch` manifest is capped at 1000 tasks; a captured task stdout (`batch --json`) at 64 MiB. -- `--max-*` values are pre-validated so `ZIP_LIMIT_INVALID` is unreachable; `none` disables a - bound with a visible warning. +- `--max-*` values are pre-validated so `ZIP_LIMIT_INVALID` is unreachable; every bound is a + positive, finite integer (`none` is refused since 1.1.0, docs/adr/0002). +- Text output that may quote archive bytes goes through `escapeTerminal()` + (`utils/sanitize.ts`); JSON stays exact. Never write a raw control or bidi character in a + source file — `tests/docs/source-hygiene.test.ts` fails on it; use escapes. +- Writes go through `writeOutput` / `writeFileStream` (lstat + `wx`, or temp + `rename()` under + `--overwrite`); a command never unlinks an output path itself. - `stream` parses local headers only: mode / symlink policy flags are refused with `E_USAGE`, and every JSON output carries `trust: "local-headers-only"`. - `inflate` always runs under a mandatory output bound (`--max-output`, default the @@ -213,7 +234,7 @@ src/ - **ESM-first source** — all internal imports use `.js` extension; tsup bundles it into the single CommonJS bin `dist/cli.cjs` (`zipnative` / `zipnative/worker` stay external). - **Lint covers `src/` AND `tests/`** (`npm run lint`, tests under a relaxed override); - coverage thresholds are 93 / 88 / 94 / 93 (`vitest.config.ts`). + coverage thresholds are 95 / 91 / 97 / 96 (`vitest.config.ts`). - **`const` over `let`** — never use `var`. - **No `any`** — use `unknown` with type narrowing. - **No `console.log`** — use `process.stdout.write(msg + '\n')` / `process.stderr.write(msg + '\n')`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ce15d79..737922b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,10 @@ version: 2 +# Every ecosystem waits seven days before it proposes a new version: a +# freshly published release is the window in which a compromised maintainer +# account or a hijacked package is still undetected (zizmor +# dependabot-cooldown; Dependabot's own default is three days). The cooldown +# applies to version updates only — security updates are never delayed. updates: - package-ecosystem: npm directory: / @@ -7,6 +12,8 @@ updates: interval: weekly day: monday open-pull-requests-limit: 10 + cooldown: + default-days: 7 labels: - dependencies commit-message: @@ -16,6 +23,8 @@ updates: update-types: ["version-update:semver-major"] - dependency-name: "eslint" update-types: ["version-update:semver-major"] + - dependency-name: "@eslint/js" + update-types: ["version-update:semver-major"] - dependency-name: "vitest" update-types: ["version-update:semver-major"] - dependency-name: "@vitest/coverage-v8" @@ -24,14 +33,21 @@ updates: update-types: ["version-update:semver-major"] - dependency-name: "tsup" update-types: ["version-update:semver-major"] - # Exact-pinned SBOM generator run inside the publish job (publish.yml): - # majors are reviewed by hand, minors/patches flow through the group. - - dependency-name: "@cyclonedx/cyclonedx-npm" + # engines.node starts at the 22 line and .nvmrc pins 22: the type + # definitions follow the Node line, not the newest major (a @types/node + # 26 would type-check code against APIs Node 22 does not have — #10). + - dependency-name: "@types/node" update-types: ["version-update:semver-major"] groups: + # The engine is the ONE runtime dependency: its bumps come alone, never + # inside a "dev" group (#11 bumped zipnative there and broke the build), + # so each one is reviewed against the engine-data sync, the export + # ledger and the veraZIP corpus (CONTRIBUTING.md §Bumping the engine). dev-dependencies: patterns: - "*" + exclude-patterns: + - "zipnative" update-types: - minor - patch @@ -42,8 +58,18 @@ updates: interval: weekly day: monday open-pull-requests-limit: 5 + cooldown: + default-days: 7 labels: - dependencies - ci commit-message: prefix: "chore(ci):" + # One pull request for every action bump of a week, so the workflows move + # together and every action stays pinned at ONE SHA across the tree — the + # four github/codeql-action steps must run the same release, which + # one-PR-per-step bumps broke (#3, #4, #5, #7). + groups: + actions: + patterns: + - "*" diff --git a/.github/instructions/cli-design.instructions.md b/.github/instructions/cli-design.instructions.md index 51947de..3f90cb1 100644 --- a/.github/instructions/cli-design.instructions.md +++ b/.github/instructions/cli-design.instructions.md @@ -18,10 +18,10 @@ applyTo: "src/index.ts" ## Dispatch -- `loadCommand(name)` lazy-imports one of the 15 commands: `create`, `modify` | `list`, - `inspect`, `cat`, `extract`, `stream` | `verify`, `crc32`, `inflate` | `batch`, `doctor`, - `schema`, `completion`, `govern`. Adding a command means touching `loadCommand`, - `COMMAND_USAGE`, `USAGE` (the "Commands (15)" block), and `COMMANDS` in +- `loadCommand(name)` lazy-imports one of the 17 commands: `create`, `modify`, `canonicalize`, + `merge` | `list`, `inspect`, `cat`, `extract`, `stream` | `verify`, `crc32`, `inflate` | + `batch`, `doctor`, `schema`, `completion`, `govern`. Adding a command means touching + `loadCommand`, `COMMAND_USAGE`, `USAGE` (the "Commands (17)" block), and `COMMANDS` in `src/commands/completion.ts` — the single source of truth that `schema manifest`, `doctor` and the docs consistency test all derive from. - `--help`/`-h` and `--version`/`-V` handled before dispatch; `zipnative --help` @@ -53,8 +53,8 @@ applyTo: "src/index.ts" ## Help text -- One global `USAGE` block listing all 15 commands under their 4 group headings plus the - `GLOBAL_USAGE` block (the global options incl. the 8 `--max-*` limits and +- One global `USAGE` block listing all 17 commands under their 4 group headings plus the + `GLOBAL_USAGE` block (the global options incl. the 9 `--max-*` limits and `--max-input-size`), and one `*_USAGE` block per command. Point agents at `AGENTS.md`; state "Offline, always". - Keep `*_USAGE` flag lists in sync with each command's actual flags AND with the `flags` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1c5581d..ebf7299 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,7 +14,7 @@ - [ ] New code has tests (coverage thresholds must not regress) - [ ] CHANGELOG.md updated (if user-facing change) - [ ] No breaking changes (or documented in description) -- [ ] Binary smoke test passes (`node dist/cli.cjs --help` outputs all 15 commands) +- [ ] Binary smoke test passes (`node dist/cli.cjs --help` outputs all 17 commands) - [ ] veraZIP gate passes (`npm run validate:zip` locally; CI on Linux + Windows) - [ ] No ZIP parsing logic in the CLI (every byte-level operation is a core call; raw bytes only in `scripts/` and `tests/helpers/`) - [ ] No security default loosened silently (opt-outs are named `--skip-*` / `--allow-*` and documented in SECURITY.md) diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json new file mode 100644 index 0000000..46ae742 --- /dev/null +++ b/.github/rulesets/main.json @@ -0,0 +1,69 @@ +{ + "name": "main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "ci (22)", "integration_id": 15368 }, + { "context": "ci (24)", "integration_id": 15368 }, + { "context": "windows (22)", "integration_id": 15368 }, + { "context": "windows (24)", "integration_id": 15368 }, + { "context": "macos (22)", "integration_id": 15368 }, + { "context": "workflow lint", "integration_id": 15368 }, + { "context": "verazip-linux", "integration_id": 15368 }, + { "context": "verazip-windows", "integration_id": 15368 }, + { "context": "sample-regression (ubuntu-latest)", "integration_id": 15368 }, + { "context": "sample-regression (windows-latest)", "integration_id": 15368 }, + { "context": "dependency-review", "integration_id": 15368 } + ] + } + }, + { + "type": "code_scanning", + "parameters": { + "code_scanning_tools": [ + { + "tool": "CodeQL", + "security_alerts_threshold": "high_or_higher", + "alerts_threshold": "errors" + } + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "pull_request" + } + ] +} diff --git a/.github/rulesets/tags.json b/.github/rulesets/tags.json new file mode 100644 index 0000000..8fdb881 --- /dev/null +++ b/.github/rulesets/tags.json @@ -0,0 +1,23 @@ +{ + "name": "tags", + "target": "tag", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/tags/v*"], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "update" + } + ], + "bypass_actors": [] +} diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..b071d34 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,84 @@ +name: Dependency Audit + +# Weekly, independent of any push: a new advisory against a pinned dev +# dependency must surface even when nobody touches the repository. Two +# databases: npm's (npm audit) and OSV (osv-scanner over the lockfile). +# Dev-only by construction — zipnative is the sole runtime dependency. + +on: + schedule: + - cron: '17 5 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + name: npm audit + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + registry.npmjs.org:443 + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Audit dependencies + run: npm audit --audit-level=moderate + + osv: + name: OSV-Scanner + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # 2026-10-18: the Go toolchain, module proxy and OSV endpoints are not baselined yet + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: '1.27' + cache: false + + # A version-pinned module, verified through the Go checksum database. + - name: Install osv-scanner (pinned) + env: + CGO_ENABLED: '0' + run: | + go install github.com/google/osv-scanner/v2/cmd/osv-scanner@v2.6.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Scan the lockfile + run: osv-scanner scan source --lockfile package-lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1df9e61..3d52960 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,34 +1,28 @@ name: CI +# The `ci` jobs run `scripts/gate.mjs --ci`, the single definition of "green": +# the step list lives in that file's STEPS table and is not repeated here, so +# CI, publish.yml and the contributor loop cannot drift apart. `--require-all` +# turns a SKIP into a FAIL (the zipnative checkout of the engine-data step is +# the one convenience skip; the Linux 22 leg provides it). +# +# `npm audit` runs outside the gate on purpose: the gate's verdict should be +# the same on a laptop and on a runner, and the advisory database is not. +# +# No path filter. Every job here reports a check the ruleset requires +# (.github/rulesets/main.json), and GitHub leaves the checks of a +# path-filtered workflow pending forever, which blocks the pull request. + on: push: branches: [main, master] - # Docs are NOT ignored: tests/docs/consistency.test.ts pins README, - # AGENTS.md, llms.txt and the knowledge base to the code, so a docs-only - # change can break the suite and must run it. - paths-ignore: - - '.github/ISSUE_TEMPLATE/**' - - '.github/FUNDING.yml' - - 'LICENSE' - - '.editorconfig' - - '.gitignore' pull_request: branches: [main, master] - # `edited` is added to the default types so a PR title change re-runs the - # commitlint job (a "Re-run" of an existing run replays the old event - # payload, i.e. the old title). The whole matrix runs on that event on - # purpose: skipping jobs would report "skipped", which GitHub counts as a - # passing required check. + # `edited` re-runs the commitlint job when the PR title changes (a "Re-run" + # replays the old event payload, i.e. the old title). The whole matrix + # runs on that event on purpose: a skipped job reports "skipped", which + # GitHub counts as a passing required check. types: [opened, synchronize, reopened, edited] - # Docs are NOT ignored: tests/docs/consistency.test.ts pins README, - # AGENTS.md, llms.txt and the knowledge base to the code, so a docs-only - # change can break the suite and must run it. - paths-ignore: - - '.github/ISSUE_TEMPLATE/**' - - '.github/FUNDING.yml' - - 'LICENSE' - - '.editorconfig' - - '.gitignore' permissions: contents: read @@ -38,16 +32,39 @@ concurrency: cancel-in-progress: true jobs: + # One job, five runs. `name:` is the status-check context the ruleset + # requires; renaming one leaves every pull request waiting for a context + # that never reports again. + # + # Windows and macOS are blocking: `\` vs `/` in entry names, reserved device + # names (CON, NUL, COM1…) the sink must refuse, the case-insensitive + # filesystems where two entries collide on disk (win32 AND darwin), Windows + # symbolic-link semantics for the exclusive writes, CRLF checkouts that + # would corrupt fixture bytes and the deterministic SHA-256 proofs, and the + # npm.cmd shim are facts a Linux runner never exercises. ci: - runs-on: ubuntu-latest - timeout-minutes: 15 + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: fail-fast: false matrix: - node-version: [22, 24] + include: + - { name: 'ci (22)', os: ubuntu-latest, node-version: 22, slug: linux-node22, audit: true, engine: true } + - { name: 'ci (24)', os: ubuntu-latest, node-version: 24, slug: linux-node24, audit: false, engine: false } + - { name: 'windows (22)', os: windows-latest, node-version: 22, slug: windows-node22, audit: false, engine: false } + - { name: 'windows (24)', os: windows-latest, node-version: 24, slug: windows-node24, audit: false, engine: false } + - { name: 'macos (22)', os: macos-latest, node-version: 22, slug: macos-node22, audit: false, engine: false } steps: + # One step serves every leg, so the policy is the one Windows and macOS + # allow (audit: the vendor's platform matrix at the pinned SHA). + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # 2026-10-18: audit only on Windows/macOS (vendor matrix), and one step serves every leg + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -58,176 +75,107 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm + # No lifecycle script of any dependency runs on the runner; the + # repository's own .npmrc says the same for every developer machine. - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts + # Once per commit, not once per platform: the advisory database does + # not depend on the runner. - name: Audit dependencies + if: matrix.audit run: npm audit --audit-level=moderate - - name: Type check - run: npm run typecheck:all - - - name: Lint - run: npm run lint - - - name: Test with coverage - run: npm run test:coverage - - - name: Build - run: npm run build - - # The product sells byte-reproducible archives; its own bundle must be - # byte-reproducible too (tsup/esbuild with a locked toolchain). - - name: Build is reproducible + # The engine-data step compares docs/data/{errors,core-exports}.json + # with the engine's own registries at the tag node_modules holds + # (scripts/sync-engine-data.mjs --check). Read-only clone. + - name: Check out the pinned zipnative tag (engine-data step) + if: matrix.engine shell: bash run: | - sha256sum dist/cli.cjs > build.sha - npm run build - sha256sum -c build.sha - - - name: Verify dist output - # The package ships the CJS bin only (no ESM build, no .d.ts, no maps). - run: | - test -f dist/cli.cjs - test ! -e dist/cli.js - test ! -e dist/cli.d.ts - test ! -e dist/cli.cjs.map - - # The built binary must answer --help / --version and expose the full - # command surface through `schema manifest` (15 commands in 4 groups — - # the single source of truth is src/commands/completion.ts COMMANDS). - - name: Binary smoke test - run: | - node dist/cli.cjs --help - node dist/cli.cjs --version - node dist/cli.cjs schema manifest | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const m=JSON.parse(s);if(!Array.isArray(m.commands)||m.commands.length!==15){console.error("schema manifest: expected 15 commands, got "+(Array.isArray(m.commands)?m.commands.length:"none"));process.exit(1)}console.log("schema manifest ok: "+m.commands.length+" commands")})' - - # Spawns the built dist/cli.cjs as a child process (real argv, real - # stdin/stdout pipes, real exit codes) — this suite self-skips when - # dist/ is absent, so it must run post-build. - - name: Built-binary integration (post-build) - run: npx vitest run tests/integration/built-binary-smoke.test.ts tests/integration/startup-budget.test.ts - - # A ZIP CLI lives or dies on Windows correctness: `\` vs `/` path - # separators in entry names and --base/--prefix handling, reserved device - # names (CON, NUL, COM1…) that the extraction sink must refuse, the - # case-insensitive filesystem where two entries can collide on disk, and - # CRLF checkouts that silently corrupt fixture bytes and the deterministic - # SHA-256 proofs. This job is BLOCKING from 1.0.0 — any `skip on win32` - # needs a linked issue. `shell: bash` keeps the POSIX `test -f` steps - # portable (Git Bash ships on windows-latest). - windows: - runs-on: windows-latest - timeout-minutes: 20 + set -euo pipefail + ENGINE=$(node -p "require('zipnative/package.json').version") + git clone --quiet --depth 1 --branch "v${ENGINE}" https://github.com/Nizoka/zipnative.git "${RUNNER_TEMP}/zipnative" + echo "ZIPNATIVE_ENGINE_CHECKOUT=${RUNNER_TEMP}/zipnative" >> "$GITHUB_ENV" - strategy: - fail-fast: false - matrix: - node-version: [22, 24] + - name: Run the quality gate (CI profile) + run: node scripts/gate.mjs --ci --require-all - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Upload the gate logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - persist-credentials: false + name: gate-logs-${{ matrix.slug }} + path: test-output/.gate/ + retention-days: 7 + if-no-files-found: ignore + + # The workflows themselves are code that holds tokens. zizmor reads them for + # the GitHub Actions weaknesses (template injection, excessive permissions, + # unpinned or impostor actions, cache poisoning); actionlint checks syntax, + # expressions and every `run:` script through shellcheck; `reuse lint` + # checks that every file carries its licence (REUSE.toml). Pinned: zizmor + # by the action and its version, actionlint by the SHA-256 of its release + # archive, reuse by its version. + workflow-lint: + name: workflow lint + runs-on: ubuntu-latest + timeout-minutes: 10 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: - node-version: ${{ matrix.node-version }} - cache: npm - - - name: Install dependencies - run: npm ci + egress-policy: audit # 2026-10-18: the container registry hosts behind ghcr.io and PyPI are not baselined yet - - name: Type check - run: npm run typecheck:all - - - name: Lint - run: npm run lint - - - name: Test - run: npm run test - - - name: Build - run: npm run build - - # The product sells byte-reproducible archives; its own bundle must be - # byte-reproducible too (tsup/esbuild with a locked toolchain). - - name: Build is reproducible - shell: bash - run: | - sha256sum dist/cli.cjs > build.sha - npm run build - sha256sum -c build.sha - - - name: Verify dist output - shell: bash - run: | - test -f dist/cli.cjs - test ! -e dist/cli.js - test ! -e dist/cli.d.ts - - - name: Binary smoke test - shell: bash - run: | - node dist/cli.cjs --help - node dist/cli.cjs --version - node dist/cli.cjs schema manifest | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const m=JSON.parse(s);if(!Array.isArray(m.commands)||m.commands.length!==15){console.error("schema manifest: expected 15 commands, got "+(Array.isArray(m.commands)?m.commands.length:"none"));process.exit(1)}console.log("schema manifest ok: "+m.commands.length+" commands")})' - - - name: Built-binary integration (post-build) - run: npx vitest run tests/integration/built-binary-smoke.test.ts tests/integration/startup-budget.test.ts - - # macOS is the second case-insensitive filesystem the sink handles - # (utils/sink.ts CASE_INSENSITIVE_FS covers win32 AND darwin): the - # case-fold collision and --flat duplicate policies must be exercised on - # a real APFS volume, not only inferred from the Windows leg. Tests + a - # build + the spawn smoke test; the veraZIP corpus stays Linux/Windows. - macos: - runs-on: macos-latest - timeout-minutes: 20 - - steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Setup Node.js 22 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - name: zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Test - run: npm run test + version: 1.30.1 + min-severity: medium + advanced-security: false + annotations: true - - name: Build - run: npm run build - - # The product sells byte-reproducible archives; its own bundle must be - # byte-reproducible too (tsup/esbuild with a locked toolchain). - - name: Build is reproducible - shell: bash + - name: actionlint + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 run: | - sha256sum dist/cli.cjs > build.sha - npm run build - sha256sum -c build.sha - - - name: Built-binary integration (post-build) - run: npx vitest run tests/integration/built-binary-smoke.test.ts tests/integration/startup-budget.test.ts + set -euo pipefail + ARCHIVE="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location --proto '=https' \ + --output "${RUNNER_TEMP}/${ARCHIVE}" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${ARCHIVE}" + echo "${ACTIONLINT_SHA256} ${RUNNER_TEMP}/${ARCHIVE}" | sha256sum --check --strict + tar -xzf "${RUNNER_TEMP}/${ARCHIVE}" -C "${RUNNER_TEMP}" actionlint + shellcheck --version | head -2 + "${RUNNER_TEMP}/actionlint" -color + + - name: REUSE compliance + env: + REUSE_VERSION: 6.2.0 + run: pipx run --spec "reuse==${REUSE_VERSION}" reuse lint # Conventional Commits, enforced without a dependency: every commit subject # of the PR and the PR title (the squash-merge subject) must match the - # pattern CONTRIBUTING.md documents. Not a required check until it has - # run quietly for a while. + # pattern CONTRIBUTING.md documents. commitlint: if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index df0a72c..302dcd8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,34 +1,22 @@ name: CodeQL +# Two languages: the TypeScript sources AND the workflows themselves +# (`actions`: untrusted input in run:, excessive permissions, unpinned +# actions). `build-mode: none` for both — CodeQL extracts TypeScript without +# a build, so no dependency code runs in this job. `security-extended` adds +# the lower-precision security queries to the default suite. +# +# No path filter: a skipped required check stays pending forever. + on: push: branches: [main, master] - paths-ignore: - - '**.md' - - 'docs/**' - - '.github/ISSUE_TEMPLATE/**' - - '.github/*.md' - - '.github/FUNDING.yml' - - 'LICENSE' - - '.editorconfig' - - '.gitignore' pull_request: branches: [main, master] - paths-ignore: - - '**.md' - - 'docs/**' - - '.github/ISSUE_TEMPLATE/**' - - '.github/*.md' - - '.github/FUNDING.yml' - - 'LICENSE' - - '.editorconfig' - - '.gitignore' schedule: - cron: '27 3 * * 1' permissions: - security-events: write - actions: read contents: read concurrency: @@ -37,30 +25,42 @@ concurrency: jobs: analyze: - name: Analyze + name: Analyze (${{ matrix.language }}) runs-on: ubuntu-latest timeout-minutes: 30 + permissions: + security-events: write # Upload the analysis to code scanning. + actions: read # Read the workflow run the analysis belongs to. + contents: read # Read the source. strategy: fail-fast: false matrix: - language: [javascript-typescript] + include: + - { language: actions, build-mode: none } + - { language: javascript-typescript, build-mode: none } steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # 2026-10-18: the CodeQL bundle and its query packs come from hosts not baselined yet + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + # init and analyze MUST share one release: CodeQL refuses a run whose + # steps disagree (the failure Dependabot's per-step PRs produced). - name: Initialize CodeQL - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} - - - name: Autobuild - uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + build-mode: ${{ matrix.build-mode }} + queries: security-extended - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: - category: "/language:${{ matrix.language }}" + category: /language:${{ matrix.language }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..22eacb7 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,63 @@ +name: Dependency Review + +# Every pull request that changes a manifest or the lockfile: a new +# dependency with a known high-severity advisory, or under a licence outside +# the allow-list, fails the check. The MPL-2.0 exception is named package by +# package (lightningcss, pulled in by the Vitest bundler, test time only — +# THIRD-PARTY-NOTICES.md). + +on: + pull_request: + branches: [main, master] + +permissions: + contents: read + +concurrency: + group: dependency-review-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + name: dependency-review + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read # Read the manifests and lockfile on both sides of the diff. + pull-requests: write # Needed for comment-summary-in-pr. + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + api.securityscorecards.dev:443 + api.deps.dev:443 + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high + allow-licenses: MIT, ISC, BSD-2-Clause, BSD-3-Clause, Apache-2.0, 0BSD, CC0-1.0, Unlicense, BlueOak-1.0.0, Python-2.0 + allow-dependencies-licenses: >- + pkg:npm/lightningcss, + pkg:npm/lightningcss-android-arm64, + pkg:npm/lightningcss-darwin-arm64, + pkg:npm/lightningcss-darwin-x64, + pkg:npm/lightningcss-freebsd-x64, + pkg:npm/lightningcss-linux-arm-gnueabihf, + pkg:npm/lightningcss-linux-arm64-gnu, + pkg:npm/lightningcss-linux-arm64-musl, + pkg:npm/lightningcss-linux-x64-gnu, + pkg:npm/lightningcss-linux-x64-musl, + pkg:npm/lightningcss-win32-arm64-msvc, + pkg:npm/lightningcss-win32-x64-msvc + comment-summary-in-pr: on-failure diff --git a/.github/workflows/node-current.yml b/.github/workflows/node-current.yml new file mode 100644 index 0000000..72ccacf --- /dev/null +++ b/.github/workflows/node-current.yml @@ -0,0 +1,61 @@ +name: Node Current + +# The CI gate on the newest Node.js line (26) — information, not a contract: +# engines.node is >=22 and the required checks run 22 and 24. Advisory: not a +# required check in .github/rulesets/main.json. + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +permissions: + contents: read + +concurrency: + group: node-current-${{ github.ref }} + cancel-in-progress: true + +jobs: + node-current: + name: node 26 (current, advisory) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + release-assets.githubusercontent.com:443 + nodejs.org:443 + registry.npmjs.org:443 + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js 26 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 26 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Run the quality gate (CI profile) + run: node scripts/gate.mjs --ci --require-all + + - name: Upload the gate logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gate-logs-linux-node26 + path: test-output/.gate/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 61b66ec..622b543 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,38 +1,127 @@ name: Publish -# Prerequisite: configure Trusted Publishing for zipnative-cli on npmjs.com -# (repository Nizoka/zipnative-cli, workflow publish.yml) — the 0.0.1 -# placeholder was token-published, so the package settings do not yet carry -# the OIDC trust relationship this workflow relies on. - +# The maintainer publishes the GitHub Release `vX.Y.Z` (which creates the tag) +# and that publication is the trigger; nothing here creates, edits or +# publishes a release. The `attest` job attaches its files to that published +# release afterwards, so release immutability stays off: an immutable release +# accepts no asset once published (docs/adr/0009-release-integrity.md). on: release: types: [published] workflow_dispatch: # Trusted Publishing (npmjs.com OIDC): -# This workflow uses npm Trusted Publishing — no long-lived NPM_TOKEN secret. -# The OIDC id-token permission mints a short-lived token that: -# 1. Authorises the `npm publish` HTTP request -# 2. Signs the provenance attestation (SLSA Level 2+ via Sigstore) -# Configure at npmjs.com → package settings → "Configure Trusted Publishing". -# Least privilege at the top level; the publish job alone requests -# `id-token: write` (OIDC) and `contents: write` (release asset upload). +# No long-lived NPM_TOKEN secret. The OIDC id-token permission mints a +# short-lived token that authorises the upload and signs npm's provenance +# statement (SLSA Build L2). Configure at npmjs.com → zipnative-cli → +# settings → "Trusted Publishing", bound to this repository, this file AND +# the `npm-publish` environment (a protected environment with a required +# reviewer — CONTRIBUTING.md §Release). +# +# Job graph: guard → build → publish → attest → consumer. +# guard reads package.json; no token, no environment, nothing installed, +# so a refusal ends the run BEFORE any approval is asked. +# build runs the whole publish gate over the dev toolchain, packs the +# tarball and hands it on with its digests. It holds no id-token: +# dev-dependency code never runs where a publishing token can be +# minted. +# publish behind the `npm-publish` environment; the only job that can +# mint the npm token. It checks out .nvmrc and nothing else, +# installs nothing from the lockfile and runs no repository script: +# it verifies the handed-on tarball against the build job's +# digests and uploads exactly it. +# attest fetches the published tarball back from the registry, checks it +# against the same digests, writes the SBOMs, attests them and +# attaches them to the release. Its id-token signs that attestation +# (Sigstore) outside the `npm-publish` environment, so it cannot +# publish; it runs no code fetched from the registry. +# consumer read-only: installs the published version into an empty project, +# runs `npm audit signatures` and the installed bin. The install +# resolves zipnative afresh, so it never holds a signing or write +# permission. +# +# No job restores a dependency cache: a cache is written by other workflows, +# and a release job must not build from what they left behind +# (package-manager-cache: false on every setup-node step — +# tests/tools/workflows.test.ts holds it). permissions: contents: read +# One release at a time, and a running publish is never cancelled half-way. +concurrency: + group: publish + cancel-in-progress: false + jobs: - publish: + # No `environment:` on purpose: the refusal must run before the approval + # prompt, never behind it. Read-only, no token minted, nothing installed. + guard: + name: guard + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # The runner image's own Node reads one JSON field; nothing is built here. + - name: Read the version and check it against the tag + id: version + run: | + set -euo pipefail + VERSION=$(node -p "require('./package.json').version") + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + # Only a tag publishes. A workflow_dispatch on a branch would + # otherwise publish whatever that branch holds, with nothing but + # the environment approval in the way. + if [ "${GITHUB_REF_TYPE}" != "tag" ]; then + echo "::error::publish runs from a v* tag only, not from ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}" + exit 1 + fi + if [ "${GITHUB_REF_NAME}" != "v${VERSION}" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match package.json version ${VERSION}" + exit 1 + fi + if [ "${VERSION%%.*}" = "0" ]; then + echo "::error::no version below 1.0.0 is released" + exit 1 + fi + echo "Guarding zipnative-cli ${VERSION} from ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}" + + # The untrusted half: every dev dependency runs here, and nothing here can + # publish. Its only products are the tarball and its digests. + build: + name: build and pack + needs: guard runs-on: ubuntu-latest - timeout-minutes: 25 - # `id-token: write` mints the OIDC token for Trusted Publishing + provenance. - # `contents: write` lets the workflow attach the generated SBOM to the release. + timeout-minutes: 40 permissions: - contents: write - id-token: write - # Lets the SBOM attestation below be stored with the repository. - attestations: write + contents: read + outputs: + version: ${{ needs.guard.outputs.version }} + tarball: ${{ steps.pack.outputs.tarball }} + sha256: ${{ steps.pack.outputs.sha256 }} + integrity: ${{ steps.pack.outputs.integrity }} + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # 2026-10-18: the install smoke test and the engine clone are not baselined yet + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -40,147 +129,344 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - # A range resolves to the NEWEST available Node (currently 24.x), - # whose bundled npm >= 11.5.1 is required by npm Trusted Publishing - # (token-less OIDC). A '22.x' pin would ship npm 10.9 and break the - # publish. The floor mirrors the engines >= 22 support policy and - # the sibling zipnative / pdfnative-cli workflows. - node-version: '>=22.14.0' - registry-url: https://registry.npmjs.org - cache: npm + # The release is built on the Node line .nvmrc names — the one + # contributors develop on and CI tests. + node-version-file: .nvmrc + package-manager-cache: false + + # Same commit as the guard: re-read, so the version built is provably + # the version guarded, should the `needs:` edge ever be lost. + - name: Confirm the guarded version + env: + GUARDED: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + VERSION=$(node -p "require('./package.json').version") + if [ "${VERSION}" != "${GUARDED}" ] || [ "${VERSION%%.*}" = "0" ]; then + echo "::error::package.json version ${VERSION} is not the guarded ${GUARDED:-nothing}, or is below 1.0.0" + exit 1 + fi + echo "Building zipnative-cli ${VERSION} from ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}" - # The publish re-runs the ENTIRE gate — a release that skips a check - # is a release that can ship a regression. Same steps as ci.yml. - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Audit dependencies run: npm audit --audit-level=moderate - - name: Type check - run: npm run typecheck:all + - name: Check out the pinned zipnative tag (engine-data step) + run: | + set -euo pipefail + ENGINE=$(node -p "require('zipnative/package.json').version") + git clone --quiet --depth 1 --branch "v${ENGINE}" https://github.com/Nizoka/zipnative.git "${RUNNER_TEMP}/zipnative" + echo "ZIPNATIVE_ENGINE_CHECKOUT=${RUNNER_TEMP}/zipnative" >> "$GITHUB_ENV" + + # Every gate step — typecheck, lint, build, reproducible build, bundle + # check, smoke, coverage, engine data, the tarball list, the samples, + # the veraZIP corpus (fail-closed without a level-1 tool) and the install + # smoke test. --require-all: a missing tool fails the release. + - name: Run the quality gate (publish profile) + run: node scripts/gate.mjs --publish --require-all + + - name: Show the package contents + run: npm pack --dry-run --ignore-scripts + + # The tarball the gate just checked, packed once. Its SHA-256 and its + # SHA-512 integrity travel to the next jobs as job outputs — not inside + # the artifact they describe — and both are checked again there. + - name: Pack the tarball and record its digests + id: pack + env: + VERSION: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/release" + npm pack --pack-destination "${RUNNER_TEMP}/release" --ignore-scripts --silent >/dev/null + TARBALL="zipnative-cli-${VERSION}.tgz" + test -f "${RUNNER_TEMP}/release/${TARBALL}" + SHA256=$(sha256sum "${RUNNER_TEMP}/release/${TARBALL}" | cut -d' ' -f1) + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "${RUNNER_TEMP}/release/${TARBALL}" | base64 -w0)" + { + echo "tarball=${TARBALL}" + echo "sha256=${SHA256}" + echo "integrity=${INTEGRITY}" + } >> "$GITHUB_OUTPUT" + { + echo '## Packed tarball' + echo + echo '```' + echo "${TARBALL} sha256 ${SHA256}" + echo "${INTEGRITY}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Hand the tarball to the publishing job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-tarball + path: ${{ runner.temp }}/release/${{ steps.pack.outputs.tarball }} + retention-days: 7 + if-no-files-found: error + + - name: Upload the gate logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gate-logs-publish + path: test-output/.gate/ + retention-days: 7 + if-no-files-found: ignore + + # The trusted half, and the only job whose id-token can publish. A + # protected environment (required reviewer) gates the token: the job cannot + # mint one before the maintainer approves — and it is only reached once the + # guard and the whole publish gate have passed. + publish: + name: publish to npm + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: npm-publish + permissions: + contents: read # Read the one sparse file, .nvmrc. + id-token: write # Mint the OIDC token Trusted Publishing exchanges, and sign provenance. + outputs: + version: ${{ needs.build.outputs.version }} + env: + VERSION: ${{ needs.build.outputs.version }} + TARBALL: ${{ needs.build.outputs.tarball }} + SHA256: ${{ needs.build.outputs.sha256 }} + INTEGRITY: ${{ needs.build.outputs.integrity }} + # Node 22 ships npm 10.9; Trusted Publishing (OIDC, provenance) needs + # npm >= 11.5.1. The client is pinned by CONTENT, not only by name: the + # registry tarball must match this SHA-512 (the value the ecosystem's + # publish workflows pin), and it runs from where it is unpacked — no + # global install. + NPM_CLIENT_VERSION: 11.19.1 + NPM_CLIENT_INTEGRITY: sha512-ztsxKxt/kkIaAs+2i0GU6I+DRmUdrNasxTZKJe9TCdSjKxlhah/4r/hl5ygMD6XAg1qZ9c2TNomR4qgOydp10g== + + steps: + # The registry (the client, the upload, the OIDC exchange) and the + # public-good Sigstore instance that signs npm's provenance. The OIDC + # token itself comes from *.actions.githubusercontent.com, which + # harden-runner always allows. + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + registry.npmjs.org:443 + fulcio.sigstore.dev:443 + rekor.sigstore.dev:443 + tuf-repo-cdn.sigstore.dev:443 + nodejs.org:443 - - name: Lint - run: npm run lint + # One file of the repository: .nvmrc, so the client that uploads runs + # on the Node line the package was built and tested on. No source, no + # lockfile, no script — nothing a dev dependency could have touched. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: .nvmrc + sparse-checkout-cone-mode: false - - name: Test with coverage - run: npm run test:coverage + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + registry-url: https://registry.npmjs.org + package-manager-cache: false - - name: Build - run: npm run build + - name: Receive the tarball + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: release-tarball + path: ${{ runner.temp }}/release - - name: Verify dist output - # CJS bin only — no ESM build, no .d.ts, no source maps in the tarball. + # The bytes uploaded are the bytes the gate checked: both digests, and + # the version inside the tarball, against what the build job recorded. + - name: Verify the tarball against the build job's digests + working-directory: ${{ runner.temp }}/release run: | - test -f dist/cli.cjs - test ! -e dist/cli.js - test ! -e dist/cli.d.ts - test ! -e dist/cli.cjs.map + set -euo pipefail + test "${TARBALL}" = "zipnative-cli-${VERSION}.tgz" + echo "${SHA256} ${TARBALL}" | sha256sum --check --strict + test "sha512-$(openssl dgst -sha512 -binary "${TARBALL}" | base64 -w0)" = "${INTEGRITY}" + INNER=$(tar -xzOf "${TARBALL}" package/package.json | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version') + if [ "${INNER}" != "${VERSION}" ] || [ "${VERSION%%.*}" = "0" ]; then + echo "::error::the tarball holds version ${INNER}, not the guarded ${VERSION}, or is below 1.0.0" + exit 1 + fi - - name: Binary smoke test + - name: Fetch and verify the pinned npm client + working-directory: ${{ runner.temp }} run: | - node dist/cli.cjs --help - node dist/cli.cjs --version - node dist/cli.cjs schema manifest | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const m=JSON.parse(s);if(!Array.isArray(m.commands)||m.commands.length!==15){console.error("schema manifest: expected 15 commands, got "+(Array.isArray(m.commands)?m.commands.length:"none"));process.exit(1)}console.log("schema manifest ok: "+m.commands.length+" commands")})' - - # Spawns the built dist/cli.cjs as a child process — self-skips when - # dist/ is absent, so it must run post-build. - - name: Built-binary integration (post-build) - run: npx vitest run tests/integration/built-binary-smoke.test.ts tests/integration/startup-budget.test.ts - - # veraZIP gate: the release must never publish a CLI whose archives the - # ISO/IEC 21320-1 reference validator rejects. Same corpus generator + - # vendored validator as .github/workflows/verazip.yml — BLOCKING, no - # continue-on-error. Level-1 tools come from the ubuntu-latest image - # (unzip, 7z, python3, jar); nothing is downloaded. The Windows leg - # (bsdtar / tar.exe, 7-Zip, python, jar) is enforced per-PR by - # verazip.yml, so every commit that can reach a release has already - # passed it. - - name: Generate ZIP corpus - # dist/ is already built by the Build step above. - run: npm run corpus:zip - - - name: Validate ZIP corpus (blocking) - env: - # Fail-closed: zero usable level-1 integrity tools is an INFRA - # failure (exit 3), never a silent skip. - VERAZIP_REQUIRED: '1' - VERAZIP_REPORT_DIR: test-output/zip/reports - run: node scripts/validate-zip.mjs - - - name: Generate SBOM (CycloneDX) - # Software Bill of Materials for supply-chain transparency. The - # generator is an EXACT-pinned devDependency installed by npm ci from - # the lockfile (integrity-checked) — never fetched by npx at publish - # time inside a job that holds the OIDC token. Build-time only: ZERO - # runtime dependencies are added to the package. - # --omit dev: the SBOM describes the shipped tree (zipnative only). - run: npx cyclonedx-npm --omit dev --output-format JSON --output-file sbom.cdx.json - - - name: Upload SBOM artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + set -euo pipefail + curl --fail --silent --show-error --location --proto '=https' \ + --output npm-client.tgz "https://registry.npmjs.org/npm/-/npm-${NPM_CLIENT_VERSION}.tgz" + test "sha512-$(openssl dgst -sha512 -binary npm-client.tgz | base64 -w0)" = "${NPM_CLIENT_INTEGRITY}" + mkdir npm-client + tar -xzf npm-client.tgz -C npm-client + test "$(node npm-client/package/bin/npm-cli.js --version)" = "${NPM_CLIENT_VERSION}" + + # A tarball, not a directory: no lifecycle script can run, and no file + # of the repository is read. npm's provenance covers this file's SHA-512. + - name: Publish + working-directory: ${{ runner.temp }}/release + run: node "${RUNNER_TEMP}/npm-client/package/bin/npm-cli.js" publish "./${TARBALL}" --provenance --access public + + # Attests the tarball npm published — fetched back from the registry, so the + # attested bytes ARE the published ones — checks it against the build job's + # digests, writes the SBOMs from the same + # commit's lockfile, and attaches everything to the release. Kept apart from + # `publish`: the release-write and attestation permissions are never held + # next to the npm token. + attest: + name: attest and attach + needs: [build, publish] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write # Attach the files to the release. + id-token: write # Sign the attestation with the workflow identity (Sigstore). + attestations: write # Store the attestation with the repository. + env: + VERSION: ${{ needs.publish.outputs.version }} + SHA256: ${{ needs.build.outputs.sha256 }} + INTEGRITY: ${{ needs.build.outputs.integrity }} + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: - name: sbom-cyclonedx - path: sbom.cdx.json - retention-days: 90 + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + uploads.github.com:443 + registry.npmjs.org:443 + fulcio.sigstore.dev:443 + rekor.sigstore.dev:443 + tuf-repo-cdn.sigstore.dev:443 + nodejs.org:443 - # Signed provenance for the SBOM itself (Sigstore, stored as a GitHub - # attestation) — a consumer can verify the SBOM was produced by THIS - # workflow run: `gh attestation verify sbom.cdx.json -R Nizoka/zipnative-cli`. - - name: Attest SBOM provenance - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - subject-path: sbom.cdx.json + persist-credentials: false - - name: Attach SBOM to release - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ github.token }} - # Passed through the environment, never interpolated into the - # shell line (a tag name is untrusted input). - TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$TAG" sbom.cdx.json --clobber + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + package-manager-cache: false - - name: Pack tarball (the bytes that will be published) + # The lockfile alone describes both dependency sets; nothing is + # installed or built here. The runtime SBOMs name zipnative, the one + # runtime dependency; the toolchain SBOM records the dev packages that + # built dist/ (tsup, esbuild, TypeScript …) for a reviewer who asks what + # produced the bytes. npm writes CycloneDX 1.5 and SPDX 2.3. + - name: Generate the SBOMs (runtime CycloneDX and SPDX, build toolchain) run: | - npm pack --pack-destination . - sha256sum zipnative-cli-*.tgz | tee tarball.sha256 - { - echo '## Published tarball' - echo - echo '```' - cat tarball.sha256 - echo '```' - } >> "$GITHUB_STEP_SUMMARY" + set -euo pipefail + npm sbom --sbom-format cyclonedx --omit dev --package-lock-only > "zipnative-cli-${VERSION}.cdx.json" + npm sbom --sbom-format spdx --omit dev --package-lock-only > "zipnative-cli-${VERSION}.spdx.json" + npm sbom --sbom-format cyclonedx --package-lock-only > "zipnative-cli-${VERSION}.toolchain.cdx.json" - - name: Verify tarball contents - # The package must carry exactly the bin, the agent docs and the - # error catalogue — never a source map, a test or a workflow. + # `npm pack @` downloads the published tarball and checks + # it against the registry's integrity. Retried: a version can take a + # moment to be served after the upload returns. Then the file's SHA-256 + # and the registry's integrity must equal the build job's handoff: what + # npm serves is what the gate checked. + - name: Fetch the published tarball and check it against the build run: | - tar -tzf zipnative-cli-*.tgz > tarball.txt - cat tarball.txt - grep -q 'package/dist/cli.cjs$' tarball.txt - grep -q 'package/AGENTS.md$' tarball.txt - grep -q 'package/llms.txt$' tarball.txt - grep -q 'package/docs/data/errors.json$' tarball.txt - ! grep -q '\.map$' tarball.txt - ! grep -q 'package/tests/' tarball.txt - - # Signed provenance for the tarball itself (Sigstore, stored as a GitHub - # attestation): gh attestation verify zipnative-cli-.tgz -R Nizoka/zipnative-cli - - name: Attest tarball provenance + set -euo pipefail + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if npm pack "zipnative-cli@${VERSION}" --ignore-scripts >/dev/null 2>&1; then break; fi + echo "zipnative-cli@${VERSION} not served yet (attempt ${attempt}); retrying in 15 s" + sleep 15 + done + test -f "zipnative-cli-${VERSION}.tgz" + echo "${SHA256} zipnative-cli-${VERSION}.tgz" | sha256sum --check --strict + test "$(npm view "zipnative-cli@${VERSION}" dist.integrity)" = "${INTEGRITY}" + + - name: Attest build provenance + id: attest uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - subject-path: zipnative-cli-*.tgz + subject-path: | + zipnative-cli-${{ env.VERSION }}.tgz + zipnative-cli-${{ env.VERSION }}.cdx.json + zipnative-cli-${{ env.VERSION }}.spdx.json + zipnative-cli-${{ env.VERSION }}.toolchain.cdx.json - - name: Attach tarball to release - if: github.event_name == 'release' + # The release the maintainer published, which started this run, receives + # the files. The Sigstore bundle goes with them, so the release carries + # its own verifiable signature. + - name: Attach the tarball, the SBOMs and the attestation to the release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$TAG" zipnative-cli-*.tgz tarball.sha256 --clobber + BUNDLE: ${{ steps.attest.outputs.bundle-path }} + run: | + set -euo pipefail + cp "${BUNDLE}" "zipnative-cli-${VERSION}.sigstore.json" + if gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + # --clobber: a re-run of this job replaces the files it attached + # before with the ones it has just attested. + gh release upload "v${VERSION}" \ + "zipnative-cli-${VERSION}.tgz" \ + "zipnative-cli-${VERSION}.cdx.json" \ + "zipnative-cli-${VERSION}.spdx.json" \ + "zipnative-cli-${VERSION}.toolchain.cdx.json" \ + "zipnative-cli-${VERSION}.sigstore.json" \ + --clobber --repo "${GITHUB_REPOSITORY}" + else + echo "::notice::No GitHub release v${VERSION} exists; the files are attested but not attached." + fi - # Publish the PACKED file, so the attested bytes are the published bytes - # (a bare npm publish would re-pack). - - name: Publish - run: npm publish ./zipnative-cli-*.tgz --provenance --access public + # The registry's signature and npm's provenance, checked the way a consumer + # checks them: installed into an empty project, and the installed bin run + # once. A job of its own, with read-only permissions: the install resolves + # zipnative afresh from the registry and runs it, so it never shares a job + # with the attestation identity (id-token) or the release-write token. + consumer: + name: verify as a consumer + needs: [publish, attest] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + env: + VERSION: ${{ needs.publish.outputs.version }} + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + registry.npmjs.org:443 + nodejs.org:443 + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + + - name: Verify the registry signatures and provenance + working-directory: ${{ runner.temp }} + run: | # zizmor: ignore[adhoc-packages] + set -euo pipefail + mkdir signatures + cd signatures + echo '{ "name": "signatures", "private": true }' > package.json + npm install "zipnative-cli@${VERSION}" --ignore-scripts --no-audit --no-fund + npm audit signatures + test "$(npm exec --offline --no -- zipnative --version)" = "${VERSION}" diff --git a/.github/workflows/sample-regression.yml b/.github/workflows/sample-regression.yml new file mode 100644 index 0000000..6546445 --- /dev/null +++ b/.github/workflows/sample-regression.yml @@ -0,0 +1,58 @@ +name: Sample Regression + +# Every sample the README and the knowledge base point to keeps working: the +# jobs of samples/run-all.js run against the BUILT binary on Linux and +# Windows (the samples ship as .sh and .ps1), including the byte-identity +# assertions of the deterministic and stream-parity jobs and the expected +# refusals (exit codes and E_* codes). Offline: the runner reads only the +# repository. No path filter (a required check must always report). + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sample-regression-${{ github.ref }} + cancel-in-progress: true + +jobs: + sample-regression: + name: sample-regression (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # 2026-10-18: one step serves the Windows leg (vendor matrix) + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Build + run: npm run build + + - name: Run every sample job + run: node scripts/gate.mjs --only samples --require-all diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 98d9e89..4d46809 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,12 +1,17 @@ name: Scorecard supply-chain security +# OpenSSF Scorecard: weekly and on every push to main, results published to +# the OpenSSF dataset and uploaded to code scanning. Least privilege at the +# top level; the job holds exactly what the action documents. + on: schedule: - cron: '27 3 * * 1' push: branches: [main, master] -permissions: read-all +permissions: + contents: read concurrency: group: scorecard-${{ github.ref }} @@ -18,19 +23,24 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 permissions: - security-events: write - id-token: write - contents: read - actions: read + security-events: write # Upload the SARIF results to the Security tab. + id-token: write # Publish signed results to the OpenSSF public dataset. + contents: read # Read repository metadata (branch protection, etc.). + actions: read # Read the workflow runs and their files, which several checks score. steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # 2026-10-18: the Scorecard API and the GitHub APIs it walks are not baselined yet + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run analysis - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif @@ -43,7 +53,8 @@ jobs: path: results.sarif retention-days: 5 + # The same codeql-action release as codeql.yml (one SHA across the tree). - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif diff --git a/.github/workflows/verazip.yml b/.github/workflows/verazip.yml index 8a17bfd..f19d1ec 100644 --- a/.github/workflows/verazip.yml +++ b/.github/workflows/verazip.yml @@ -2,7 +2,7 @@ name: ISO 21320-1 Validation (veraZIP) # veraZIP — zipnative-cli's analogue of pdfnative-cli's veraPDF gate. # -# WHAT: the BUILT binary (dist/cli.cjs) writes a corpus of 37 archives (33 conformant + 4 negative canaries) +# WHAT: the BUILT binary (dist/cli.cjs) writes a corpus of 41 archives (37 conformant + 4 negative canaries) # (scripts/generate-zip-corpus.mjs → test-output/zip/ + manifest.json) # covering a representative sample of the features the CLI exposes: # store/deflate, --stream, --parallel, --deterministic, zip64, directory @@ -78,6 +78,11 @@ jobs: timeout-minutes: 20 steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # 2026-10-18: audit on Windows (vendor matrix); the registry is the only host either leg needs + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -89,7 +94,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Record tool versions run: | @@ -146,11 +151,16 @@ jobs: if-no-files-found: warn retention-days: 14 + # Step outputs reach the script through env:, never interpolated into it + # (template injection — zizmor / actionlint). RUNNER_OS is set by the runner. - name: Job summary if: always() shell: bash + env: + EXIT_CODE: ${{ steps.validate.outputs.exit_code }} + OUTCOME: ${{ steps.validate.outcome }} run: | - code='${{ steps.validate.outputs.exit_code }}' + code="${EXIT_CODE}" case "${code}" in 0) verdict='all expectations met' ;; 1) verdict='CONFORMANCE failure (FAIL, XPASS, level-1 rejection or canary drift — see report)' ;; @@ -159,9 +169,9 @@ jobs: *) verdict="unknown (exit ${code:-n/a})" ;; esac { - echo "## ISO/IEC 21320-1 validation (veraZIP, blocking, ${{ runner.os }})" + echo "## ISO/IEC 21320-1 validation (veraZIP, blocking, ${RUNNER_OS})" echo - echo "Step outcome: ${{ steps.validate.outcome }} · exit ${code:-n/a} · ${verdict}" + echo "Step outcome: ${OUTCOME} · exit ${code:-n/a} · ${verdict}" echo echo '```' cat verazip-report.txt 2>/dev/null || echo "(no report produced)" @@ -182,6 +192,11 @@ jobs: timeout-minutes: 25 steps: + - name: Harden the runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit # 2026-10-18: audit on Windows (vendor matrix); the registry is the only host either leg needs + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -193,7 +208,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Record tool versions shell: pwsh @@ -238,11 +253,16 @@ jobs: if-no-files-found: warn retention-days: 14 + # Step outputs reach the script through env:, never interpolated into it + # (template injection — zizmor / actionlint). RUNNER_OS is set by the runner. - name: Job summary if: always() shell: bash + env: + EXIT_CODE: ${{ steps.validate.outputs.exit_code }} + OUTCOME: ${{ steps.validate.outcome }} run: | - code='${{ steps.validate.outputs.exit_code }}' + code="${EXIT_CODE}" case "${code}" in 0) verdict='all expectations met' ;; 1) verdict='CONFORMANCE failure (FAIL, XPASS, level-1 rejection or canary drift — see report)' ;; @@ -251,9 +271,9 @@ jobs: *) verdict="unknown (exit ${code:-n/a})" ;; esac { - echo "## ISO/IEC 21320-1 validation (veraZIP, blocking, ${{ runner.os }})" + echo "## ISO/IEC 21320-1 validation (veraZIP, blocking, ${RUNNER_OS})" echo - echo "Step outcome: ${{ steps.validate.outcome }} · exit ${code:-n/a} · ${verdict}" + echo "Step outcome: ${OUTCOME} · exit ${code:-n/a} · ${verdict}" echo echo '```' cat verazip-report.txt 2>/dev/null || echo "(no report produced)" diff --git a/.gitignore b/.gitignore index c6f9ff2..3469b80 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,13 @@ samples/output/ !.github/drafts/README.md !.github/drafts/TEMPLATE.md -# Local Claude Code state -.claude/ +# Claude Code: the shared settings and the release-audit skill are tracked; +# per-machine settings and worktrees are not +.claude/settings.local.json +.claude/worktrees/ + +# Release-audit ledgers (/release-audit) — local, never committed +.audit/ # Environment files — never commit secrets .env @@ -35,6 +40,5 @@ Thumbs.db *.swo .vscode/settings.json -# npm +# npm (the project .npmrc IS tracked: ignore-scripts, engine-strict, audit level) *.tgz -.npmrc diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7c7e7e8 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +ignore-scripts=true +engine-strict=true +audit-level=moderate +fund=false diff --git a/AGENTS.md b/AGENTS.md index 7bedca6..d86e213 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ with its frozen `ZIP_*` code intact. ## 1. The process contract -**Prerequisite:** Node.js ≥ 22 (check with `zipnative doctor`). +**Prerequisite:** Node.js `^22.17.1 || >=24.4.1` — a security patch floor (check with `zipnative doctor`). | Channel | Carries | |---------|---------| @@ -31,10 +31,24 @@ Keep stdout binary-clean: write archives and entries to `--output ` / **Flags are order-independent.** `zipnative --json list a.zip`, `zipnative list --json a.zip` and `zipnative list a.zip --json` are the same invocation: a boolean flag never consumes the token after it, `--flag=false` -(`0`, `no`, `off`) is the explicit off form, and combined short flags (`-lq`) -are refused with exit 2. Short aliases that take a value: `-i --input`, +(`0`, `no`, `off`) is the explicit off form and `--flag=true` (`1`, `yes`, `on`) the +explicit on form — any other value is refused with exit 2 — and combined short flags +(`-lq`) are refused with exit 2. `schema manifest` lists each command's `switches`, +`repeatable` and `pathFlags` and its `operands`, so a tool schema needs no `--help` +scraping. Short aliases that take a value: `-i --input`, `-o --output`, `-d --output-dir`, `-e --entry`, `-f --format`; boolean short -aliases: `-q`, `-h`, `-V`. There is no `-l`. +aliases: `-q`, `-h`, `-V` — an alias means its long flag on every command that +declares it. There is no `-l`. + +**The invocation is checked before anything runs (1.1.0).** A flag the command +does not declare (`--overwite`), a switch given twice, a single-value flag given +twice, a flag given both bare and with a value, more positionals than the command +takes, or an operand together with its flag (`list a.zip --input b.zip`) is +`E_USAGE` (exit 2) — never a silent no-op. Every `batch --manifest` task gets the +same check, for all tasks before the first one runs. `zipnative schema manifest` +lists each command's flags; repeatable flags are `--include`, `--exclude`, +`--entry`, `--check`, `--cat`, `--input` (create, crc32), `--add`, `--add-dir`, +`--remove`, `--rename`, `--replace`, `--entry-comment` and `--codec`. **Environment.** The global flags set process-wide variables that the same process reads back, and you may set them yourself instead of passing the flag: @@ -46,7 +60,11 @@ process reads back, and you may set them yourself instead of passing the flag: | `ZIPNATIVE_QUIET=1` | `--quiet` | | | `ZIPNATIVE_STRICT=1` | `--strict` | | | `ZIPNATIVE_PURE_CODECS=1` | `--pure-codecs` | | +| `ZIPNATIVE_PROGRESS=1` | `--progress` | NDJSON `{"type":"progress",…}` lines on stderr under `--json` | | `ZIPNATIVE_DEBUG=1` | — | stack traces on stderr | + +Each `ZIPNATIVE_*` switch takes `1|true|yes|on` or `0|false|no|off` (empty = off); any other +value is `E_USAGE` (exit 2) — `ZIPNATIVE_DRY_RUN=ture` never silently writes. | `NO_COLOR`, `FORCE_COLOR`, `TERM` | `--no-color` | colour decision, see above | (`VERAZIP_REQUIRED`, `VERAZIP_REPORT_DIR`, `VERAZIP_TOOLS` are read by the @@ -54,15 +72,31 @@ conformance scripts only.) **Hermetic invocation.** `.zipnativerc.json` is discovered by walking up from the working directory to the filesystem root, so an unattended call inherits whatever a -parent directory holds (`extract: { overwrite: true }`, `max-ratio: none`, …). Always -pass `--no-config` (or `--config `) in a pipeline; an explicit flag always wins -over the file, and the `codec` key is refused from any config file. +parent directory holds. Since 1.1.0 a config file is **presentation only** — `format`, +`fields`, `json`, `pretty`, `quiet`, `no-color`, `progress`, `strict`, `long`, +`summary` — and every other key (`overwrite`, `skip-unsafe`, `max-*`, paths, `codec`, +…) makes the run fail with `E_USAGE` naming the file. It can therefore change the +SHAPE of your output, not its safety: still pass `--no-config` (or `--config `) in +a pipeline so stdout is exactly what you asked for. When a file did apply defaults, the +status and error envelopes carry `config: { path, applied: [keys] }` and text mode prints +`info: defaults from : …`; a read report under `--json` (`list`, `inspect`, `verify`, +`stream`) names it nowhere — one more reason for `--no-config`. An explicit flag always wins +over the file. **Parse stderr line by line.** Under `--json` stderr is newline-delimited and may still -carry text lines (`warning:` progress for skipped entries, `--codec` / `none` notices, -NDJSON diagnostics, `ZIPNATIVE_DEBUG` traces). Each line is either a JSON object -(starts with `{`) or text; the envelope is the *last line that starts with `{`*. Never -`JSON.parse` the whole stream. `--quiet` removes the text lines, never an envelope. +carry text lines (`warning:` progress for skipped entries, `--codec` notices, NDJSON +diagnostics, `ZIPNATIVE_DEBUG` traces) and, with `--progress`, NDJSON objects +`{ "type": "progress", "command", "entriesDone", "entriesTotal", "bytesIn", "bytesOut" }`. +Each line is either a JSON object (starts with `{`) or text; the envelope is the *last +line that starts with `{` and has an `ok` key*. Never `JSON.parse` the whole stream. +`--quiet` removes the text and progress lines, never an envelope. + +**Text mode is terminal-safe.** In text output entry names, comments and messages are +printed with control, bidi and invisible characters escaped (`\x1b`, `\u202e`), and a +name's own TAB / LF escaped too (one entry, one row); JSON carries the +exact text and `--long` the bytes (`rawNameHex`). A text-mode error leads with its class +and cause — `error E_DATA (ZIP_CRC_MISMATCH): …`, `error E_USAGE: …` — then `remedy: …` +when one exists. **Edge cases you can rely on.** @@ -117,19 +151,23 @@ to their JSON format and compact it). { "ok": false, "command": "list", "error": { "code": "E_LIMIT", "message": "\"a.zip\" exceeds --max-input-size (10 bytes; observed 3361). …", "detail": { "limit": "maxInputSize", "configured": 10, "observed": 3361 } } } ``` +```json +{ "ok": false, "command": "create", "error": { "code": "E_LIMIT", "message": "Timed out after 1 ms (--timeout); the operation was stopped at the next chunk or entry boundary and any file being written was removed.", "detail": { "limit": "timeout", "configured": 1, "observed": 12 }, "remedy": "--timeout (a larger deadline)" } } +``` + `error.code` is the **class** (13 values, below), `error.zipCode` is the exact **cause** — zipnative's frozen `err.code`, verbatim (39 values). `entryName` and `detail` appear when known: `{ limit, configured, observed }` for `E_LIMIT` (the engine's `ZipLimits` keys, or the CLI's own `maxInputSize` / -`captureBytes`), `{ feature }` for `E_UNSUPPORTED`, `{ expectedCrc, actualCrc }` +`captureBytes` / `timeout` — milliseconds for the last), `{ feature }` for `E_UNSUPPORTED`, `{ expectedCrc, actualCrc }` for `E_DATA` / `crc32 --expect`. -**On success**, `create` / `modify` / `extract` / `stream` (extract and cat +**On success**, `create` / `modify` / `canonicalize` / `merge` / `extract` / `stream` (extract and cat modes, or list mode under `--dry-run`) / `cat` / `inflate` / `crc32` write a status line to stderr (`zipnative schema status`): ```json -{ "ok": true, "command": "create", "output": "out.zip", "entries": 12, "files": 11, "directories": 1, "bytesIn": 131072, "method": "deflate", "level": 6, "deterministic": true, "order": "canonical", "stream": false, "layout": "buffered", "parallel": false, "skipped": [], "dryRun": false, "bytes": 48213, "tier": "pure-pinned", "diagnostics": [] } +{ "ok": true, "command": "create", "output": "out.zip", "entries": 12, "files": 11, "directories": 1, "bytesIn": 131072, "method": "deflate", "level": 6, "deterministic": true, "order": "canonical", "stream": false, "zip64": false, "layout": "buffered", "parallel": false, "skipped": [], "dryRun": false, "bytes": 48213, "tier": "pure-pinned", "diagnostics": [] } ``` ```json @@ -144,8 +182,10 @@ Command-specific fields (all confirmed against the built binary): | Command | Envelope fields beyond `ok`, `command`, `dryRun`, `diagnostics` | |---------|-------------------------------------------------------------------| -| `create` | `output`, `entries`, `files`, `directories`, `bytesIn`, `method`, `level`, `deterministic`, `order`, `stream`, `layout: "buffered" \| "data-descriptor"` (streamed entries), `parallel: false \| { workers }`, `skipped: [{ name, path, reason: "symlink" \| "special" \| "filtered" }]`, `tier` and `bytes` (both absent under `--dry-run`; under `--parallel` `tier` is `node-zlib` or `pure-pinned`, never `injected`) | -| `modify` | `output`, `bytes`, `edits: [{ op, name, to? }]` (a binary comment shows as `""`), `layout: "append-only" \| "compact"`, `changed` (false when the save returned the same bytes), `verified` (untouched entries verified before re-emission), `verifySkipped` (encrypted / stream-only-codec entries copied as-is), `tier` (deflate tier of new payloads) | +| `create` | `output`, `entries`, `files`, `directories`, `bytesIn`, `method`, `level`, `deterministic`, `order`, `stream`, `zip64` (`--zip64`: streamed entries carry a Zip64 local header), `layout: "buffered" \| "data-descriptor"` (streamed entries), `parallel: false \| { workers }`, `skipped: [{ name, path, reason: "symlink" \| "special" \| "filtered" }]`, `tier` and `bytes` (both absent under `--dry-run`; under `--parallel` `tier` is `node-zlib` or `pure-pinned`, never `injected`) | +| `canonicalize` | `output`, `entries`, `bytes`, `changed` (the output differs from the input), `deterministic` / `canonicalForm` (`analyzeDeterminism()` on the OUTPUT), `unsafeNames` (hostile names kept as they are — reproducible is not safe), `encrypted` | +| `merge` | `output`, `inputs`, `entries`, `files`, `directories`, `order`, `layout`, `skipped: [{ name, input, reason: "filtered" \| "duplicate" \| "unsupported" }]`, `bytes` (absent under `--dry-run`) | +| `modify` | `output`, `bytes`, `edits: [{ op, name, to? }]` (a binary comment shows as `""`), `layout: "append-only" \| "compact"`, `canonical` (`--compact --canonical`), `changed` (false when the save returned the same bytes), `verified` (untouched entries verified before re-emission), `verifySkipped` (encrypted / stream-only-codec entries copied as-is), `tier` (deflate tier of new payloads) | | `extract` | `outputDir`, `entries`, `files`, `directories`, `bytes`, `skipped: [{ name, reason: "unsafe-path" \| "symlink" \| "filtered" \| "duplicate" \| "unsupported" }]`, `symlinksAsData` | | `stream` | `mode`, `trust: "local-headers-only"`, `outputDir?`, `entries`, `bytes?` (extract / cat modes), `skipped?: [{ name, reason: "unsafe-path" \| "filtered" \| "duplicate" \| "unsupported" }]`, `stoppedAt: "central-directory" \| "eof"` | | `cat` | `output` (`"-"` for stdout), `entries: [names]`, `bytes`, `raw`, `verifyCrc` | @@ -162,19 +202,32 @@ selects / compacts the JSON format. Shapes worth knowing: - `list` / `inspect`: `archive.commentHex` (the raw comment bytes) whenever `commentBytes > 0` — `comment` is the lossy UTF-8 decode; `--long` rows carry `rawNameHex` (always) and `commentHex` (when the entry has a comment); - `unixMode` is four octal digits (`"0644"`, `"4755"`) or `null`. + `unixMode` is four octal digits (`"0644"`, `"4755"`) or `null`. `--long` rows also + carry `timestamps: { mtime, atime, ctime, source: "ut" | "ntfs" | "dos" }` (the + most precise stored clock: UT `0x5455`, NTFS `0x000a` or DOS; `atime` / `ctime` + `null` when absent) and + `unixIds: { uid, gid }` or `null` (the `0x7875` extra field). Under + `--name-encoding