Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
commitlint:
name: Commitlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Validate the last commit with commitlint
if: github.event_name == 'push'
run: pnpm exec commitlint --last --verbose
- name: Validate every PR commit with commitlint
if: github.event_name == 'pull_request'
run: pnpm exec commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose

lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint

typecheck:
name: Typecheck
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm build
- run: pnpm exec attw --pack

test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
# Cloudflare Workers (workerd) runtime test -- pnpm install above already built the workerd binary (allowBuilds in pnpm-workspace.yaml), so this exercises the archive-codec surface inside a Workers isolate, enforcing zero Node-only API usage on the tested paths at runtime.
- run: pnpm test:workers

test-smoke:
name: Smoke test
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build && node -e "require('./dist/index.cjs')" && node --input-type=module -e "import('./dist/index.js').then(m => console.log(Object.keys(m).length + ' exports'))"

release:
name: Release
needs: [commitlint, lint, typecheck, test, test-smoke]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # to push the release commit/tag and create the GitHub Release
issues: write # to comment on released issues
pull-requests: write # to comment on released pull requests
id-token: write # OIDC identity for npm trusted publishing (no NPM_TOKEN)
outputs:
published: ${{ steps.before.outputs.version != steps.after.outputs.version }}
version: ${{ steps.after.outputs.version }}
steps:
# main requires status checks to merge, and the default GITHUB_TOKEN has no bypass for that -- @semantic-release/git's own push of the release commit is a direct push to main, so it needs a token from an actor the branch ruleset explicitly allows to bypass (see ExaDev/.github and each repo's own ruleset). Everything else in this job (release notes, GitHub Release creation, issue/PR comments, OIDC npm publish) keeps using secrets.GITHUB_TOKEN below, unaffected -- only checkout's own git credentials need the elevated token, since that's what the later `git push` inherits.
- name: Generate a token for the release push
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: "4473709"
private-key: ${{ secrets.AUTOMERGE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
# semantic-release analyses the full commit history since the last release.
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
# registry-url is deliberately absent. Setting it makes setup-node write an .npmrc containing an _authToken line, and that line wins over the OIDC token exchange -- so the setting that looks like it configures the registry is exactly the one that would stop trusted publishing working.
- run: pnpm install --frozen-lockfile
- name: Read pre-release version
id: before
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1)
run: npm install -g npm@latest
- name: Release
# HUSKY=0 so the commit-msg hook never fires against the automated release commit.
run: HUSKY=0 pnpm exec semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Blanked, not omitted -- an inherited NPM_TOKEN/NODE_AUTH_TOKEN from a workflow-level env block, reusable workflow, or composite action would otherwise be used in preference to the OIDC exchange.
NPM_TOKEN: ''
NODE_AUTH_TOKEN: ''
- name: Read post-release version
id: after
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"

publish-github-packages:
name: Publish alias to GitHub Packages
needs: release
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: write
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v7
with:
ref: main # the release commit semantic-release just pushed
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
# Deliberately NOT setup-node's own registry-url/scope inputs (see ExaDev/documents.js#309): those write an @exadev:registry=https://npm.pkg.github.com/ *install-time* scope-to-registry mapping into .npmrc, and per setup-node's own docs an empty/omitted `scope` still falls back to the repository owner (ExaDev) when the target is GitHub Packages -- there is no way to get just the auth-token line out of that mechanism without also redirecting every @exadev-scoped *install* through it, which breaks `pnpm install` below: @exadev/eslint-config is a real devDependency published only to the default registry, never mirrored to GitHub Packages. publishConfig.registry (set explicitly below) already fully determines pnpm publish's *target* registry on its own; only the host-scoped auth token below is genuinely needed, added after install so install never sees any @exadev scope mapping at all.
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Rewrite package name and registry for the GitHub Packages scope
# GitHub Packages requires the npm package name to be scoped to the repo owner. Rewriting the fields rather than keeping a second package.json means this alias cannot drift away from the real package's metadata. publishConfig.registry has to be overridden too: it takes precedence over the .npmrc registry-url set by setup-node above, so without this the publish silently targets registry.npmjs.org instead -- confirmed failure mode, not a hypothetical (404 on the npmjs.org registry, since GITHUB_TOKEN isn't a credential it recognises).
run: |
npm pkg set name="@exadev/archive-codec"
npm pkg set publishConfig.registry="https://npm.pkg.github.com"
- name: Configure the GitHub Packages auth token for publish only
run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
- run: pnpm publish --access public --no-git-checks

attest:
name: Attest SBOM and build provenance
needs: release
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@v7
with:
ref: main # the release commit semantic-release just pushed
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
# Pack into a directory of its own, separate from dist/ (tsdown's raw build output). The attestation subject has to be the artefact that actually ships -- attesting dist/ itself would mix in files that never leave the repo, producing digests that match nothing a consumer can download.
- run: pnpm pack --pack-destination release-artifact
- run: pnpm sbom --sbom-format spdx --prod > release-artifact/sbom.spdx.json
# attest-sbom/attest-build-provenance are deprecated wrappers around actions/attest; called as two steps (sbom-path present vs. absent) rather than one, since actions/attest's docs don't clearly state whether passing both together attests SBOM and provenance in a single call or SBOM only -- two steps is the unambiguous equivalent of what this replaced.
- name: Attest SBOM
uses: actions/attest@v4
with:
subject-path: release-artifact/*.tgz
sbom-path: release-artifact/sbom.spdx.json
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-path: release-artifact/*.tgz
1 change: 1 addition & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pnpm exec commitlint --edit "$1"
2 changes: 2 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Lint and auto-fix staged TypeScript files.
pnpm exec lint-staged
2 changes: 2 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Run the full test suite before push to catch breakage early.
./node_modules/.bin/vitest run
1 change: 1 addition & 0 deletions AGENTS.md
1 change: 1 addition & 0 deletions CLAUDE.md
65 changes: 60 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,73 @@
# archive-codec

> Recursive archive walking for the [documents.js family](https://github.com/ExaDev): detects ZIP-in-ZIP entries, walks them recursively under explicit depth and total-decompressed-size caps (zip-bomb guards), and returns a flat listing of every inner entry with its ancestor chain. Zero document-format knowledge; Worker-isomorphic (the same code runs under Node and inside a Cloudflare Workers isolate).
[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/archive-codec) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/archive-codec) [![Release](https://img.shields.io/github/v/release/ExaDev/archive-codec)](https://github.com/ExaDev/archive-codec/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/archive-codec/ci.yml?branch=main)](https://github.com/ExaDev/archive-codec/actions)

> Recursive archive (ZIP-in-ZIP) detection and walking with depth and cumulative decompressed-size guards — zero document-format knowledge, the archive utility package for the [documents.js family](https://github.com/ExaDev). Worker-isomorphic: the same code runs under Node and inside a Cloudflare Workers isolate.

Created for [documents.js#564](https://github.com/ExaDev/documents.js/issues/564): nothing in the ecosystem recursed into a nested archive. Most concretely, OOXML's embedded-object model — a docx/pptx carrying a genuinely separate ZIP blob at `word/embeddings/oleObject1.xlsx` — had no safe handling anywhere, and no package guarded against recursive-archive inputs at all (`byte-codec`'s 512 MiB per-stream inflate cap does not compose across recursion). A new sibling was chosen over extending `byte-codec` (whose charter is byte/image primitives, zero container-format knowledge) or doing it inline in `documents.js` (which would repeat the duplication `byte-codec`'s own extraction was meant to avoid). No family package depends on it yet; `documents.js` consuming it for OOXML embedded objects is planned follow-up work.

Scope for v1: **ZIP containers only** — read and write over [`fflate`](https://github.com/101arrowz/fflate), recursive walking of ZIP-in-ZIP entries, and archive-format detection (ZIP vs not-ZIP). **tar and gzip are explicitly out of scope.**

## Getting started

Requires Node.js `>=20` and pnpm `11.6.0`.

```sh
pnpm install
pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts)
pnpm typecheck # tsc -p tsconfig.json && tsc -p tsconfig.node.json (dual tsconfig)
pnpm lint # eslint . --fix --cache --max-warnings 0
pnpm test # vitest run
pnpm test:watch # vitest
pnpm test:workers # vitest run --config vitest.workers.config.ts, inside a real Cloudflare Workers (workerd) isolate
```

To run a single test file, pass its path to vitest directly, e.g. `pnpm exec vitest run src/zip/walk.test.ts`.

## What it provides

| Module | Exports |
|---|---|
| `zip/container` | `zipPackage` (ordered-entries ZIP write with stored-uncompressed support), `unzipPackage`, `ZipEntry` |
| `zip/detect` | `detectArchiveFormat` (`'zip' \| 'unknown'`), `isZipArchive`, `ArchiveFormat` |
| `zip/walk` | `walkArchive` (recursive ZIP-in-ZIP walking), `ArchiveWalkEntry`, `ArchiveWalkLimitError`, `MAX_WALK_DEPTH`, `MAX_WALK_TOTAL_BYTES`, `WalkArchiveOptions` |

### Recursive walking

```ts
import { walkArchive } from 'archive-codec';

// Every entry of every nested ZIP, flattened; throws if the walk exceeds
// the depth cap or the cumulative decompressed-bytes budget.
// Every entry of every nested ZIP, flattened. Throws ArchiveWalkLimitError if
// the walk exceeds the depth cap or the cumulative decompressed-bytes budget.
for (const entry of walkArchive(docxBytes)) {
entry.path; // e.g. 'xl/workbook.xml' within its own archive
entry.path; // e.g. 'xl/workbook.xml', the path within its own archive
entry.ancestors; // e.g. ['word/embeddings/oleObject1.xlsx'] -- the nested
// ZIP entries descended through to reach this one
entry.bytes; // decompressed content
}
```

Scope for v1: ZIP containers only (read and write, over [fflate](https://github.com/101arrowz/fflate)); tar and gzip are explicitly out of scope. MIT licensed.
Both guards throw rather than truncate: an input outside the contract must fail loudly, never return a partial listing that looks complete. The defaults are `MAX_WALK_DEPTH` (8 — real producers bottom out around depth 3; the motivating OOXML embedded-object case is depth 2) and `MAX_WALK_TOTAL_BYTES` (512 MiB cumulative across every nesting level — the same figure `byte-codec` grants a single stream, re-purposed as one shared budget so a bomb's multiplicative nesting leverage becomes bounded addition). Both are overridable per call via `walkArchive(bytes, { maxDepth, maxTotalBytes })`, and each constant's derivation is stated in its source comment.

### ZIP container

`zipPackage` takes an *ordered* array of `[path, entry]` tuples, not a `Record`, so the caller controls the exact emission order deterministically (the property formats with a fixed-offset first entry — ODF's `mimetype` — depend on), and any entry can be written stored-uncompressed via `stored: true`. `unzipPackage` is the read side; the returned `Record` makes no ordering promise and collapses duplicate paths.

## Conventions

- Worker-isomorphic (see the [family-wide convention](https://github.com/ExaDev/documents.js/blob/main/README.md#conventions)): runtime `src/` must not import `node:*`, a bare Node builtin, or use the `Buffer` global — enforced by a `no-restricted-imports`/`no-restricted-globals` ESLint rule and exercised in CI by running the test suite inside an actual `workerd` isolate (`pnpm test:workers`). Test files under `src/**/*.test.ts` and `src/test-support/` are exempt and may use Node APIs for fixtures.
- Only `src/index.ts` may be named `index.*` — a custom ESLint rule (`local/no-non-barrel-index`) rejects any other module using an `index` basename, since that would be a hidden entry point the `exports` map in `package.json` doesn't advertise.
- Zero document-format knowledge: this package knows bytes and ZIP structure, never that any entry is a document. It depends only on `fflate` — not on `byte-codec`, `ooxml.js`, or `odf.js` (whose ZIP wrappers it deliberately mirrors rather than imports, keeping their branding and release cadences decoupled).
- Releases are fully automated: a push to `main` runs `semantic-release` in CI, which determines the version from Conventional Commit messages and publishes to npm via OIDC trusted publishing (no local `NPM_TOKEN` needed). There is no manual publish step.

## Install

```sh
pnpm add archive-codec
# or
npm install archive-codec
```

## License

MIT
10 changes: 10 additions & 0 deletions commitlint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { commitTypes } from './release.config';

export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', commitTypes.map((t) => t.type)],
},
// dependabot-auto-merge.yml lands Dependabot's rebase-merged commits on main verbatim, including its generated Bumps/Release notes/Changelog body, which routinely contains a markdown link line over the inherited body-max-line-length limit and cannot be reformatted by this repo. Skip linting entirely for those commits, identified by the "Signed-off-by: dependabot[bot]" trailer that fetch-metadata's auto-merge always appends, while leaving every rule fully enforced for human-authored commits.
ignores: [(message: string) => /^Signed-off-by: dependabot\[bot\]/m.test(message)],
};
Loading