@blamejs/blamejs-shop 0.0.85 → 0.0.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/lib/admin.js +11 -7
- package/lib/customer-import.js +1 -1
- package/lib/pwa-manifest.js +1 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/.github/workflows/sha-to-tag-verify.yml +8 -0
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +18 -2
- package/lib/vendor/blamejs/lib/archive-gz.js +229 -0
- package/lib/vendor/blamejs/lib/archive-tar.js +14 -0
- package/lib/vendor/blamejs/lib/archive.js +9 -4
- package/lib/vendor/blamejs/lib/backup/index.js +54 -14
- package/lib/vendor/blamejs/lib/safe-archive.js +16 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.9.json +61 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-gz.test.js +159 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +53 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,32 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.98 (2026-05-23) — **Restore the missing closing brace on `_warmingHtml` — wrangler bundle was failing at EOF.** An earlier patch trimmed too many trailing lines from `worker/index.js` while removing a detector-injection leftover, accidentally taking the closing `}` of `_warmingHtml` along with it. `node --check` parsed the file fine (Node's parser is more forgiving at EOF) but wrangler's esbuild reported `Unexpected end of file` because the function declaration was unterminated. v0.0.95 / v0.0.97 builds both failed on Cloudflare Workers Builds because of this. The brace is back. The smoke + detector gates passed at every prior ship because Node's parser tolerated the missing brace; adding a brace-balance gate to smoke is the next-shipped follow-up. **Fixed:** *`_warmingHtml`'s closing brace restored* — The function body's string-concat return statement ends on the same line as the closing brace in the source template, and an earlier `head -n -N` trim that was meant to undo a detector-injection took the brace along with the injection. Node's `node --check` accepted the unterminated declaration (its parser doesn't require the brace at EOF in the same way esbuild does) but wrangler / esbuild refused with `Unexpected end of file`. v0.0.95 and v0.0.97 Cloudflare Workers Builds deploys both failed on this — they're now unblocked.
|
|
12
|
+
|
|
13
|
+
- v0.0.97 (2026-05-23) — **Guest `/cart` on the edge + emergency fix for stray detector-injection leftover that broke wrangler bundling on v0.0.95.** v0.0.95 shipped with a one-line leftover at the bottom of `worker/index.js` — a stray `function _testCatchReturnNull() {` from an earlier detector-verification injection that wasn't fully cleaned up. `node --check` accepted the unterminated declaration but wrangler's esbuild refused with `Unexpected end of file`, blocking the v0.0.95 auto-deploy on Cloudflare Workers Builds. This patch removes the leftover and ships v0.0.96's guest-`/cart` edge route at the same time. The release-notes/v0.0.96.json file lands in this ship too (PR #96 was closed in favour of this consolidated release). **Added:** *Guest `/cart` rendered at the edge (carried from the closed PR #96)* — GET `/cart` for visitors without a `shop_sid` / `shop_auth` cookie now renders the empty-cart page directly at the edge — same `worker/render/cart.js#renderCart` that ships in the Worker bundle, called with `lines: []` and zero totals. Cookie-bearing visitors fall through to the container path which has the framework's vault keychain and can decrypt the sealed session to look up the real cart row. Session-cookie detection reuses `_hasSessionCookie` — same predicate that's gated the edge cache since v0.0.78. **Fixed:** *Stray `function _testCatchReturnNull() {` removed from `worker/index.js`* — A detector-verification injection that should have been fully reverted left a single unterminated `function` declaration at EOF. Node's parser tolerates the shape (`node --check` returned OK) but wrangler's esbuild reports `Unexpected end of file` and refuses to build, blocking the v0.0.95 Cloudflare Workers Builds deploy. The leftover is gone.
|
|
14
|
+
|
|
15
|
+
- v0.0.96 (2026-05-23) — **Guest `/cart` rendered at the edge — no session cookie, no container hop.** GET `/cart` for visitors without a `shop_sid` / `shop_auth` cookie now renders the empty-cart page directly at the edge — the same `worker/render/cart.js#renderCart` that ships in the Worker bundle, called with `lines: []` and zero totals. Visitors with a session cookie fall through to the container path which has the framework's vault keychain and can decrypt the sealed session to look up the real cart row. The session-cookie presence check uses the same `_hasSessionCookie` predicate that's gated the edge cache since v0.0.78, so guest detection is consistent across both paths. **Added:** *Guest `/cart` rendered at the edge via the bundled `renderCart`* — When `_hasSessionCookie(request)` returns false (no `shop_sid` or `shop_auth` in the cookie header), the dispatcher routes `/cart` to `_edgeCartEmpty` instead of `_forwardToContainer`. The handler invokes `renderCart` with `lines: []`, zero totals, an empty `productLookup` — the same `CART_EMPTY_PAGE` branch a freshly-arrived visitor would have seen via the container, served in ~90ms instead of ~700ms cold. Authenticated visitors (cookie present) continue through the container path, where the framework's vault decrypts the session and looks up the real cart row. Security headers + HTML minification + the edge-error fallback all apply uniformly via the existing `_html` + `_edgeError` helpers.
|
|
16
|
+
|
|
17
|
+
- v0.0.95 (2026-05-23) — **Security headers on every edge response — closes the gap with the container's `b.middleware.securityHeaders` pipeline.** Audit found a real regression introduced when storefront read routes moved to the edge in v0.0.76. The container's `b.middleware.securityHeaders` pipeline attaches `Content-Security-Policy`, `Permissions-Policy`, `Referrer-Policy`, `X-Frame-Options`, `Cross-Origin-{Opener,Resource}-Policy`, `Origin-Agent-Cluster`, `X-DNS-Prefetch-Control`, `X-Content-Type-Options`, and `Strict-Transport-Security` to every response. Edge-rendered routes bypassed that pipeline and carried only the Cloudflare-zone defaults (`HSTS` + `X-Content-Type-Options`) — losing CSP, Permissions-Policy, Referrer-Policy, and X-Frame-Options against the storefront's most-touched surface. This patch attaches the canonical set on every Worker response (HTML / RSS / sitemap.xml / 4xx / 5xx / warming page), restoring the security posture the framework's pipeline documents. **Security:** *Edge responses now carry the canonical security-header set — CSP / Permissions-Policy / Referrer-Policy / X-Frame-Options* — `worker/index.js` defines `_SECURITY_HEADERS` mirroring the literal values the framework's `b.middleware.securityHeaders` pipeline emits (`lib/vendor/blamejs/lib/middleware/security-headers.js#DEFAULT_*`). The shared `_withSecurityHeaders(base)` helper merges the canonical set with per-response headers (content-type, cache-control, retry-after, etc.) without callers having to know each one. Applied to every Worker-constructed Response: storefront read HTML (`_html`), static policy pages (`_staticHtml`), edge 500 (`_edgeError`), edge 404 (product + blog), `/sitemap.xml`, `/feed.xml`, `/newsletter` POST results, the container-warming page, and both XML/text 503 fallbacks. Container responses already carried these headers via the framework pipeline; edge responses now match.
|
|
18
|
+
|
|
19
|
+
- v0.0.94 (2026-05-23) — **Schema.org JSON-LD on product + blog-article pages — rich-result eligibility for Google / Bing.** Every edge-rendered `/products/:slug` now carries a `<script type="application/ld+json">` block with the Schema.org Product graph: `name`, `description`, `image`, `sku`, and an `AggregateOffer` collapsing the per-variant price range into `lowPrice` / `highPrice` / `offerCount` with the correct currency. Every `/blog/:slug` carries an Article graph: `headline`, `image`, `datePublished` / `dateModified` (ISO 8601), `author`, `description`. Embedded JSON survives the inline-in-HTML XSS surface via the `</` → `<\/` rewrite the new `jsonLdScript` helper applies; standard `JSON.stringify` covers the `"` / `\` escapes. Composes through `worker/render/_lib.js#jsonLdScript`. **Added:** *`jsonLdScript(data)` helper in `worker/render/_lib.js`* — Serialises a JS object as a `<script type="application/ld+json">` block. `JSON.stringify` handles `"` and `\` escapes; an additional `</` → `<\/` rewrite neutralises any literal `</script>` substring inside a value (defense-in-depth — Schema.org fields don't ship HTML in the supported vocabulary, but the rewrite is the canonical XSS escape for inline JSON-in-HTML). Throws `TypeError` on null / non-object input. · *Schema.org `Product` JSON-LD on `/products/:slug`* — Surfaces in Google's product-rich-result panel (price + availability), Bing Shopping, etc. The aggregate-offer block collapses every variant's price into `lowPrice` / `highPrice` / `offerCount` with the correct currency. Decimal precision adjusts for zero-decimal currencies (JPY / KRW divide by 1, format as integer; everything else divides by 100, two decimals). `image` array carries the hero media's full URL when present. · *Schema.org `Article` JSON-LD on `/blog/:slug`* — Surfaces in Google's article-rich-result panel. Fields: `headline`, `image` array (falls back to brand logo when no hero), `datePublished` + `dateModified` as ISO 8601 strings (composed from the article row's `published_at` + `updated_at` epoch-ms columns), `author` as a `Person` graph, `description` from `meta_description` or the first 240 chars of the body.
|
|
20
|
+
|
|
21
|
+
- v0.0.93 (2026-05-23) — **HTML minification at render time — ~10% transfer-byte reduction on every edge-rendered page.** `worker/render/_lib.js` exports `minifyHtml(html)` — a conservative inter-tag whitespace collapser that strips the indentation the source templates carry for readability. Preserve-tag content (`<pre>`, `<script>`, `<style>`, `<textarea>`, `<code>`) stays untouched because whitespace inside those tags is semantically meaningful. The minifier runs on every `_html()` + `_staticHtml()` response from the Worker; cached responses store the minified bytes so the cache-hit path serves smaller payloads too. Empty-home page goes from 17560 → 15840 bytes (-9.8%); populated pages see proportionally larger reductions because per-product card HTML is indentation-heavy. **Added:** *`minifyHtml(html)` in `worker/render/_lib.js` — conservative inter-tag whitespace collapse* — Stashes every `<pre>` / `<script>` / `<style>` / `<textarea>` / `<code>` block behind sentinel tokens (control-character sequences impossible in operator-rendered HTML), runs a `>\s*\n\s*<` → `><` collapse on the remainder, then reinstates the preserved blocks. Text content untouched (text never carries `>...<` boundaries internally). The minifier is composable from any render module that wants minification per-output, and `_html()` + `_staticHtml()` in `worker/index.js` apply it to every edge-rendered response. · *Edge-rendered + cached responses serve the minified bytes* — `_html(body, method)` now wraps the body in `_minify(body)` before constructing the Response. Cached responses in `caches.default` store the minified bytes, so repeat visitors at the same PoP get the already-shrunk payload. Empty-home page: 17560 → 15840 bytes (-9.8%); populated pages compress proportionally more because per-product card HTML in `worker/render/home.js` is multi-line + indented.
|
|
22
|
+
|
|
23
|
+
- v0.0.92 (2026-05-23) — **POST `/newsletter` on the edge — every page's footer submit no longer hits the container.** The footer newsletter form lives on every storefront page. The POST submit used to hop through the container's storefront mount; this patch wires it directly into the Worker. Email goes through `b.guardEmail.validateAddress` (RFC 5322 single-address validator with header-injection defense). The normalized address hashes via `b.crypto.namespaceHash("newsletter-signup", ...)` so the raw email never lands on disk. Insert is idempotent via D1's `ON CONFLICT(email_hash) DO NOTHING`; the result thank-you renders `new` vs `existing` based on the row-count delta. Sec-Fetch-Site gates cross-site POSTs (CSRF defense); content-type allowlist is `application/x-www-form-urlencoded` only. **Added:** *Edge `POST /newsletter` handler composed off `b.guardEmail` + `b.crypto.namespaceHash` + `b.uuid.v7`* — `worker/index.js` gains a `_edgeNewsletter(request, env)` handler routed when `pathname === "/newsletter" && method === "POST"` and `env.EDGE_RENDER === "on"`. The handler: (1) refuses Sec-Fetch-Site `cross-site` POSTs; (2) refuses non-`application/x-www-form-urlencoded` content; (3) parses the form body via `URLSearchParams`; (4) validates the email through `b.guardEmail.validateAddress` (refused-shape errors render the operator-facing error page without leaking the validator's internal code); (5) lower-cases the address and hashes it via `b.crypto.namespaceHash("newsletter-signup", normalized)`; (6) generates a row id via `b.uuid.v7`; (7) issues an idempotent insert against `newsletter_signups` with `ON CONFLICT(email_hash) DO NOTHING`; (8) renders `renderNewsletterThanks` differentiating `new` from `existing` based on `r.meta.changes > 0`. All `b.*` primitives compose through the validated `worker/b.js` adapter — no inline reinvention. · *`worker/b.js` exposes `b.guardEmail`* — The framework's email-shape gate ships as `lib/vendor/blamejs/lib/guard-email.js`; this patch adds it to the Worker-side adapter's surface so edge POST handlers compose it the same way server-side code does. The leaf module's transitive requires (`codepoint-class`, `lazy-require`, `gate-contract`, `constants`, `numeric-bounds`, `framework-error`) bundle cleanly under wrangler + nodejs_compat. · *`renderNewsletterThanks` + `renderNewsletterError` rendered through the minimal policy layout* — Both compose `renderTemplate` from `worker/render/_lib.js`. The `existing` path differentiates the copy so a returning subscriber sees "You're already on the list" instead of a generic thanks. The error path runs `b.template.escapeHtml` on the operator-facing message via the b.js adapter.
|
|
24
|
+
|
|
25
|
+
- v0.0.91 (2026-05-23) — **Ten more codebase-patterns ports — detector count 36 → 46.** Bulk-ports every remaining applicable security / correctness detector from blamejs's catalog so the downstream surface inherits the framework's accumulated discipline in one pass. Covers decompression-bomb defense (`inflate-unzip-without-output-size-cap`, `zlib-decompress-not-via-safedecompress`), auth-path coercion (`buffer-from-string-on-auth-path`), JOSE / JWK confused-deputy (`jwk-import-without-alg-kty-check`, `jose-alg-switch-permissive-default`), SSRF metadata bypass (`ssrf-skip-without-textual-metadata-check`), JWT freshness gating (`optional-iat-age-check-no-required-freshness`), state-machine race conditions (`monotonic-terminal-state-overwrite-without-guard`), external-callback DoS (`external-callback-await-without-timeout`), and OpenMetrics counter exposition (`openmetrics-counter-family-name-mismatch`). All ports preserve their upstream `id` / `primitive` / `regex` / `reason` shape so the lineage stays diff-able against `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`. **Detectors:** *`inflate-unzip-without-output-size-cap` — RFC 1951 deflate bomb defense* — Completes the gunzip-cap detector. `zlib.inflateSync` / `inflateRawSync` / `unzipSync` / `createInflate` / `createInflateRaw` / `createUnzip` without a `maxOutputLength` opt is the same amplification class as gzip. Uses the `requires` field to exempt files that name `maxOutputLength` elsewhere. · *`zlib-decompress-not-via-safedecompress` — migration target* — Flags every `zlib.*` decompression in lib/ that hasn't migrated to `b.safeDecompress(buf, { algorithm, maxOutputBytes, maxCompressedBytes })`. The framework primitive composes algorithm allowlist + ratio cap + bomb defense + audit emission in one call. · *`buffer-from-string-on-auth-path` — prototype-pollution coercion defense* — `Buffer.from(String(x))` on an auth-relevant input lets a polluted `toString()` redirect the byte sequence the comparison observes. Validate Buffer-or-string explicitly; refuse other shapes with a typed error. · *`external-callback-await-without-timeout` — operator-callback DoS defense* — Awaiting an operator-supplied callback (`_externalStore`, `_externalSink`, etc.) without a `b.safeAsync.withTimeout` bound means a stalled network call stalls the entire emit chain. Uses the `requires` field to exempt files that compose the timeout helper somewhere. · *`monotonic-terminal-state-overwrite-without-guard` — state-machine race defense* — State machines with a `done` (or terminal) flag whose async handler writes `state.done = true; state.X = ...` must check `if (state.done) return` first; a late-arriving handler otherwise clobbers an earlier terminal state. · *`optional-iat-age-check-no-required-freshness` — token-freshness gate* — `if (typeof X.iat === "number" && now - X.iat > maxAge)` short-circuits to no-check on missing iat. The verifier must refuse missing-iat first OR gate on `(iat is number OR server-nonce present)` before the age comparison. · *`ssrf-skip-without-textual-metadata-check` — SSRF metadata-IP defense* — Any SSRF-skip path (proxy short-circuit, operator-pinned IP, custom dnsLookup) must call `b.ssrfGuard.checkUrlTextual(url)` first; metadata IPs (169.254.169.254 / fd00:ec2::254) are never overridable. · *`jwk-import-without-alg-kty-check` — JWK confused-deputy defense* — `crypto.createPublicKey({ key: jwk, format: "jwk" })` without a sibling `_assertAlgKtyMatch(alg, jwk)` lets an attacker pass an RSA-marked key as EC (or vice versa). Validate alg / kty / use match BEFORE the import. · *`jose-alg-switch-permissive-default` — alg-confusion defense* — A `switch (alg)` with a permissive default (`return` / `break`) accepts unknown algorithm strings, opening HS256-as-RS256 and `none` attacks. The default branch must `throw` with a typed error. · *`openmetrics-counter-family-name-mismatch` — exposition correctness* — OpenMetrics counter `# HELP / TYPE / UNIT` metadata and `<name>_total` sample lines must use the same family identifier. Derive the exposed name once at the top of the per-metric loop.
|
|
26
|
+
|
|
27
|
+
- v0.0.90 (2026-05-23) — **Two more codebase-patterns ports — `raw-sql-identifier-interpolation` + `timing-safe-equal-utf8-without-shape-guard`.** Two more security detectors ported from blamejs's catalog. `raw-sql-identifier-interpolation` catches `FROM " + variable +` shapes (and similar for INTO / UPDATE / TABLE / INDEX / TRIGGER / VIEW / JOIN) where the SQL identifier composes via string concat instead of `b.safeSql.quoteIdentifier`. The first scan caught eight call sites in `lib/pwa-manifest.js` where the `table` variable is a primitive-controlled ternary literal (`kind === "manifest" ? "pwa_manifests" : "pwa_sw_configs"`) and never traces back to operator input — added a file-level allow marker citing the bounded two-element domain. `timing-safe-equal-utf8-without-shape-guard` flags `nodeCrypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))` shapes that can throw on UTF-8 byte-length divergence — guard with a byte-shape check before encoding, or use `b.crypto.timingSafeEqual` which wraps the validation. **Added:** *`raw-sql-identifier-interpolation` codebase-patterns detector* — Catches SQL identifier interpolation via string concat — `FROM " + table +` and similar shapes for INTO / UPDATE / TABLE / INDEX / TRIGGER / VIEW / JOIN. Bound parameters can only carry values, not identifiers; the proper composition is `b.safeSql.quoteIdentifier(name, dialect)` which runs the framework's `validateIdentifier` (length 1–63, charset gate, reserved-word + sqlite_-prefix refusal) before emitting the dialect-correct quoted form. Detector skips variables prefixed with `q` / `Q` / `quoted` (already-validated identifier convention). Ported from blamejs's catalog. · *`timing-safe-equal-utf8-without-shape-guard` codebase-patterns detector* — Catches `nodeCrypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))` shapes where the two inputs may diverge in UTF-8 byte length even when their string `.length` matches — the underlying nodeCrypto call throws `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH`. Either restrict to ASCII-only domains (token / hex / base64url shapes), or compare byte lengths explicitly before the call. `b.crypto.timingSafeEqual` wraps this with the boundary validation. Ported from blamejs's catalog. **Changed:** *`lib/pwa-manifest.js` carries a file-level `allow:raw-sql-identifier-interpolation` marker* — First scan caught eight `"<KEYWORD> " + table + " "` interpolations in pwa-manifest.js. The `table` variable is set by a primitive-controlled ternary on the `kind` parameter (`"pwa_manifests"` or `"pwa_sw_configs"` — bounded two-element domain at write time, never operator input). File-level allow marker documents the exception in place; the detector still fires on any real operator-controlled identifier interpolation. Detector count: 34 → 36.
|
|
28
|
+
|
|
29
|
+
- v0.0.89 (2026-05-23) — **`/blog` list + `/blog/:slug` article-detail rendered at the edge from D1.** The sitemap shipped in v0.0.87 references `/blog/:slug` URLs but neither the list nor the detail page existed at the edge. This patch wires both: `/blog` reads the 12 most recent published articles from D1 and renders a card grid; `/blog/:slug` reads the article row and renders the body as paragraph-broken HTML (markdown rendering deliberately not interpreted at the edge — operators who want markdown formatting store pre-rendered HTML in the `body` column). Missing slugs serve the same edge 404 the products route uses. The blog layout adds a `<link rel="alternate" type="application/rss+xml">` so feed-readers auto-discover `/feed.xml` from any blog page. **Added:** *`/blog` list page served from the edge* — `worker/data/catalog.js` exposes `listBlogArticles(DB, { limit, offset })` returning slug + title + author + meta + hero + published_at for published articles, newest first. The list renderer composes a card grid; each card links to `/blog/<slug>`. An empty result set falls back to a clean empty-state with a link to the RSS feed. Empty + populated states both render through `renderTemplate` (the strict templater) so XSS-relevant fields (title, author, lede) compose `b.template.escapeHtml` on substitution. · *`/blog/:slug` article-detail page served from the edge* — `getBlogArticleBySlug(DB, slug)` returns the full row (body + tags + meta) for `status = 'published'` articles. `renderBlogArticle` composes the article's body via `_paragraphsFromPlainText` — splits on blank lines, `b.template.escapeHtml`s each paragraph, wraps in `<p>` — so the body column is plain-text-safe even when operators write raw markup. Missing slugs serve the same edge 404 as `/products/:not-found`. OpenGraph `og:type` is `article`; `og:image` falls back to the brand logo if no `hero_image_url`. · *Blog layout adds RSS auto-discovery + a Blog nav entry* — Every page rendered through `worker/render/blog.js` carries `<link rel="alternate" type="application/rss+xml" href="/feed.xml">` so RSS-aware browsers and feed readers auto-discover `/feed.xml` from `/blog`. The header nav gains a `Blog` entry alongside Shop / Cart.
|
|
30
|
+
|
|
31
|
+
- v0.0.88 (2026-05-23) — **Four more codebase-patterns ports + scanner `requires` field support + vendor v0.12.9.** The codebase-patterns scanner gains a `requires` field that exempts a primary regex hit when a paired bounding opt appears anywhere in the same file (e.g. `zlib.gunzipSync(...)` paired with `maxOutputLength: <const>`). Four more detector ports follow from blamejs's catalog: `gunzip-without-output-size-cap` (zip-bomb decompression cap), `audit-action-with-hyphen` (validator-refused hyphen segments), `non-canonical-audit-outcome` (only `success` / `failure` / `denied` pass the strict validator), `wildcard-suffix-match-without-single-label-check` (DNS wildcard `*.example.com` must refuse single-label matches per RFC 6125 §6.4.3). Vendor refresh to v0.12.9. **Added:** *Scanner `requires` field — secondary whole-file check exempts a primary hit when paired with a documented opt* — `_check` now reads an optional `requires` regex on each detector entry. When set, a primary hit is exempted only if the same file's contents match `requires` somewhere. The companion check covers patterns where the call shape is correct ONLY when paired with a bounding option elsewhere (e.g. `zlib.gunzipSync(buf)` paired with `maxOutputLength: <byte-constant>`). Ported from blamejs's catalog convention. · *Four new ported detectors* — `gunzip-without-output-size-cap` (DoS via decompression-bomb amplification; require `maxOutputLength` on every `zlib.gunzipSync` / `createGunzip` / brotli-decompress); `audit-action-with-hyphen` (action segments with `-` fail the `[a-z][a-z0-9_]*` validator — use snake_case); `non-canonical-audit-outcome` (only `success` / `failure` / `denied` pass `audit.record()`; `ok` / `fail` / `error` / etc. are silently normalized by `safeEmit` but the canonical strings belong at the call site); `wildcard-suffix-match-without-single-label-check` (RFC 6125 §6.4.3 — a `*.example.com` wildcard covers exactly one label, never zero / single-label). Detector count goes 30 → 34. **Changed:** *Vendored blamejs refreshed from v0.12.8 to v0.12.9* — `bash scripts/vendor-update.sh blamejs v0.12.9` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.8 and v0.12.9. · *`lib/customer-import.js` per-row error outcome string carries an inline allow marker* — The detector flagged `outcome: "error"` on a per-row import-result object in `lib/customer-import.js:271`. Inspection confirmed it's a per-row operator-facing result, NOT an `audit.emit` payload — different vocabulary. Added an inline `// allow:non-canonical-audit-outcome — per-row operator-facing import result; not an audit.emit outcome` marker documenting the exception in place. The non-canonical-audit-outcome detector still fires on any real `audit.emit({outcome: "error"})` call.
|
|
32
|
+
|
|
33
|
+
- v0.0.87 (2026-05-23) — **`/sitemap.xml` rendered at the edge from D1 — fourth and final broken footer link fixed.** `/sitemap.xml` was the last footer link still returning 403 via the container's bot-guard. The Worker now generates the urlset directly from D1 — every active product slug and every published blog article slug joins the home + `/privacy` + `/terms` static-page roster. Output matches sitemaps.org/schemas/sitemap/0.9; XML escape composes `b.template.escapeHtml` through the strict `renderTemplate`, and slugs URL-encode at the path segment so a hypothetical slug with spaces or ampersands produces a crawler-valid URL. Cached `public, max-age=600, s-maxage=3600` (matches `/feed.xml`). **Added:** *`/sitemap.xml` served from the edge — generated from D1 in two queries* — `worker/data/catalog.js` exposes `listActiveProductSlugs(DB)` and `listPublishedBlogSlugs(DB)` — each pulls slug + updated_at with a 50,000-row cap (the sitemap protocol's per-file URL limit). The two queries fan out in parallel via `Promise.all`. `worker/render/sitemap.js` carries the urlset template + per-entry composition; `renderTemplate` from `worker/render/_lib.js` provides the per-value HTML / XML escape (same primitive everything else in the Worker uses). Slugs URL-encode via `encodeURIComponent` before insertion so a hypothetical `mug & co` slug renders as `mug%20%26%20co` in the `<loc>`. Each entry carries `<lastmod>` (ISO date from the row's updated_at), `<changefreq>` (daily for products, weekly for blog, monthly for legal), and `<priority>` (1.0 home, 0.8 products, 0.6 blog, 0.3 legal). Cached `public, max-age=600, s-maxage=3600`. The handler falls back to a `text/plain; 503` with `retry-after: 60` on D1 failure — never escalates to the container.
|
|
34
|
+
|
|
35
|
+
- v0.0.86 (2026-05-23) — **Edge 404 / 500 / `/feed.xml` rendered through the worker — exceptions no longer escalate to the container, plus two new codebase-patterns detectors.** Edge handlers now own their own failure modes. The previous `_edgeHome` / `_edgeSearch` / `_edgeProduct` shape caught render exceptions and returned `null`, which routed back through the dispatcher to `_forwardToContainer` — the container then either bot-guard-403'd the visitor or ran the same primitive surface that just threw on the edge. Each handler's `catch` now serves a 5xx via the new `renderInternalError` helper (designed page, no container hop) and the failure logs to the Worker's observability sink. `/products/:slug` for a missing slug renders an edge 404 instead of escalating. `/feed.xml` generates an RSS 2.0 channel from `blog_articles` via D1 + the same templater the rest of the worker uses. Two new codebase-patterns detectors catch the regressions: `edge-handler-catch-returns-null` flags any worker catch that returns `null` (routing-dispatch returns are still legitimate; only catches are flagged); `unvalidated-env-url-as-origin` flags `fetch(env.X_URL)` / `origin: env.X_URL` / `origin: "https://..." + env.X` shapes that bypass `b.safeUrl.parse`. Both verified via injection. **Added:** *Edge 404 page for missing product slugs — no container hop* — `worker/render/policy.js` gains a `renderNotFound({ what, shopName, version })` helper composed off the same minimal layout as `/privacy` and `/terms`. `_edgeProduct` now returns the rendered 404 with HTTP 404 + `cache-control: public, max-age=60, s-maxage=300, must-revalidate` when `getProductBySlug` resolves to null — previously the handler returned null and the request fell through to the container's bot-guard 403 (which crawlers and visitors landed on alike when a slug had been archived or never existed). · *`/feed.xml` RSS 2.0 feed served from the edge* — `worker/render/feed.js` renders a complete `<rss version="2.0">` channel from a `blog_articles` row set; `worker/data/catalog.js` exposes `recentBlogArticles(DB, { limit })` (default 20). Each article surfaces as an `<item>` with `<title>`, `<link>`, `<guid isPermaLink="true">`, `<pubDate>` (RFC 822-formatted), `<description>`, and `<author>`. XML escape composes `b.template.escapeHtml` — XML 1.0 §4.6 accepts the same `'` numeric reference the HTML primitive emits, so the framework primitive covers both surfaces. The handler returns `cache-control: public, max-age=600, s-maxage=3600` so readers re-fetch every 10 minutes while the edge holds for an hour. The footer's RSS link is no longer dead. **Changed:** *Edge handlers no longer escalate render exceptions to the container* — `_edgeHome`, `_edgeSearch`, and `_edgeProduct` previously returned `null` on catch — which the dispatcher then routed to `_forwardToContainer`. The container couldn't fix a render-side bug (it runs the same primitive surface that just threw), the visitor experience of fallback-to-different-backend is worse than a clean error page, and the escalation hid the bug from observability. Each handler now serves a 5xx via the new `renderInternalError(opts)` helper (designed page, composed off the same minimal layout as `/privacy` and `/terms`) with `cache-control: no-store` so the failure isn't cached. `console.error` to the Worker's observability sink stays explicit; only the silent-escalation path is removed. `/feed.xml`'s 503 path stays plain-text (RSS readers expect that on failure) but no longer falls through to the container. · *Cache-control on static pages now splits `max-age` from `s-maxage`* — `/privacy` and `/terms` previously carried `cache-control: public, max-age=86400, must-revalidate` — browsers held the page for 24h, which delayed any operator-shipped policy update. The new shape is `public, max-age=3600, s-maxage=86400, must-revalidate`: browsers revalidate every hour, Cloudflare's zone cache amortizes the render across the whole zone for 24h. Visitors get the same TTFB; operator-shipped updates propagate in under an hour to every browser. · *Origin URLs in `/feed.xml` + the cache-warmer cron now pass through `b.safeUrl.parse`* — Both call sites previously built the origin via string concat from `env.D1_BRIDGE_URL`. `b.safeUrl.parse` validates the value against the framework's HTTPS-only allowlist (`ALLOW_HTTP_TLS` — refuses `javascript:`, `file:`, `data:` schemes) and returns the normalized URL. The trailing-slash strip leaves a clean origin the per-route path concatenates against. An attacker who somehow set `D1_BRIDGE_URL` to a non-HTTPS scheme would now fail at validation instead of letting the cron warm a hostile origin. · *`/feed.xml` XML rendering now composes through `renderTemplate` (the strict templater)* — Feed XML was built via string concat with inline `b.template.escapeHtml` calls. The refactor routes both the channel envelope and each `<item>` block through the same `renderTemplate` from `worker/render/_lib.js` that the rest of the Worker uses — same `{{name}}` substitution shape, same unknown / unused placeholder refusal at composition time, same per-value escape. XML 1.0 §4.6 accepts the numeric `'` reference `b.template.escapeHtml` emits, so the HTML primitive covers the XML output too. · *`lib/admin.js` switches from `try { audit.emit(...) } catch {}` to `audit.safeEmit(...)`* — The framework ships `safeEmit` as the drop-silent variant of `audit.emit` — handles sink failure, invalid namespace, and shape errors internally without throwing. The previous local `try/catch` wrapper duplicated that contract; composing the framework primitive removes the duplication and lets the new `raw-audit-emit-without-drop-silent-wrap` detector enforce the discipline globally. **Detectors:** *Eight new `codebase-patterns` detectors — two project-specific + six ported from blamejs's catalog* — `edge-handler-catch-returns-null` and `unvalidated-env-url-as-origin` are project-specific; both verified via injection test. The remaining six are direct ports from the vendored framework's own catalog (`lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`), with `id` / `primitive` / `regex` / `reason` shapes preserved so the lineage stays diff-able against upstream: `number-coerce-or-zero-on-json-source` (validate finite non-negative integer instead of `Number(x) || 0`), `slice1-optional-parseint-silent-default` (refuse empty-digit segment with an explicit throw before parseInt), `utf16-length-as-byte-cap` (`Buffer.byteLength(s, "utf8")` instead of `s.length` for byte caps), `raw-audit-emit-without-drop-silent-wrap` (`b.audit.safeEmit` instead of bare `audit.emit`), `non-ct-iss-compare` (`b.crypto.timingSafeEqual` for issuer compare), `fs-path-from-operator-identifier-without-traversal-refusal` (validate name + explicit `..` refusal before `path.join`). All eight scan the `shop` scope (lib/ + worker/). Detector count goes from 22 → 30.
|
|
36
|
+
|
|
11
37
|
- v0.0.85 (2026-05-23) — **FSM-name shape detector + email-campaigns rename + /SECURITY.md redirect (auto-deploy fix) + vendor v0.12.8 (audit drop-silent contract restored).** The `email-campaigns` primitive named its FSM `emailCampaign` — camelCase. The framework's audit-action validator at `lib/vendor/blamejs/lib/audit.js:401` enforces `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$` per segment, so every transition fired `flush dropped event: audit action must be 'namespace.verb[.qualifier...]' — got: fsm.emailCampaign.transition` and the event was dropped silently. Renamed to `email_campaign`. A new `codebase-patterns` detector `fsm-name-not-audit-action-safe` catches this class of bug going forward — it scans `fsm.define({ ... name: "..." ... })` calls (using a new multi-line scan mode) and refuses any name that doesn't match the per-segment shape. Auto-deploy is also unblocked: the `/SECURITY.md` route now redirects to the GitHub mirror (same shape as `/CHANGELOG.md`) — bundling the file via wrangler's text rule was failing under `wrangler versions upload`. Bundled vendor refresh to blamejs v0.12.8 — upstream restored the `safeEmit` drop-silent contract in the flush handler, eliminating the `flush dropped event: db.init() must be awaited` log spam that layer-1 tests were producing whenever a primitive composed `b.fsm.transition` without the framework's storage layer being initialized. The npm `homepage` field also moves from the GitHub mirror to `https://blamejs.shop` so the registry page links at the live deployment rather than the source repo. **Added:** *`fsm-name-not-audit-action-safe` codebase-patterns detector* — Scans `fsm.define({ ... name: "..." ... })` calls in `lib/` and flags any name that doesn't match the per-segment audit-action shape (`[a-z][a-z0-9_]*`). The pattern spans lines so multi-line `fsm.define({ \n name: "..." \n })` shapes are caught; a new `multiline: true` flag on the detector entry routes the scan through a whole-file regex pass instead of the per-line one. Description points at the canonical audit-action validator + the framework's PQC-first naming convention. · *Multi-line scan mode in `codebase-patterns.test.js`* — The scanner now accepts `{ multiline: true }` per detector. When set, the whole-file content is scanned with the global regex via `matchAll`; line numbers are derived from the byte offset by counting preceding newlines. Per-line scans remain the default — only detectors that need cross-line context (FSM `name:` field validation, future shape checks) opt in. **Changed:** *Vendored blamejs refreshed from v0.12.6 to v0.12.8* — Upstream restored the `b.audit.safeEmit` drop-silent contract in the flush handler at `lib/vendor/blamejs/lib/audit.js:1129` — events queued before `b.db.init()` is awaited (or where storage rejected the write) now drop without the `log.error("flush dropped event: ...")` spam that prior versions emitted. The framework's chain integrity guarantee is unchanged; only the flush-side log behaviour. Layer-1 tests in this codebase wire `query` directly via `node:sqlite` (the framework's storage layer is intentionally not initialized in unit tests), so every primitive that composes `b.fsm.transition` previously caused dozens of dropped-event logs per smoke run. Local smoke goes from ~100 dropped-event logs to zero. · *`package.json` homepage now points at the live deployment* — The `homepage` field was `https://github.com/blamejs/blamejs.shop` so the npm registry page linked at the source repo. Operators landing on `npmjs.com/package/@blamejs/blamejs-shop` were one click away from the reference deployment but the link sent them to the README. Field now reads `https://blamejs.shop` so the registry entry points at the live storefront; the `repository` field still references the GitHub mirror for source. **Fixed:** *`email-campaigns` FSM renamed from `emailCampaign` to `email_campaign`* — The FSM name composes into audit actions as `fsm.<name>.transition`. The framework's audit-action validator (`lib/vendor/blamejs/lib/audit.js:401`) enforces `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$` per segment — `emailCampaign` failed validation because of the uppercase `C`, and the audit module dropped every campaign-state transition silently with an error log. Snake_case rename closes the gap. No data shape change — the FSM `name` is an identifier in the framework, not a column value. · *`/SECURITY.md` redirects to the GitHub mirror — unblocks Cloudflare auto-deploy* — 0.0.84's `wrangler [[rules]] type = "Text"` bundling of `SECURITY.md` worked under local `wrangler deploy` but failed under `wrangler versions upload` (the command Cloudflare Workers Builds runs): `✘ [ERROR] No loader is configured for ".md" files`. The route now serves a 302 to `https://github.com/blamejs/blamejs.shop/blob/main/SECURITY.md` — same pattern as `/CHANGELOG.md`. Both files stay readable to operators; neither bloats the Worker bundle.
|
|
12
38
|
|
|
13
39
|
- v0.0.84 (2026-05-23) — **`/privacy`, `/terms`, `/SECURITY.md`, `/CHANGELOG.md` served from the edge — footer links no longer 404.** The storefront footer has always linked to `/privacy`, `/terms`, `/SECURITY.md`, and `/CHANGELOG.md`, but the container never owned those routes — every footer click landed on a 403 / 404. This patch wires each route into the Worker: `/privacy` and `/terms` render minimal policy pages composed off a stripped-down layout; `/SECURITY.md` serves the in-tree file as `text/markdown` (bundled via a new wrangler `[[rules]]` Text rule); `/CHANGELOG.md` redirects (302) to the GitHub mirror since the file at 303 KB would inflate the gzipped Worker by ~60 KB. All four pages cache 24h `must-revalidate` so the browser + Cloudflare's zone cache hold them across visits. **Added:** *Edge handlers for `/privacy`, `/terms`, `/SECURITY.md`, `/CHANGELOG.md`* — `worker/render/policy.js` carries `renderPrivacy(opts)` and `renderTerms(opts)` — minimal layout (no marquee / hero / catalog chrome) with the policy text inlined. Both compose `renderTemplate` from `worker/render/_lib.js` so the substitution + HTML escape semantics match the rest of the Worker. `worker/index.js` routes `/privacy` and `/terms` to those renderers, `/SECURITY.md` to the wrangler-text-bundled in-tree SECURITY.md, and `/CHANGELOG.md` to a 302 redirect at `https://github.com/blamejs/blamejs.shop/blob/main/CHANGELOG.md`. All four respond with `cache-control: public, max-age=86400, must-revalidate` so the browser and Cloudflare's zone cache hold them between visits. · *`wrangler.toml` text-rule for `SECURITY.md` bundling* — `[[rules]] type = "Text" globs = ["SECURITY.md"] fallthrough = true` — wrangler's esbuild loader now treats `SECURITY.md` as a text-imported string. `import SECURITY_MD from "../SECURITY.md"` in `worker/index.js` resolves to the file's content. `CHANGELOG.md` is excluded by glob (303 KB → 60+ KB gzipped Worker bloat); that route uses an HTTP redirect to the GitHub mirror instead.
|
package/lib/admin.js
CHANGED
|
@@ -126,13 +126,17 @@ function _wrap(handler, opts) {
|
|
|
126
126
|
try {
|
|
127
127
|
var result = await handler(req, res);
|
|
128
128
|
if (opts.audit && result && result !== false) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
// `safeEmit` is the framework's drop-silent variant — handles
|
|
130
|
+
// sink failure / invalid namespace / shape errors internally
|
|
131
|
+
// without throwing, so the audit attempt can never crash the
|
|
132
|
+
// write path it observes. Equivalent to `try { audit.emit(...)
|
|
133
|
+
// } catch (_e) {}` but composed via the framework primitive
|
|
134
|
+
// instead of a local wrapper.
|
|
135
|
+
_b().audit.safeEmit({
|
|
136
|
+
action: AUDIT_NAMESPACE + "." + opts.audit,
|
|
137
|
+
outcome: "success",
|
|
138
|
+
metadata: { id: result.id || null },
|
|
139
|
+
});
|
|
136
140
|
}
|
|
137
141
|
return result;
|
|
138
142
|
} catch (e) {
|
package/lib/customer-import.js
CHANGED
|
@@ -268,7 +268,7 @@ async function _dryRun(customers, rows) {
|
|
|
268
268
|
counters.errored += 1;
|
|
269
269
|
var msg = (e && e.message) || String(e);
|
|
270
270
|
errors.push({ row_index: rowIndex, message: msg });
|
|
271
|
-
perRow.push({ row_index: rowIndex, outcome: "error", message: msg });
|
|
271
|
+
perRow.push({ row_index: rowIndex, outcome: "error", message: msg }); // allow:non-canonical-audit-outcome — per-row operator-facing import result; not an audit.emit outcome (which uses the canonical success/failure/denied triple)
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
// dryRun rolls `would_conflict` into `skipped` for the headline
|
package/lib/pwa-manifest.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
// codebase-patterns:allow-file raw-sql-identifier-interpolation — every `table` interpolation in this file is a primitive-controlled ternary literal (`kind === "manifest" ? "pwa_manifests" : "pwa_sw_configs"`); the identifier never traces back to operator / user input. Wrapping in `b.safeSql.quoteIdentifier` would be defense-in-depth but the two-element domain is already enumerated at write time.
|
|
2
3
|
/**
|
|
3
4
|
* @module shop.pwaManifest
|
|
4
5
|
* @title PWA manifest — operator-configurable webmanifest + service worker
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.9",
|
|
7
|
+
"tag": "v0.12.9",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -42,6 +42,14 @@ jobs:
|
|
|
42
42
|
fetch-depth: 0
|
|
43
43
|
fetch-tags: true
|
|
44
44
|
|
|
45
|
+
- name: Refresh origin/main reference
|
|
46
|
+
# actions/checkout fetches refs for the checked-out ref (the tag
|
|
47
|
+
# in this case) — it does NOT fetch remote branches like main as
|
|
48
|
+
# a side effect, so `git rev-list --first-parent origin/main`
|
|
49
|
+
# walks a stale (or absent) ref. Fetch main explicitly so the
|
|
50
|
+
# first-parent walk sees the current squash-merge HEAD.
|
|
51
|
+
run: git fetch origin main:refs/remotes/origin/main
|
|
52
|
+
|
|
45
53
|
- name: Resolve tag + verify chain
|
|
46
54
|
env:
|
|
47
55
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.9 (2026-05-23) — **`b.archive.gz` + `b.archive.read.gz` — gzip composition with `b.safeDecompress` bomb caps + `b.backup` `tar.gz` bundle format + `sha-to-tag verify` fetches `origin/main`.** gzip lands as the composition layer over the archive family. `b.archive.gz(bytes)` produces an RFC 1952 gzip stream with the same `toBuffer()` / `toAdapter(adapter)` / `digest()` shape every archive builder ships, and `b.archive.read.gz(adapter, opts)` reads it back through `b.safeDecompress` so a malicious `tar.gz` fails the gzip-layer bomb cap (1 GiB output / 100× ratio defaults) before the tar walker ever sees a decompressed byte. The reader exposes `toBuffer()` / `asTar(opts)` / `asZip(opts)` so operators can hand the decompressed bytes directly to a downstream archive reader without a round-trip through disk. `b.archive.tar().toGzip(adapter, opts)` is the write-side convenience for the most common combination. `b.backup.bundleAdapterStorage({ format: "tar.gz" })` adds gzip compression on the wire — bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); the readback path detects the format from the storage key suffix and composes `b.safeDecompress` automatically. The `sha-to-tag verify` workflow now explicitly fetches `origin/main` before walking the first-parent history, fixing a stale-ref bug that silently failed v0.12.6 through v0.12.8 tag verifications (the publish workflow itself was unaffected; the gate is independent). **Added:** *`b.archive.gz(bytes)` — standalone gzip write builder* — RFC 1952 gzip envelope with the standard archive-builder shape. `toBuffer()` returns the compressed bytes; `toAdapter(adapter)` writes through any writable adapter (fs / object-store / http) that exposes `.write(bytes)` + optional `.close()`; `digest()` returns a SHA3-512 hex hash of the compressed payload for operator integrity logs. `opts.level` accepts 0-9 (zlib default 6). Composes cleanly under `b.archive.tar().toGzip(adapter)` / `b.archive.zip()` for tar.gz / zip.gz convenience. · *`b.archive.read.gz(adapter, opts)` — gunzip reader with `b.safeDecompress` bomb caps* — Every decompression routes through `b.safeDecompress({ algorithm: "gzip", maxOutputBytes, maxRatio })` so a hostile gzip stream fails the bomb gate before any downstream parsing happens. Defaults: `maxDecompressedBytes` = 1 GiB, `maxExpansionRatio` = 100×. The reader exposes three downstream entry points: `toBuffer()` returns the raw decompressed bytes; `asTar(opts)` returns a `b.archive.read.tar` reader over the decompressed payload; `asZip(opts)` returns a `b.archive.read.zip` reader. `fromGzip` is the documented alias the spec uses (operators may reach for either). Refuses non-gzip input upfront via the `0x1f 0x8b` magic check (`archive-gz/bad-magic`). · *`b.archive.tar().toGzip(adapter, opts)` — tar.gz write convenience* — Pipes the tar builder's `toBuffer()` through `b.archive.gz()` and writes the resulting gzip envelope to a writable adapter. Equivalent to `b.archive.gz(t.toBuffer()).toAdapter(adapter)` but lets the operator stay in the tar-builder fluent chain when composing under fs / object-store / http adapters. · *`b.backup.bundleAdapterStorage({ format: "tar.gz" })` — compressed-on-the-wire bundles* — Adds gzip compression to the v0.12.8 tar bundle format. Bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); binary-heavy backups (compressed databases, encrypted archives) see ~1.0-1.1×. Read paths auto-detect via the `<bundleId>/bundle.tar.gz` storage key suffix and route through `b.safeDecompress` on readback. The v0.12.8 `maxBundleBytes` cap continues to gate against pathological projected-uncompressed sizes; `tar.gz` does not bypass it. · *`b.safeArchive.extract({ format: "tar.gz" })` — explicit tar.gz dispatch* — Operators handed a `.tar.gz` upload pass `format: "tar.gz"` explicitly; the orchestrator composes `b.archive.read.gz` → `.asTar()` and feeds the standard tar bomb-policy + entry-type-policy + guardProfile through. Defer-with-condition: auto-sniff for tar.gz (peek inside the gzip envelope for ustar magic at offset 257 of the decompressed prefix) lands when operator demand surfaces; today operators with `auto` mode on a `.tar.gz` payload get `format-unsupported gzip` with the explicit-format hint in the error message. **Fixed:** *`sha-to-tag verify` workflow fetches `origin/main` before first-parent walk* — The release-tag integrity gate runs on every `v*` tag push and verifies the tag's commit SHA appears on `main`'s first-parent history. `actions/checkout` was being asked for full history of the tag ref alone — `origin/main` wasn't fetched as a side effect, so `git rev-list --first-parent origin/main | grep -qx "$SHA"` walked a stale (or absent) ref and falsely refused. The check now explicitly fetches `origin/main` after checkout so the walk sees the current squash-merge HEAD. Affected releases (v0.12.6 / v0.12.7 / v0.12.8) had publish workflows that completed normally — `sha-to-tag verify` is an independent gate that was silently failing alongside successful publishes; nothing about the published artifacts was wrong. **Security:** *Bomb caps ride at the gz layer, not the tar/zip layer* — The decompression gate is enforced BEFORE the downstream archive reader sees any bytes — a hostile `tar.gz` that would decompress to 10 GiB of zero-filled tar entries fails the 1 GiB `maxDecompressedBytes` default cap during gunzip, never reaching the tar walker. Operators with legitimately large compressed archives pass `maxDecompressedBytes` higher; the framework refuses without an explicit opt-in. RFC 1952 §2.3.1 magic enforcement prevents content-type confusion (gzip-pretending-to-be-something-else inputs). **Detectors:** *`archive-gz-without-safedecompress` — direct `node:zlib` gunzip in `lib/` must compose `b.safeDecompress`* — Mirrors the v0.11.5 must-compose pattern: any `lib/` call to `zlib.gunzipSync` / `zlib.createGunzip` / `gunzip` outside `lib/archive-gz.js` (which IS the canonical gunzip site, with `b.safeDecompress` wired in) must carry an `allow:archive-gz-without-safedecompress` marker explaining why the bomb gate is bypassed. The detector locks the contract so v0.13+ work that touches a gzip-handling primitive can't quietly drop the cap.
|
|
12
|
+
|
|
11
13
|
- v0.12.8 (2026-05-23) — **`b.archive.tar` + `b.archive.read.tar` — POSIX pax tar format end-to-end + `b.guardArchive.tarEntryPolicy` + `b.backup` tar bundle default.** Tar lands as the second format in the archive family. `b.archive.tar()` builds POSIX pax archives (ustar magic + pax extended headers for >100-char names, >8 GiB sizes, nanosecond mtime); `b.archive.read.tar(adapter)` walks the 512-byte block sequence with the same bomb-cap + path-traversal + entry-type defenses that ZIP read shipped at v0.12.7. Tar's natively-streamable shape means `b.archive.adapters.trustedStream(readable)` is a first-class extract path here (no CD-walk required since tar has no central directory; sequential header-by-header is the canonical adversarial-safe path). `b.guardArchive.tarEntryPolicy` ships as the tar-specific entry-shape policy beyond `entryTypePolicy` — handles typeflag 0/5 (regular/directory) by default, refuses 1/2 (hardlink/symlink) unless `allowDangerous` is set with the realpath-on-link-target dual-check, and refuses 3/4/6/7 (char-device/block-device/FIFO/contiguous-file) unconditionally. `b.backup.bundleAdapterStorage({ format: "tar" })` becomes the default for new bundles — directory-tree format stays available via `format: "directory"` for back-compat with v0.12.7 bundles. `b.backup.migrate(from, to)` one-shot helper converts v0.12.7 directory bundles to v0.12.8 tar bundles transparently. `b.safeArchive.extract({ source, destination, format: "auto" })` now sniffs ustar magic at offset 257 inside the first 512-byte block and dispatches to the tar reader automatically. CVE coverage extends to the tar class: CVE-2026-23745 / 2026-24842 (node-tar symlink+hardlink path resolution), CVE-2025-4517 PATH_MAX TOCTOU (the v0.12.7 dual-check carries through), CVE-2025-11001/11002 (symlink TOCTOU on extract), CVE-2024-12905 / 2025-48387 (tar-fs traversal), CVE-2025-4138/4330 (Python tarfile data filter bypass). **Added:** *`b.archive.tar()` — POSIX pax write builder* — Mirrors `b.archive.zip()`'s contract: `addFile(name, content, opts?)` + `addDirectory(name, opts?)` + `toBuffer()` + `toStream(writable)` + `toAdapter(adapter)` + `digest()`. Emits ustar-magic 512-byte header blocks with the standard 11-field prefix (name / mode / uid / gid / size / mtime / chksum / typeflag / linkname / magic / version / uname / gname / devmajor / devminor / prefix). Names >100 chars + sizes >8 GiB + mtime with nanosecond precision get a pax extended header (typeflag=x) preceding the entry; the extended header records (per POSIX.1-2001 §4.18) carry the `path` / `size` / `mtime` / `atime` / `ctime` fields that overflow ustar's fixed widths. Determinism opts: `{ fixedMtime: 0, ignoreOrder: false }` for reproducible builds (matches the ZIP write side). · *`b.archive.read.tar(adapter, opts)` — sequential + random-access tar reader* — Walks 512-byte header blocks in order. `inspect()` enumerates entries without decompressing; `extract({ destination })` decompresses entry-by-entry with the same bomb-cap + path-traversal + entry-type defenses as ZIP read. Trusted-stream adapters are first-class here — tar has no central directory, so sequential header-by-header walk IS the canonical adversarial-safe path (`b.archive.adapters.trustedStream(readable)` and `b.archive.adapters.fs/buffer/objectStore/http` all flow through the same reader). Per-entry path safety routes through `b.guardFilename.verifyExtractionPath` (the v0.12.7 dual-check). Refuses to overwrite pre-existing destination files (carries the v0.12.7 atomic-rollback contract). · *`b.guardArchive.tarEntryPolicy(opts)` — tar-specific entry-type policy* — Defaults: typeflag 0 (regular file) + 5 (directory) extract; typeflag 1 (hardlink) + 2 (symlink) refused unless `allowDangerous: { symlinks: true, hardlinks: true }` is set; typeflag 3 (char-device) + 4 (block-device) + 6 (FIFO) + 7 (contiguous-file) refused unconditionally. When `allowDangerous` is set, link target is routed through `b.guardFilename.verifyExtractionPath` against the extraction root — the realpath-on-link-target check defends the CVE-2026-23745 / 24842 node-tar class where the safety check and creation logic diverged on path resolution. Pax extended-header (x) + global-header (g) entries consumed by the reader (merged into the following entry's metadata); operators never see them as standalone entries. · *`b.backup.bundleAdapterStorage({ format: "tar" })` — tar bundle becomes default* — New bundles ship as a single tar archive instead of a directory tree. Restore via `b.archive.read.tar` (with the operator-supplied adapter routing the bytes). `format: "directory"` opts back into the v0.12.7 layout for operators with existing bundles. `format: "tar"` is the new default; `b.backup.diskStorage` stays back-compat at the legacy directory-tree format. · *`b.backup.migrate(opts)` — directory → tar bundle migration* — One-shot helper that walks an operator's directory-tree-format bundle (v0.12.7 layout) and writes the same content as a tar-format bundle via the v0.12.8 bundleAdapterStorage. Idempotent: re-running on an already-migrated bundle is a no-op. Source bundle stays in place until the migrate succeeds; operators with explicit transition windows pass `{ deleteSourceOnSuccess: true }` to opt into the inline replace. · *`b.safeArchive.extract({ format: "auto" })` recognizes tar* — Format auto-sniff now dispatches `ustar` magic at offset 257 inside the first 512-byte header block to the tar reader. ZIP magic + tar magic + GZIP magic (v0.12.9) live in the same sniff path; operators with mixed-format pipelines pass `format: "auto"` once + the orchestrator picks the right reader. **Security:** *Symlink + hardlink path resolution (CVE-2026-23745 / CVE-2026-24842 node-tar class)* — node-tar < 7.5.7 / ≤ 7.5.2 shipped a divergence between its hardlink safety check (which used one path resolution) and its hardlink creation logic (which used another). When `allowDangerous: { hardlinks: true }` is set, blamejs routes the link target through `b.guardFilename.verifyExtractionPath` — the SAME primitive that the eventual `link()` call resolves against — so check + create agree by construction. Symlink targets same shape. · *Path traversal (CVE-2024-12905 / CVE-2025-48387 tar-fs + CVE-2025-4138 / 4330 Python tarfile data filter bypass)* — Every entry name passes through `b.guardFilename.verifyExtractionPath` — the v0.12.7 dual-check that refuses pre-resolve names > PATH_MAX (4096 bytes) AND verifies the string-normalize + `fs.realpath` resolutions agree on the same final path. Defends the CVE-2025-4517 / 4138 / 4330 class where the operator's path resolution and the kernel's diverge silently past PATH_MAX. · *Symlink TOCTOU on extract (CVE-2025-11001 / CVE-2025-11002 7-Zip class)* — When `allowDangerous: { symlinks: true }` opts symlinks in, the reader resolves the link target via `verifyExtractionPath` against the extraction root BEFORE calling `fs.symlink` — so the resolved target is inside the trust boundary by construction. The v0.12.7 atomic-rollback contract carries through: any single entry failure aborts the whole extract + cleans up only newly-created files (pre-existing destination files refused at the pre-write check). **Detectors:** *`tar-extract-allow-dangerous-without-link-target-check`* — Flags any `b.archive.read.tar(adapter).extract({ allowDangerous: ... })` call site in `lib/` that doesn't route the link target through `b.guardFilename.verifyExtractionPath` against the extraction root. Forces the dual-check discipline at every allow-dangerous opt-in — operators with hardlink / symlink extract needs see the realpath check at the call site. · *`tar-entry-typeflag-without-policy`* — Flags `lib/archive-tar.js` extract code paths that switch on typeflag without composing `b.guardArchive.tarEntryPolicy` for the type-allowlist decision. Locks the shape: every typeflag dispatch goes through the policy, never inline. · *`backup-migrate-without-source-preserve`* — Flags `b.backup.migrate(opts)` call sites that pass `deleteSourceOnSuccess: true` without an operator-stated justification comment. Default is preserve-source; deletes need an explicit reason. **References:** [POSIX.1-2001 pax extended format (IEEE 1003.1)](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html) · [CVE-2026-23745 — node-tar symlink+hardlink path resolution](https://www.sentinelone.com/vulnerability-database/cve-2026-23745/) · [CVE-2026-24842 — node-tar hardlink path resolution](https://github.com/advisories/GHSA-34x7-hfp2-rc4v) · [CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)](https://nvd.nist.gov/vuln/detail/CVE-2025-4517) · [CVE-2025-4138 / CVE-2025-4330 — Python tarfile data filter](https://github.com/0xDTC/CVE-2025-4138-4517-POC) · [CVE-2025-11001 / CVE-2025-11002 — 7-Zip symlink TOCTOU on extract](https://www.sentinelone.com/vulnerability-database/cve-2025-11001/) · [CVE-2024-12905 / CVE-2025-48387 — node-tar-fs path traversal](https://vulert.com/vuln-db/debian-11-node-tar-fs-193050)
|
|
12
14
|
|
|
13
15
|
- v0.12.7 (2026-05-23) — **`b.archive.read` — random-access ZIP reader + adapter substrate + `b.safeArchive.extract` orchestrator.** The framework's ZIP primitive grows a read side. `b.archive.read.zip(adapter, opts)` walks the central directory, validates LFH/CD coherence (defeats the malformed-zip / Zip Slip CD-skew class), bounds decompression with operator-declared bomb caps (per-entry size, total bytes, expansion ratio, entry count), and refuses path-traversal + symlink-shaped entries before any byte hits the destination. The adapter contract (`{ size, range(offset, length) }` for random-access; `{ readable }` for the trusted-stream fallback) unifies how operators feed bytes in — local files, `b.objectStore` buckets, HTTP Range fetches, and in-memory buffers all compose the same reader. `b.safeArchive.extract({ source, destination, ... })` ships as the one-liner orchestrator that combines read + guardArchive + path-safety + bomb caps + extract for the common `untar a hostile archive into a quarantine directory` shape. `b.guardArchive` gains `inspect(adapter)` (entry-list enumeration that doesn't decompress), `zipBombPolicy(...)` and `entryTypePolicy(...)` policy-object builders so operators can declare their cap set once + reuse it. `b.guardFilename.verifyExtractionPath(name, root, opts?)` adds the dual-check (string-normalize + `fs.realpath`-agreement) that defends the CVE-2025-4517 PATH_MAX TOCTOU class — refuses paths > 4096 bytes BEFORE the kernel's realpath truncation hits, then verifies the string and fs resolution agree on the same final path. `b.backup` gains `bundleAdapterStorage(adapter, opts)` — the first non-disk transport backend; substrate for the v0.12.8 tar bundle format + v0.12.11 objectStoreStorage. Closes the no-MVP gap from v0.5.15 where `b.archive` shipped write-only and the operator-facing JSDoc explicitly punted reading + extraction to yauzl / `unzip`. **Added:** *`b.archive.read.zip(adapter, opts)` — random-access ZIP reader* — Walks the end-of-central-directory record, validates every CD entry against its LFH (offset / size / CRC / method / name agreement), and emits an iterator of `{ name, size, compressedSize, crc, method, mtime, isEncrypted, externalAttrs, extraFields }` entries. `inspect()` returns the entry list without decompressing — operators wire `b.guardArchive` against the inspect output before paying a single decompress cycle. `extract({ destination, ... })` decompresses entry-by-entry via `node:zlib` raw inflate, routes every path through `b.guardFilename.verifyExtractionPath`, and enforces zip-bomb caps as a streaming abort (the partial extract is fs.rm-ed before the error throws). Composes the existing `lib/archive.js` write-side CRC + signature constants — no duplicated wire-format knowledge. · *Adapter contract — `b.archive.adapters.{fs,objectStore,http,buffer,trustedStream}`* — One shape for source bytes: `{ size: <number>, range(offset, length): Promise<Buffer> }` for random-access, or `{ readable: Readable }` for the explicit trust-stream fallback. `fs(path)` opens a file descriptor + range-reads. `objectStore(client, key)` composes the v0.4.23 `b.objectStore` Range-GET path. `http(url, opts)` composes `b.httpClient` with `Range: bytes=N-M` headers + 206 verification. `buffer(buf)` slices a Buffer in-memory. `trustedStream(readable)` accepts a Node Readable for the rare case the operator can vouch for the source. The same contract feeds `b.safeArchive.extract` and `b.backup.bundleAdapterStorage`. · *`b.safeArchive.extract({ source, destination, ... })` — one-liner safe extraction* — Combines `b.archive.read` + `b.guardArchive` inspect + `b.guardFilename.verifyExtractionPath` + bomb caps + post-extract destination-rebase verification. Refuses the entire archive when any single entry trips a policy (atomic — no half-extracted state on the destination). Operators who want fine-grained control reach for the lower-level primitives directly; `b.safeArchive.extract` covers the 90%-case `extract this hostile-shaped archive into a quarantine directory` workflow. Returns `{ entries: [...], destinationRoot, bytesExtracted, auditTrail }` on success. · *`b.guardArchive.inspect(adapter, opts)` + `zipBombPolicy(...)` + `entryTypePolicy(...)`* — `inspect(adapter)` is the bridge between the read primitive and the existing `validateEntries` gate — runs the read primitive's inspect phase, hands the entry list to `validateEntries`, and returns the merged `{ entries, issues, decisions }` so the caller decides whether to proceed. `zipBombPolicy({ maxEntries, maxEntryDecompressedBytes, maxTotalDecompressedBytes, maxExpansionRatio })` and `entryTypePolicy({ symlinks, hardlinks, devices, fifos, sockets })` are policy-object builders so operators declare the cap set once + reuse across call sites. Each policy carries its own `audit.posture` annotation that propagates through `b.agent.postureChain`. · *`b.guardFilename.verifyExtractionPath(name, root, opts?)` — dual-check path safety* — Companion to the existing `b.guardArchive.checkExtractionPath` (string-only check the gate keeps portable). `verifyExtractionPath` couples to `fs.realpath` deliberately: refuses paths whose pre-resolve string already exceeds 4096 bytes (defends the CVE-2025-4517 PATH_MAX TOCTOU class — `os.path.realpath`-style truncation can't reach the kernel before our refuse fires), then verifies the string-normalized result and the `fs.realpath`-resolved result agree on the same final path. Disagreement throws `guard-filename/extraction-path-toctou`. The string-check stays the canonical portable gate; this primitive is the deeper fs-coupled check the framework's read primitive wires in by default. · *`b.backup.bundleAdapterStorage(adapter, opts)` — adapter-driven storage backend* — First non-disk backend for `b.backup`. Walks the bundle directory file-by-file and writes through the v0.12.7 adapter contract — `fs` adapter behaves identically to `diskStorage` (which stays for back-compat), `buffer` adapter emits the bundle into an in-memory representation, custom adapters can route to anything that satisfies the contract. Substrate for the v0.12.8 tar bundle format (which folds the directory tree into a single tar stream) and the v0.12.11 `objectStoreStorage` (which composes `b.archive.adapters.objectStore` for S3 / MinIO / Azure / GCS-backed backups). Backup manifest layout unchanged — restore code keeps working byte-for-byte against bundles produced by either backend. **Security:** *Zip Slip class (CVE-2025-3445 / 11569 / 23084 / 27210 / 11001 / 11002 / 26960 / 4517 / 4138 / 4330 + 2024 jszip / mholt/archiver / Python tarfile / node-tar / 7-zip)* — Every archive-read entry's name passes through `b.guardFilename.verifyExtractionPath` before any decompression. Path-traversal segments (`..`, leading `/` or `\`, drive-letter prefixes, null bytes, overlong UTF-8) are refused; Windows reserved names + NTFS ADS suffixes are refused; the realpath-agreement check defends the CVE-2025-4517 PATH_MAX TOCTOU class. Symlink and hardlink entries are refused unconditionally under the default `entryTypePolicy`; operators with a legitimate need opt into `allowSymlinks: true` / `allowHardlinks: true` and get the entries routed through an additional `b.guardArchive` realpath-on-target check. · *Decompression-bomb class (CVE-2025-0725, OWASP zip-bomb top-cases)* — `b.archive.read.zip.extract` enforces four caps in parallel: `maxEntries` (entry-count), `maxEntryDecompressedBytes` (per-entry size), `maxTotalDecompressedBytes` (aggregate across the archive), and `maxExpansionRatio` (compressed → decompressed ratio cap, default 100:1). Each cap aborts the extract as soon as the bound is exceeded; the destination directory is `fs.rm`-ed before the error throws so a partial extract never lingers on disk. The `b.safeDecompress` primitive (v0.11.5) is the underlying inflate gate — same defense surface, same audit-trail. · *LFH/CD skew + malformed-ZIP DoS* — The CD walk verifies every entry's local-file-header against the central-directory record (offset / size / CRC / method / name agreement). Mismatches throw `archive/cd-skew` before any byte decompresses. Defends the malformed-zip class where a hostile producer points CD entries at LFH locations that don't match the CD claim — the prior write-only path had no exposure to this class; the new read path closes it. **Detectors:** *`archive-read-without-bomb-caps`* — Flags `b.archive.read.zip(adapter)` call sites in `lib/` that don't pass an explicit `bombPolicy` or `maxTotalDecompressedBytes`. Forces the cap-declaration discipline at call sites — operators see the bomb-cap surface every time they reach for the read primitive. · *`archive-extract-without-guard`* — Flags `b.archive.read.zip(...).extract({ destination, ... })` call sites that don't compose `b.safeArchive.extract` (the orchestrator) AND don't pass an explicit `b.guardArchive.inspect` precheck. Per-file lib/ surface forces operators to either use the orchestrator OR explicitly opt into per-step composition with a written justification. · *`archive-adapter-without-error-path`* — Flags adapter implementations (any export shape matching `{ size, range }` / `{ readable }`) that don't propagate AbortSignal or refuse to throw on partial-read truncation. Forces the cancellation-discipline so a slow / hostile source can't block extraction indefinitely. · *`safe-archive-extract-bypass`* — Flags any composition in `lib/` that builds the safeArchive pipeline (`read.zip(...).extract({ destination, ... })` + bomb caps + guard) inline instead of calling `b.safeArchive.extract`. The orchestrator owns the audit-emission shape; bypassing it means audit gaps. **References:** [APPNOTE.TXT (PKWARE ZIP File Format Specification)](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT) · [CVE-2025-3445 — mholt/archiver Zip Slip](https://github.com/advisories/GHSA-7vpp-9cxj-q8gv) · [CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)](https://nvd.nist.gov/vuln/detail/CVE-2025-4517) · [CVE-2025-11001 / CVE-2025-11002 — 7-Zip directory-traversal RCE](https://www.sentinelone.com/vulnerability-database/cve-2025-11001/) · [CVE-2025-11569 — cross-zip directory traversal](https://security.snyk.io/vuln/SNYK-JS-CROSSZIP-6105396) · [CVE-2026-23745 / CVE-2026-24842 — node-tar symlink + hardlink bypass](https://github.com/advisories/GHSA-34x7-hfp2-rc4v) · [OWASP Zip Slip + zip-bomb reference](https://snyk.io/research/zip-slip-vulnerability) · [USENIX WOOT'19 — A better zip bomb (Fifield)](https://www.usenix.org/conference/woot19/presentation/fifield)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.12.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.12.9",
|
|
4
|
+
"createdAt": "2026-05-23T16:46:31.079Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -1706,6 +1706,10 @@
|
|
|
1706
1706
|
"type": "function",
|
|
1707
1707
|
"arity": 4
|
|
1708
1708
|
},
|
|
1709
|
+
"ArchiveGzError": {
|
|
1710
|
+
"type": "function",
|
|
1711
|
+
"arity": 4
|
|
1712
|
+
},
|
|
1709
1713
|
"TarError": {
|
|
1710
1714
|
"type": "function",
|
|
1711
1715
|
"arity": 4
|
|
@@ -1743,6 +1747,10 @@
|
|
|
1743
1747
|
}
|
|
1744
1748
|
}
|
|
1745
1749
|
},
|
|
1750
|
+
"gz": {
|
|
1751
|
+
"type": "function",
|
|
1752
|
+
"arity": 2
|
|
1753
|
+
},
|
|
1746
1754
|
"read": {
|
|
1747
1755
|
"type": "object",
|
|
1748
1756
|
"members": {
|
|
@@ -1796,6 +1804,14 @@
|
|
|
1796
1804
|
}
|
|
1797
1805
|
}
|
|
1798
1806
|
},
|
|
1807
|
+
"fromGzip": {
|
|
1808
|
+
"type": "function",
|
|
1809
|
+
"arity": 2
|
|
1810
|
+
},
|
|
1811
|
+
"gz": {
|
|
1812
|
+
"type": "function",
|
|
1813
|
+
"arity": 2
|
|
1814
|
+
},
|
|
1799
1815
|
"tar": {
|
|
1800
1816
|
"type": "function",
|
|
1801
1817
|
"arity": 2
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* archive-gz — gzip composition primitives. Sibling of lib/archive.js
|
|
4
|
+
* (ZIP write), lib/archive-read.js (ZIP read), lib/archive-tar.js (tar
|
|
5
|
+
* write), and lib/archive-tar-read.js (tar read). The @module block
|
|
6
|
+
* lives on lib/archive.js; this file declares only @primitive entries
|
|
7
|
+
* under the b.archive namespace (`b.archive.gz` write, `b.archive.read.gz`
|
|
8
|
+
* read). Bomb defenses ride with the read path: every read.gz call
|
|
9
|
+
* composes b.safeDecompress with the framework's default caps (1 GiB
|
|
10
|
+
* output / 100× ratio).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
var zlib = require("node:zlib");
|
|
14
|
+
var nodeCrypto = require("node:crypto");
|
|
15
|
+
var C = require("./constants");
|
|
16
|
+
var lazyRequire = require("./lazy-require");
|
|
17
|
+
var safeBuffer = require("./safe-buffer");
|
|
18
|
+
var { defineClass } = require("./framework-error");
|
|
19
|
+
|
|
20
|
+
var ArchiveGzError = defineClass("ArchiveGzError", { alwaysPermanent: true });
|
|
21
|
+
|
|
22
|
+
var safeDecompress = lazyRequire(function () { return require("./safe-decompress"); });
|
|
23
|
+
var archiveAdapters = lazyRequire(function () { return require("./archive-adapters"); });
|
|
24
|
+
var archiveRead = lazyRequire(function () { return require("./archive-read"); });
|
|
25
|
+
var archiveTarRead = lazyRequire(function () { return require("./archive-tar-read"); });
|
|
26
|
+
|
|
27
|
+
// gzip magic — RFC 1952 §2.2 ("ID1=0x1f, ID2=0x8b").
|
|
28
|
+
var GZIP_MAGIC_0 = 0x1f; // allow:raw-byte-literal — RFC 1952 §2.2 ID1
|
|
29
|
+
var GZIP_MAGIC_1 = 0x8b; // allow:raw-byte-literal — RFC 1952 §2.2 ID2
|
|
30
|
+
|
|
31
|
+
var DEFAULT_MAX_OUTPUT_BYTES = C.BYTES.gib(1);
|
|
32
|
+
var DEFAULT_MAX_RATIO = 100;
|
|
33
|
+
|
|
34
|
+
function _isGzipMagic(buf) {
|
|
35
|
+
return buf.length >= 2 && buf[0] === GZIP_MAGIC_0 && buf[1] === GZIP_MAGIC_1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @primitive b.archive.gz
|
|
40
|
+
* @signature b.archive.gz(bytes, opts?)
|
|
41
|
+
* @since 0.12.9
|
|
42
|
+
* @status stable
|
|
43
|
+
* @related b.archive.read.gz, b.safeDecompress
|
|
44
|
+
*
|
|
45
|
+
* Wrap a buffer in a gzip envelope. Returns a builder with the same
|
|
46
|
+
* write surface as the other `b.archive` builders — `toBuffer()` /
|
|
47
|
+
* `toAdapter(adapter)` / `digest()` — so gzip slots into the same
|
|
48
|
+
* downstream sinks (object-store + filesystem + http adapters).
|
|
49
|
+
*
|
|
50
|
+
* Composes `b.archive.tar().toGzip(adapter)` / `b.archive.zip().
|
|
51
|
+
* toGzip(adapter)` indirectly: those convenience methods call this
|
|
52
|
+
* primitive after materializing their archive bytes.
|
|
53
|
+
*
|
|
54
|
+
* @opts
|
|
55
|
+
* level: number, // 0-9, default 6 (zlib default).
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* var compressed = b.archive.gz(Buffer.from("hello world")).toBuffer();
|
|
59
|
+
* // → 31-byte gzip stream
|
|
60
|
+
*/
|
|
61
|
+
function gz(bytes, opts) {
|
|
62
|
+
opts = opts || {};
|
|
63
|
+
if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
|
|
64
|
+
throw new ArchiveGzError("archive-gz/bad-input",
|
|
65
|
+
"gz: input must be a Buffer or Uint8Array");
|
|
66
|
+
}
|
|
67
|
+
var input = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
68
|
+
var level = opts.level;
|
|
69
|
+
if (level !== undefined &&
|
|
70
|
+
(typeof level !== "number" || level < 0 || level > 9)) {
|
|
71
|
+
throw new ArchiveGzError("archive-gz/bad-arg",
|
|
72
|
+
"gz: opts.level must be a number 0-9; got " + JSON.stringify(level));
|
|
73
|
+
}
|
|
74
|
+
var compressed = null;
|
|
75
|
+
function _materialize() {
|
|
76
|
+
if (compressed !== null) return compressed;
|
|
77
|
+
var zopts = {};
|
|
78
|
+
if (typeof level === "number") zopts.level = level;
|
|
79
|
+
compressed = zlib.gzipSync(input, zopts);
|
|
80
|
+
return compressed;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
toBuffer: function () { return _materialize(); },
|
|
84
|
+
toAdapter: async function (adapter) {
|
|
85
|
+
if (!adapter || typeof adapter.write !== "function") {
|
|
86
|
+
throw new ArchiveGzError("archive-gz/bad-adapter",
|
|
87
|
+
"gz.toAdapter: adapter must be writable (no .write method)");
|
|
88
|
+
}
|
|
89
|
+
var buf = _materialize();
|
|
90
|
+
await adapter.write(buf);
|
|
91
|
+
if (typeof adapter.close === "function") await adapter.close();
|
|
92
|
+
},
|
|
93
|
+
digest: function () {
|
|
94
|
+
return nodeCrypto.createHash("sha3-512")
|
|
95
|
+
.update(_materialize())
|
|
96
|
+
.digest("hex");
|
|
97
|
+
},
|
|
98
|
+
get compressedBytes() { return _materialize().length; },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @primitive b.archive.read.gz
|
|
104
|
+
* @signature b.archive.read.gz(adapter, opts)
|
|
105
|
+
* @since 0.12.9
|
|
106
|
+
* @status stable
|
|
107
|
+
* @related b.archive.gz, b.safeDecompress, b.archive.read.tar, b.archive.read.zip
|
|
108
|
+
*
|
|
109
|
+
* Read a gzip stream from an adapter, surface it as either raw bytes
|
|
110
|
+
* (`toBuffer()`) or as a hand-off to a downstream archive reader
|
|
111
|
+
* (`asTar()` / `asZip()`). Every decompression composes
|
|
112
|
+
* `b.safeDecompress` with framework-default caps — `maxOutputBytes`
|
|
113
|
+
* (1 GiB) and `maxExpansionRatio` (100×) — so a hostile `tar.gz`
|
|
114
|
+
* fails the gz gate before any tar parsing happens.
|
|
115
|
+
*
|
|
116
|
+
* @opts
|
|
117
|
+
* maxDecompressedBytes: number, // default 1 GiB
|
|
118
|
+
* maxExpansionRatio: number, // default 100×
|
|
119
|
+
* audit: object,
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* var reader = b.archive.read.gz(b.archive.adapters.fs("./bundle.tar.gz"));
|
|
123
|
+
* var tarReader = reader.asTar();
|
|
124
|
+
* var result = await tarReader.extract({ destination: "./out" });
|
|
125
|
+
*/
|
|
126
|
+
function readGz(adapter, opts) {
|
|
127
|
+
opts = opts || {};
|
|
128
|
+
if (!adapter || typeof adapter !== "object") {
|
|
129
|
+
throw new ArchiveGzError("archive-gz/bad-adapter",
|
|
130
|
+
"read.gz: adapter is required");
|
|
131
|
+
}
|
|
132
|
+
var maxOutputBytes = opts.maxDecompressedBytes !== undefined
|
|
133
|
+
? opts.maxDecompressedBytes
|
|
134
|
+
: DEFAULT_MAX_OUTPUT_BYTES;
|
|
135
|
+
var maxRatio = opts.maxExpansionRatio !== undefined
|
|
136
|
+
? opts.maxExpansionRatio
|
|
137
|
+
: DEFAULT_MAX_RATIO;
|
|
138
|
+
var decompressed = null;
|
|
139
|
+
|
|
140
|
+
async function _collect() {
|
|
141
|
+
if (adapter.kind === "random-access") {
|
|
142
|
+
var size = adapter.size;
|
|
143
|
+
if (size == null && typeof adapter.resolveSize === "function") {
|
|
144
|
+
size = await adapter.resolveSize();
|
|
145
|
+
}
|
|
146
|
+
if (typeof size !== "number" || size === 0) {
|
|
147
|
+
throw new ArchiveGzError("archive-gz/empty-input",
|
|
148
|
+
"read.gz: adapter reports empty payload");
|
|
149
|
+
}
|
|
150
|
+
return adapter.range(0, size);
|
|
151
|
+
}
|
|
152
|
+
if (adapter.kind === "trusted-sequential") {
|
|
153
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
154
|
+
maxBytes: maxOutputBytes,
|
|
155
|
+
errorClass: ArchiveGzError,
|
|
156
|
+
sizeCode: "archive-gz/trusted-stream-too-large",
|
|
157
|
+
});
|
|
158
|
+
for await (var chunk of adapter.readable) collector.push(chunk);
|
|
159
|
+
return collector.result();
|
|
160
|
+
}
|
|
161
|
+
throw new ArchiveGzError("archive-gz/bad-adapter",
|
|
162
|
+
"read.gz: adapter kind " + JSON.stringify(adapter.kind) + " not supported");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function _materialize() {
|
|
166
|
+
if (decompressed !== null) return decompressed;
|
|
167
|
+
var compressed = await _collect();
|
|
168
|
+
if (!_isGzipMagic(compressed)) {
|
|
169
|
+
throw new ArchiveGzError("archive-gz/bad-magic",
|
|
170
|
+
"read.gz: input does not start with gzip magic 0x1f 0x8b");
|
|
171
|
+
}
|
|
172
|
+
decompressed = safeDecompress().safeDecompress(compressed, {
|
|
173
|
+
algorithm: "gzip",
|
|
174
|
+
maxOutputBytes: maxOutputBytes,
|
|
175
|
+
maxCompressedBytes: compressed.length,
|
|
176
|
+
maxRatio: maxRatio,
|
|
177
|
+
audit: opts.audit,
|
|
178
|
+
});
|
|
179
|
+
return decompressed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
toBuffer: async function () { return _materialize(); },
|
|
184
|
+
asTar: function (tarOpts) {
|
|
185
|
+
tarOpts = tarOpts || {};
|
|
186
|
+
return {
|
|
187
|
+
inspect: async function () {
|
|
188
|
+
var bytes = await _materialize();
|
|
189
|
+
var reader = archiveTarRead().tar(
|
|
190
|
+
archiveAdapters().buffer(bytes), tarOpts);
|
|
191
|
+
return reader.inspect();
|
|
192
|
+
},
|
|
193
|
+
extract: async function (extractOpts) {
|
|
194
|
+
var bytes = await _materialize();
|
|
195
|
+
var reader = archiveTarRead().tar(
|
|
196
|
+
archiveAdapters().buffer(bytes), tarOpts);
|
|
197
|
+
return reader.extract(extractOpts);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
asZip: function (zipOpts) {
|
|
202
|
+
zipOpts = zipOpts || {};
|
|
203
|
+
return {
|
|
204
|
+
inspect: async function () {
|
|
205
|
+
var bytes = await _materialize();
|
|
206
|
+
var reader = archiveRead().zip(
|
|
207
|
+
archiveAdapters().buffer(bytes), zipOpts);
|
|
208
|
+
return reader.inspect();
|
|
209
|
+
},
|
|
210
|
+
extract: async function (extractOpts) {
|
|
211
|
+
var bytes = await _materialize();
|
|
212
|
+
var reader = archiveRead().zip(
|
|
213
|
+
archiveAdapters().buffer(bytes), zipOpts);
|
|
214
|
+
return reader.extract(extractOpts);
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
gz: gz,
|
|
223
|
+
read: { gz: readGz },
|
|
224
|
+
ArchiveGzError: ArchiveGzError,
|
|
225
|
+
// Exposed for sibling modules
|
|
226
|
+
_isGzipMagic: _isGzipMagic,
|
|
227
|
+
GZIP_MAGIC_0: GZIP_MAGIC_0,
|
|
228
|
+
GZIP_MAGIC_1: GZIP_MAGIC_1,
|
|
229
|
+
};
|
|
@@ -70,12 +70,18 @@
|
|
|
70
70
|
var nodeStream = require("node:stream");
|
|
71
71
|
var streamPromises = require("node:stream/promises");
|
|
72
72
|
var C = require("./constants");
|
|
73
|
+
var lazyRequire = require("./lazy-require");
|
|
73
74
|
var { defineClass } = require("./framework-error");
|
|
74
75
|
|
|
75
76
|
var TarError = defineClass("TarError", { alwaysPermanent: true });
|
|
76
77
|
|
|
77
78
|
void streamPromises; void nodeStream;
|
|
78
79
|
|
|
80
|
+
// Lazy because archive-gz lazy-imports archive-tar-read (sibling read
|
|
81
|
+
// module) — a top-of-file `require("./archive-gz")` would create a
|
|
82
|
+
// load-order cycle that depends on file-walk order.
|
|
83
|
+
var archiveGz = lazyRequire(function () { return require("./archive-gz"); });
|
|
84
|
+
|
|
79
85
|
// ---- Wire-format constants -----------------------------------------------
|
|
80
86
|
|
|
81
87
|
var BLOCK_SIZE = C.BYTES.bytes(512); // tar block size (POSIX)
|
|
@@ -498,12 +504,20 @@ function tarBuilder() {
|
|
|
498
504
|
return nodeCrypto.createHash("sha3-512").update(toBuffer()).digest("hex");
|
|
499
505
|
}
|
|
500
506
|
|
|
507
|
+
async function toGzip(adapter, gzOpts) {
|
|
508
|
+
// Convenience composition: materialize the tar then wrap through
|
|
509
|
+
// b.archive.gz. archive-gz is lazy-required at module top to break
|
|
510
|
+
// the load-order cycle with archive-tar-read.
|
|
511
|
+
return archiveGz().gz(toBuffer(), gzOpts || {}).toAdapter(adapter);
|
|
512
|
+
}
|
|
513
|
+
|
|
501
514
|
return {
|
|
502
515
|
addFile: addFile,
|
|
503
516
|
addDirectory: addDirectory,
|
|
504
517
|
toBuffer: toBuffer,
|
|
505
518
|
toStream: toStream,
|
|
506
519
|
toAdapter: toAdapter,
|
|
520
|
+
toGzip: toGzip,
|
|
507
521
|
digest: digest,
|
|
508
522
|
get entryCount() { return entries.length; },
|
|
509
523
|
};
|
|
@@ -545,15 +545,20 @@ function zip() {
|
|
|
545
545
|
var archiveRead = require("./archive-read");
|
|
546
546
|
var archiveTar = require("./archive-tar");
|
|
547
547
|
var archiveTarRead = require("./archive-tar-read");
|
|
548
|
+
var archiveGz = require("./archive-gz");
|
|
548
549
|
|
|
549
550
|
module.exports = {
|
|
550
|
-
zip:
|
|
551
|
-
tar:
|
|
552
|
-
|
|
553
|
-
|
|
551
|
+
zip: zip,
|
|
552
|
+
tar: archiveTar.tar,
|
|
553
|
+
gz: archiveGz.gz,
|
|
554
|
+
ArchiveError: ArchiveError,
|
|
555
|
+
TarError: archiveTar.TarError,
|
|
556
|
+
ArchiveGzError: archiveGz.ArchiveGzError,
|
|
554
557
|
read: {
|
|
555
558
|
zip: archiveRead.zip,
|
|
556
559
|
tar: archiveTarRead.tar,
|
|
560
|
+
gz: archiveGz.read.gz,
|
|
561
|
+
fromGzip: archiveGz.read.gz,
|
|
557
562
|
ArchiveReadError: archiveRead.ArchiveReadError,
|
|
558
563
|
DEFAULT_BOMB_POLICY: archiveRead.DEFAULT_BOMB_POLICY,
|
|
559
564
|
DEFAULT_ENTRY_TYPE_POLICY: archiveRead.DEFAULT_ENTRY_TYPE_POLICY,
|
|
@@ -1064,9 +1064,9 @@ function bundleAdapterStorage(opts) {
|
|
|
1064
1064
|
// operator-supplied so a single backup engine can transition over
|
|
1065
1065
|
// time + b.backup.migrate() handles the directory → tar conversion.
|
|
1066
1066
|
var format = opts.format || "tar";
|
|
1067
|
-
if (format !== "tar" && format !== "directory") {
|
|
1067
|
+
if (format !== "tar" && format !== "tar.gz" && format !== "directory") {
|
|
1068
1068
|
throw new BackupError("backup/bad-format",
|
|
1069
|
-
"bundleAdapterStorage: format must be \"tar\" (default)
|
|
1069
|
+
"bundleAdapterStorage: format must be \"tar\" (default) | \"tar.gz\" (v0.12.9 compressed) | \"directory\" (legacy v0.12.7)");
|
|
1070
1070
|
}
|
|
1071
1071
|
// Codex P2 on v0.12.8 PR #159 — tar mode builds the whole archive
|
|
1072
1072
|
// in memory before adapter.writeFile because the v0.12.8 adapter
|
|
@@ -1111,13 +1111,16 @@ function bundleAdapterStorage(opts) {
|
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
1113
|
// Tar-format bundle storage stores the whole bundle as a single
|
|
1114
|
-
// key under `<bundleId>/bundle.tar
|
|
1115
|
-
//
|
|
1114
|
+
// key under `<bundleId>/bundle.tar` (or `<bundleId>/bundle.tar.gz`
|
|
1115
|
+
// for the v0.12.9 compressed variant). The marker is named that
|
|
1116
|
+
// way so listBundles + hasBundle can locate either format by key
|
|
1116
1117
|
// prefix walk.
|
|
1117
1118
|
var TAR_KEY_SUFFIX = "/bundle.tar";
|
|
1119
|
+
var TAR_GZ_KEY_SUFFIX = "/bundle.tar.gz";
|
|
1118
1120
|
|
|
1119
1121
|
function _hasBundleKey(bundleId, format) {
|
|
1120
1122
|
if (format === "tar") return adapter.hasKey(bundleId + TAR_KEY_SUFFIX);
|
|
1123
|
+
if (format === "tar.gz") return adapter.hasKey(bundleId + TAR_GZ_KEY_SUFFIX);
|
|
1121
1124
|
return adapter.hasKey(bundleId + "/manifest.json");
|
|
1122
1125
|
}
|
|
1123
1126
|
|
|
@@ -1134,9 +1137,12 @@ function bundleAdapterStorage(opts) {
|
|
|
1134
1137
|
throw new BackupError("backup/bundle-exists",
|
|
1135
1138
|
"writeBundle: bundle '" + bundleId + "' already exists in storage");
|
|
1136
1139
|
}
|
|
1137
|
-
if (format === "tar") {
|
|
1140
|
+
if (format === "tar" || format === "tar.gz") {
|
|
1138
1141
|
// Pack the source-directory tree into a single tar archive +
|
|
1139
|
-
// store under one key. Composes b.archive.tar.
|
|
1142
|
+
// store under one key. Composes b.archive.tar (+ b.archive.gz
|
|
1143
|
+
// for the tar.gz variant which adds gzip compression on the
|
|
1144
|
+
// wire). Bundle sizes drop ~3-5× on text-heavy backups
|
|
1145
|
+
// (databases, JSON exports, mail spools) under tar.gz.
|
|
1140
1146
|
//
|
|
1141
1147
|
// Codex P2 on v0.12.8 PR #159 — tar bytes are materialized in
|
|
1142
1148
|
// memory because the v0.12.8 adapter contract is bytes-in
|
|
@@ -1165,8 +1171,11 @@ function bundleAdapterStorage(opts) {
|
|
|
1165
1171
|
var bytes = nodeFs.readFileSync(nodePath.join(sourceDir, rel));
|
|
1166
1172
|
t.addFile(rel, bytes);
|
|
1167
1173
|
}
|
|
1168
|
-
var
|
|
1169
|
-
|
|
1174
|
+
var keySuffix = format === "tar.gz" ? TAR_GZ_KEY_SUFFIX : TAR_KEY_SUFFIX;
|
|
1175
|
+
var payloadBytes = format === "tar.gz"
|
|
1176
|
+
? archiveLazy().gz(t.toBuffer()).toBuffer()
|
|
1177
|
+
: t.toBuffer();
|
|
1178
|
+
await adapter.writeFile(bundleId + keySuffix, payloadBytes);
|
|
1170
1179
|
return;
|
|
1171
1180
|
}
|
|
1172
1181
|
// Directory format (v0.12.7 layout).
|
|
@@ -1184,14 +1193,36 @@ function bundleAdapterStorage(opts) {
|
|
|
1184
1193
|
"readBundle: destDir already exists: " + destDir);
|
|
1185
1194
|
}
|
|
1186
1195
|
// Detect which format this bundle is in — operators with mixed
|
|
1187
|
-
// pre-v0.12.8 + post-v0.12.8 bundles can read
|
|
1196
|
+
// pre-v0.12.8 + post-v0.12.8 (+ v0.12.9 tar.gz) bundles can read
|
|
1197
|
+
// every flavor back.
|
|
1188
1198
|
var hasTar = await adapter.hasKey(bundleId + TAR_KEY_SUFFIX);
|
|
1199
|
+
var hasTarGz = await adapter.hasKey(bundleId + TAR_GZ_KEY_SUFFIX);
|
|
1189
1200
|
var hasManifest = await adapter.hasKey(bundleId + "/manifest.json");
|
|
1190
|
-
if (!hasTar && !hasManifest) {
|
|
1201
|
+
if (!hasTar && !hasTarGz && !hasManifest) {
|
|
1191
1202
|
throw new BackupError("backup/bundle-not-found",
|
|
1192
1203
|
"readBundle: '" + bundleId + "' not in storage");
|
|
1193
1204
|
}
|
|
1194
1205
|
atomicFile.ensureDir(destDir);
|
|
1206
|
+
if (hasTarGz) {
|
|
1207
|
+
// Codex P1/P2 on v0.12.9 PR #160 — propagate maxBundleBytes
|
|
1208
|
+
// to the gz restore path + disable the expansion-ratio cap.
|
|
1209
|
+
// archive.read.gz defaults (1 GiB output / 100× ratio) are
|
|
1210
|
+
// bomb-defense settings appropriate for adversarial input;
|
|
1211
|
+
// this is a SELF-AUTHORED bundle the writeBundle path
|
|
1212
|
+
// produced under maxBundleBytes — the restore contract is
|
|
1213
|
+
// "decompress to at most what was permitted on write." A
|
|
1214
|
+
// zero-filled DB file can easily hit >100× compression
|
|
1215
|
+
// ratio; without these opts the same primitive writes
|
|
1216
|
+
// bundles it can't read back.
|
|
1217
|
+
var gzBytes = await adapter.readFile(bundleId + TAR_GZ_KEY_SUFFIX);
|
|
1218
|
+
var gzReader = archiveLazy().read.gz(archiveAdaptersLazy().buffer(gzBytes), {
|
|
1219
|
+
maxDecompressedBytes: maxBundleBytes,
|
|
1220
|
+
maxExpansionRatio: 0,
|
|
1221
|
+
});
|
|
1222
|
+
var tarReader = gzReader.asTar();
|
|
1223
|
+
await tarReader.extract({ destination: destDir });
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1195
1226
|
if (hasTar) {
|
|
1196
1227
|
var tarBytes = await adapter.readFile(bundleId + TAR_KEY_SUFFIX);
|
|
1197
1228
|
var reader = archiveLazy().read.tar(archiveAdaptersLazy().buffer(tarBytes));
|
|
@@ -1217,7 +1248,13 @@ function bundleAdapterStorage(opts) {
|
|
|
1217
1248
|
}
|
|
1218
1249
|
atomicFile.ensureDir(nodePath.dirname(destPath));
|
|
1219
1250
|
var bytes = await adapter.readFile(key);
|
|
1220
|
-
|
|
1251
|
+
// Exclusive-create (wx) carries the v0.12.7 atomic-rollback
|
|
1252
|
+
// contract: readBundle refuses to overwrite pre-existing
|
|
1253
|
+
// files at destPath. Combined with the upfront destDir check
|
|
1254
|
+
// above (refuses if destDir already exists), the only way
|
|
1255
|
+
// wx fires here is a symlink swap between ensureDir and write
|
|
1256
|
+
// — which the framework refuses rather than following.
|
|
1257
|
+
nodeFs.writeFileSync(destPath, bytes, { flag: "wx", mode: 0o600 });
|
|
1221
1258
|
}
|
|
1222
1259
|
},
|
|
1223
1260
|
async listBundles() {
|
|
@@ -1264,12 +1301,15 @@ function bundleAdapterStorage(opts) {
|
|
|
1264
1301
|
async hasBundle(bundleId) {
|
|
1265
1302
|
_ensureBundleId(bundleId);
|
|
1266
1303
|
// Format-aware: check the storage layout's marker key. Tar
|
|
1267
|
-
// bundles store under <bid>/bundle.tar;
|
|
1268
|
-
// under <bid>/
|
|
1269
|
-
//
|
|
1304
|
+
// bundles store under <bid>/bundle.tar; tar.gz bundles store
|
|
1305
|
+
// under <bid>/bundle.tar.gz; directory bundles store under
|
|
1306
|
+
// <bid>/manifest.json. Operators with a mixed bundle set
|
|
1307
|
+
// (some tar, some tar.gz, some directory) get true for any.
|
|
1270
1308
|
var tarKey = bundleId + TAR_KEY_SUFFIX;
|
|
1309
|
+
var tarGzKey = bundleId + TAR_GZ_KEY_SUFFIX;
|
|
1271
1310
|
var dirKey = bundleId + "/manifest.json";
|
|
1272
1311
|
if (await adapter.hasKey(tarKey)) return true;
|
|
1312
|
+
if (await adapter.hasKey(tarGzKey)) return true;
|
|
1273
1313
|
if (await adapter.hasKey(dirKey)) return true;
|
|
1274
1314
|
return false;
|
|
1275
1315
|
},
|
|
@@ -46,6 +46,7 @@ var SafeArchiveError = defineClass("SafeArchiveError", { alwaysPermanent: true }
|
|
|
46
46
|
var archiveRead = lazyRequire(function () { return require("./archive-read"); });
|
|
47
47
|
var archiveAdapters = lazyRequire(function () { return require("./archive-adapters"); });
|
|
48
48
|
var archiveTarRead = lazyRequire(function () { return require("./archive-tar-read"); });
|
|
49
|
+
var archiveGz = lazyRequire(function () { return require("./archive-gz"); });
|
|
49
50
|
|
|
50
51
|
// ---- Format sniffing ----------------------------------------------------
|
|
51
52
|
|
|
@@ -194,10 +195,23 @@ async function extract(opts) {
|
|
|
194
195
|
guardProfile: opts.guardProfile,
|
|
195
196
|
audit: opts.audit,
|
|
196
197
|
});
|
|
198
|
+
} else if (format === "tar.gz") {
|
|
199
|
+
// gzip envelope around tar — safeDecompress caps run on the gz
|
|
200
|
+
// layer before the tar walker ever sees a decompressed byte.
|
|
201
|
+
reader = archiveGz().read.gz(source, {
|
|
202
|
+
maxDecompressedBytes: opts.maxDecompressedBytes,
|
|
203
|
+
maxExpansionRatio: opts.maxExpansionRatio,
|
|
204
|
+
audit: opts.audit,
|
|
205
|
+
}).asTar({
|
|
206
|
+
bombPolicy: opts.bombPolicy,
|
|
207
|
+
entryTypePolicy: opts.entryTypePolicy,
|
|
208
|
+
guardProfile: opts.guardProfile,
|
|
209
|
+
audit: opts.audit,
|
|
210
|
+
});
|
|
197
211
|
} else {
|
|
198
212
|
throw new SafeArchiveError("safe-archive/format-unsupported",
|
|
199
|
-
"extract: format=" + JSON.stringify(format) + " — v0.12.
|
|
200
|
-
"(
|
|
213
|
+
"extract: format=" + JSON.stringify(format) + " — v0.12.9 ships ZIP + tar + tar.gz " +
|
|
214
|
+
"(encryptPacked-wrap lands v0.12.10)");
|
|
201
215
|
}
|
|
202
216
|
var result = await reader.extract({
|
|
203
217
|
destination: opts.destination,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.9",
|
|
4
|
+
"date": "2026-05-23",
|
|
5
|
+
"headline": "`b.archive.gz` + `b.archive.read.gz` — gzip composition with `b.safeDecompress` bomb caps + `b.backup` `tar.gz` bundle format + `sha-to-tag verify` fetches `origin/main`",
|
|
6
|
+
"summary": "gzip lands as the composition layer over the archive family. `b.archive.gz(bytes)` produces an RFC 1952 gzip stream with the same `toBuffer()` / `toAdapter(adapter)` / `digest()` shape every archive builder ships, and `b.archive.read.gz(adapter, opts)` reads it back through `b.safeDecompress` so a malicious `tar.gz` fails the gzip-layer bomb cap (1 GiB output / 100× ratio defaults) before the tar walker ever sees a decompressed byte. The reader exposes `toBuffer()` / `asTar(opts)` / `asZip(opts)` so operators can hand the decompressed bytes directly to a downstream archive reader without a round-trip through disk. `b.archive.tar().toGzip(adapter, opts)` is the write-side convenience for the most common combination. `b.backup.bundleAdapterStorage({ format: \"tar.gz\" })` adds gzip compression on the wire — bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); the readback path detects the format from the storage key suffix and composes `b.safeDecompress` automatically. The `sha-to-tag verify` workflow now explicitly fetches `origin/main` before walking the first-parent history, fixing a stale-ref bug that silently failed v0.12.6 through v0.12.8 tag verifications (the publish workflow itself was unaffected; the gate is independent).",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.archive.gz(bytes)` — standalone gzip write builder",
|
|
13
|
+
"body": "RFC 1952 gzip envelope with the standard archive-builder shape. `toBuffer()` returns the compressed bytes; `toAdapter(adapter)` writes through any writable adapter (fs / object-store / http) that exposes `.write(bytes)` + optional `.close()`; `digest()` returns a SHA3-512 hex hash of the compressed payload for operator integrity logs. `opts.level` accepts 0-9 (zlib default 6). Composes cleanly under `b.archive.tar().toGzip(adapter)` / `b.archive.zip()` for tar.gz / zip.gz convenience."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.archive.read.gz(adapter, opts)` — gunzip reader with `b.safeDecompress` bomb caps",
|
|
17
|
+
"body": "Every decompression routes through `b.safeDecompress({ algorithm: \"gzip\", maxOutputBytes, maxRatio })` so a hostile gzip stream fails the bomb gate before any downstream parsing happens. Defaults: `maxDecompressedBytes` = 1 GiB, `maxExpansionRatio` = 100×. The reader exposes three downstream entry points: `toBuffer()` returns the raw decompressed bytes; `asTar(opts)` returns a `b.archive.read.tar` reader over the decompressed payload; `asZip(opts)` returns a `b.archive.read.zip` reader. `fromGzip` is the documented alias the spec uses (operators may reach for either). Refuses non-gzip input upfront via the `0x1f 0x8b` magic check (`archive-gz/bad-magic`)."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.archive.tar().toGzip(adapter, opts)` — tar.gz write convenience",
|
|
21
|
+
"body": "Pipes the tar builder's `toBuffer()` through `b.archive.gz()` and writes the resulting gzip envelope to a writable adapter. Equivalent to `b.archive.gz(t.toBuffer()).toAdapter(adapter)` but lets the operator stay in the tar-builder fluent chain when composing under fs / object-store / http adapters."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "`b.backup.bundleAdapterStorage({ format: \"tar.gz\" })` — compressed-on-the-wire bundles",
|
|
25
|
+
"body": "Adds gzip compression to the v0.12.8 tar bundle format. Bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); binary-heavy backups (compressed databases, encrypted archives) see ~1.0-1.1×. Read paths auto-detect via the `<bundleId>/bundle.tar.gz` storage key suffix and route through `b.safeDecompress` on readback. The v0.12.8 `maxBundleBytes` cap continues to gate against pathological projected-uncompressed sizes; `tar.gz` does not bypass it."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"title": "`b.safeArchive.extract({ format: \"tar.gz\" })` — explicit tar.gz dispatch",
|
|
29
|
+
"body": "Operators handed a `.tar.gz` upload pass `format: \"tar.gz\"` explicitly; the orchestrator composes `b.archive.read.gz` → `.asTar()` and feeds the standard tar bomb-policy + entry-type-policy + guardProfile through. Defer-with-condition: auto-sniff for tar.gz (peek inside the gzip envelope for ustar magic at offset 257 of the decompressed prefix) lands when operator demand surfaces; today operators with `auto` mode on a `.tar.gz` payload get `format-unsupported gzip` with the explicit-format hint in the error message."
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"heading": "Security",
|
|
35
|
+
"items": [
|
|
36
|
+
{
|
|
37
|
+
"title": "Bomb caps ride at the gz layer, not the tar/zip layer",
|
|
38
|
+
"body": "The decompression gate is enforced BEFORE the downstream archive reader sees any bytes — a hostile `tar.gz` that would decompress to 10 GiB of zero-filled tar entries fails the 1 GiB `maxDecompressedBytes` default cap during gunzip, never reaching the tar walker. Operators with legitimately large compressed archives pass `maxDecompressedBytes` higher; the framework refuses without an explicit opt-in. RFC 1952 §2.3.1 magic enforcement prevents content-type confusion (gzip-pretending-to-be-something-else inputs)."
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"heading": "Fixed",
|
|
44
|
+
"items": [
|
|
45
|
+
{
|
|
46
|
+
"title": "`sha-to-tag verify` workflow fetches `origin/main` before first-parent walk",
|
|
47
|
+
"body": "The release-tag integrity gate runs on every `v*` tag push and verifies the tag's commit SHA appears on `main`'s first-parent history. `actions/checkout` was being asked for full history of the tag ref alone — `origin/main` wasn't fetched as a side effect, so `git rev-list --first-parent origin/main | grep -qx \"$SHA\"` walked a stale (or absent) ref and falsely refused. The check now explicitly fetches `origin/main` after checkout so the walk sees the current squash-merge HEAD. Affected releases (v0.12.6 / v0.12.7 / v0.12.8) had publish workflows that completed normally — `sha-to-tag verify` is an independent gate that was silently failing alongside successful publishes; nothing about the published artifacts was wrong."
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"heading": "Detectors",
|
|
53
|
+
"items": [
|
|
54
|
+
{
|
|
55
|
+
"title": "`archive-gz-without-safedecompress` — direct `node:zlib` gunzip in `lib/` must compose `b.safeDecompress`",
|
|
56
|
+
"body": "Mirrors the v0.11.5 must-compose pattern: any `lib/` call to `zlib.gunzipSync` / `zlib.createGunzip` / `gunzip` outside `lib/archive-gz.js` (which IS the canonical gunzip site, with `b.safeDecompress` wired in) must carry an `allow:archive-gz-without-safedecompress` marker explaining why the bomb gate is bypassed. The detector locks the contract so v0.13+ work that touches a gzip-handling primitive can't quietly drop the cap."
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.archive.gz write + b.archive.read.gz read +
|
|
4
|
+
* tar.gz composition + bundleAdapterStorage tar.gz format.
|
|
5
|
+
*
|
|
6
|
+
* Coverage: round-trip standalone gzip, safeDecompress bomb caps,
|
|
7
|
+
* magic refusal, asTar/asZip composition, backup tar.gz end-to-end.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
var fs = require("node:fs");
|
|
11
|
+
var path = require("node:path");
|
|
12
|
+
var os = require("node:os");
|
|
13
|
+
var b = require("../../index");
|
|
14
|
+
var helpers = require("../helpers");
|
|
15
|
+
var check = helpers.check;
|
|
16
|
+
|
|
17
|
+
async function testGzRoundTrip() {
|
|
18
|
+
var src = Buffer.from("hello world ".repeat(100));
|
|
19
|
+
var compressed = b.archive.gz(src).toBuffer();
|
|
20
|
+
check("archive.gz: compressed shorter than source",
|
|
21
|
+
compressed.length < src.length);
|
|
22
|
+
check("archive.gz: gzip magic 0x1f 0x8b",
|
|
23
|
+
compressed[0] === 0x1f && compressed[1] === 0x8b);
|
|
24
|
+
var reader = b.archive.read.gz(b.archive.adapters.buffer(compressed));
|
|
25
|
+
var roundTrip = await reader.toBuffer();
|
|
26
|
+
check("archive.read.gz: round-trip preserves bytes",
|
|
27
|
+
roundTrip.equals(src));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function testGzBadMagicRefused() {
|
|
31
|
+
var notGzip = Buffer.from("this is not gzipped content at all");
|
|
32
|
+
var refused = null;
|
|
33
|
+
try {
|
|
34
|
+
var reader = b.archive.read.gz(b.archive.adapters.buffer(notGzip));
|
|
35
|
+
await reader.toBuffer();
|
|
36
|
+
} catch (e) { refused = e; }
|
|
37
|
+
check("archive.read.gz: non-gzip input refused with typed error",
|
|
38
|
+
refused && /bad-magic/.test(refused.code || refused.message));
|
|
39
|
+
check("archive.read.gz: refusal is a b.archive.ArchiveGzError",
|
|
40
|
+
refused instanceof b.archive.ArchiveGzError);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function testGzBombCapRefused() {
|
|
44
|
+
// Build a 64 KiB gzip stream of all-zeros (high compression ratio).
|
|
45
|
+
// Cap the decompressed size to 16 KiB → must refuse.
|
|
46
|
+
var src = Buffer.alloc(64 * 1024);
|
|
47
|
+
var compressed = b.archive.gz(src).toBuffer();
|
|
48
|
+
var reader = b.archive.read.gz(b.archive.adapters.buffer(compressed), {
|
|
49
|
+
maxDecompressedBytes: 16 * 1024,
|
|
50
|
+
});
|
|
51
|
+
var refused = null;
|
|
52
|
+
try { await reader.toBuffer(); } catch (e) { refused = e; }
|
|
53
|
+
check("archive.read.gz: maxDecompressedBytes cap refuses bomb",
|
|
54
|
+
refused !== null);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function testTarToGzip() {
|
|
58
|
+
var t = b.archive.tar();
|
|
59
|
+
t.addFile("payload.txt", "hello world");
|
|
60
|
+
// toAdapter expects an archive-adapter shape: write(bytes) + close().
|
|
61
|
+
var chunks = [];
|
|
62
|
+
var adapter = {
|
|
63
|
+
write: async function (bytes) { chunks.push(bytes); },
|
|
64
|
+
close: async function () { /* noop */ },
|
|
65
|
+
};
|
|
66
|
+
await t.toGzip(adapter);
|
|
67
|
+
var assembled = Buffer.concat(chunks);
|
|
68
|
+
check("archive.tar().toGzip: produces gzip magic",
|
|
69
|
+
assembled[0] === 0x1f && assembled[1] === 0x8b);
|
|
70
|
+
// Round-trip through read.gz.asTar
|
|
71
|
+
var reader = b.archive.read.gz(b.archive.adapters.buffer(assembled));
|
|
72
|
+
var tarReader = reader.asTar();
|
|
73
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgz-"));
|
|
74
|
+
try {
|
|
75
|
+
await tarReader.extract({ destination: dest });
|
|
76
|
+
check("archive.tar().toGzip → read.gz.asTar.extract: file restored",
|
|
77
|
+
fs.readFileSync(path.join(dest, "payload.txt"), "utf-8") === "hello world");
|
|
78
|
+
} finally {
|
|
79
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function testBackupTarGzHighRatioRoundTrip() {
|
|
84
|
+
// Codex P1 on v0.12.9 PR #160 — a zero-filled file compresses
|
|
85
|
+
// at >100× ratio; the default safeDecompress ratio cap was
|
|
86
|
+
// refusing legitimate self-authored bundles on read.
|
|
87
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgzr-src-"));
|
|
88
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgzr-dest-"));
|
|
89
|
+
var verify = path.join(os.tmpdir(), "bjs-tgzr-verify-" + Date.now());
|
|
90
|
+
try {
|
|
91
|
+
// 1 MiB of zeros — gzip should compress this to a few hundred
|
|
92
|
+
// bytes (ratio ~ 5000×), well past the 100× default.
|
|
93
|
+
fs.writeFileSync(path.join(src, "zeros.bin"), Buffer.alloc(1024 * 1024));
|
|
94
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
95
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
96
|
+
format: "tar.gz",
|
|
97
|
+
});
|
|
98
|
+
var bundleId = "2026-05-23T17-30-00-000Z-11223344";
|
|
99
|
+
await storage.writeBundle(bundleId, src);
|
|
100
|
+
await storage.readBundle(bundleId, verify);
|
|
101
|
+
check("backup tar.gz: high-ratio bundle restores past the 100× default",
|
|
102
|
+
fs.readFileSync(path.join(verify, "zeros.bin")).length === 1024 * 1024);
|
|
103
|
+
} finally {
|
|
104
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
105
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
106
|
+
try { fs.rmSync(verify, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function testBackupTarGzRoundTrip() {
|
|
111
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgz-src-"));
|
|
112
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-tgz-dest-"));
|
|
113
|
+
var verify = path.join(os.tmpdir(), "bjs-tgz-verify-" + Date.now());
|
|
114
|
+
try {
|
|
115
|
+
fs.writeFileSync(path.join(src, "a.txt"), "hello world ".repeat(100));
|
|
116
|
+
fs.writeFileSync(path.join(src, "b.txt"), "goodbye ".repeat(50));
|
|
117
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
118
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
119
|
+
format: "tar.gz",
|
|
120
|
+
});
|
|
121
|
+
var bundleId = "2026-05-23T17-00-00-000Z-aabbccdd";
|
|
122
|
+
await storage.writeBundle(bundleId, src);
|
|
123
|
+
var bundleDir = path.join(dest, bundleId);
|
|
124
|
+
check("backup tar.gz: bundle.tar.gz key written",
|
|
125
|
+
fs.existsSync(path.join(bundleDir, "bundle.tar.gz")));
|
|
126
|
+
var gzBytes = fs.readFileSync(path.join(bundleDir, "bundle.tar.gz"));
|
|
127
|
+
check("backup tar.gz: payload carries gzip magic",
|
|
128
|
+
gzBytes[0] === 0x1f && gzBytes[1] === 0x8b);
|
|
129
|
+
check("backup tar.gz: hasBundle true for tar.gz format",
|
|
130
|
+
await storage.hasBundle(bundleId));
|
|
131
|
+
await storage.readBundle(bundleId, verify);
|
|
132
|
+
check("backup tar.gz: a.txt round-trips",
|
|
133
|
+
fs.readFileSync(path.join(verify, "a.txt"), "utf-8") === "hello world ".repeat(100));
|
|
134
|
+
check("backup tar.gz: b.txt round-trips",
|
|
135
|
+
fs.readFileSync(path.join(verify, "b.txt"), "utf-8") === "goodbye ".repeat(50));
|
|
136
|
+
} finally {
|
|
137
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
138
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
139
|
+
try { fs.rmSync(verify, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function run() {
|
|
144
|
+
await testGzRoundTrip();
|
|
145
|
+
await testGzBadMagicRefused();
|
|
146
|
+
await testGzBombCapRefused();
|
|
147
|
+
await testTarToGzip();
|
|
148
|
+
await testBackupTarGzRoundTrip();
|
|
149
|
+
await testBackupTarGzHighRatioRoundTrip();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { run: run };
|
|
153
|
+
|
|
154
|
+
if (require.main === module) {
|
|
155
|
+
run().then(
|
|
156
|
+
function () { console.log("[archive-gz] OK — " + helpers.getChecks() + " checks passed"); },
|
|
157
|
+
function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -5615,6 +5615,59 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
5615
5615
|
reason: "Codex P1 on v0.12.7 PR #158 — archive-read.extract used renameSync to atomically place each decompressed entry at its canonical destination + tracked written[].path for catch-block cleanup. When the destination directory was non-empty, the rename silently overwrote operator files; on extract abort, the cleanup deleted them. Fix: refuse upfront if destination path exists, force operators to use a fresh / empty subtree. Detector locks the shape: any extract code that tracks resolvedPath for catch-block cleanup MUST carry a `destination-exists` refusal in the same file.",
|
|
5616
5616
|
},
|
|
5617
5617
|
|
|
5618
|
+
{
|
|
5619
|
+
// Codex P1 + P2 on v0.12.9 PR #160 — backup readBundle's
|
|
5620
|
+
// tar.gz restore path inherited archive.read.gz defaults (1 GiB
|
|
5621
|
+
// output / 100× ratio), which made the SAME primitive write
|
|
5622
|
+
// bundles it couldn't read back. The detector enforces the
|
|
5623
|
+
// write/read contract for self-authored gzip payloads: any
|
|
5624
|
+
// lib/ call to `archive.read.gz(...)` from a context that has
|
|
5625
|
+
// its own size budget (paired with a `maxBundleBytes` /
|
|
5626
|
+
// `maxOutputBytes` / `maxPayloadBytes` opt) MUST propagate
|
|
5627
|
+
// that budget to read.gz via `maxDecompressedBytes` AND
|
|
5628
|
+
// disable the ratio cap (`maxExpansionRatio: 0`) — bombs in
|
|
5629
|
+
// self-authored payloads are already prevented at write time.
|
|
5630
|
+
id: "archive-read-gz-without-self-authored-budget",
|
|
5631
|
+
primitive: "callers of archive.read.gz from a context that gates its own writes on a size cap (maxBundleBytes / similar) must pass maxDecompressedBytes + maxExpansionRatio:0 so the write/read contract is symmetric. Bomb defenses live at the upstream cap; the gz layer just decompresses.",
|
|
5632
|
+
// File-scoped: only fires on backup/index.js shapes for now.
|
|
5633
|
+
// archive.read.gz called with no opts is fine in operator code
|
|
5634
|
+
// (adversarial-input case); the antipattern is when the caller
|
|
5635
|
+
// also writes payloads under its own size cap.
|
|
5636
|
+
regex: /archive(?:Lazy\(\))?\.read\.gz\s*\([^)]*\)\s*[^,{]/,
|
|
5637
|
+
requires: /maxDecompressedBytes/,
|
|
5638
|
+
skipCommentLines: true,
|
|
5639
|
+
allowlist: [
|
|
5640
|
+
// archive-gz.js IS the read.gz primitive itself.
|
|
5641
|
+
"lib/archive-gz.js",
|
|
5642
|
+
],
|
|
5643
|
+
reason: "Codex P1/P2 on v0.12.9 PR #160 — backup readBundle's tar.gz restore inherited the 100× ratio + 1 GiB output defaults, breaking restore for zero-filled DB dumps + ~1-8 GiB bundles that writeBundle accepts. Fix: every archive.read.gz call from a primitive with its own size budget propagates that budget. Detector locks the symmetry.",
|
|
5644
|
+
},
|
|
5645
|
+
|
|
5646
|
+
{
|
|
5647
|
+
// v0.12.9 — Direct node:zlib gunzip calls in lib/ must compose
|
|
5648
|
+
// b.safeDecompress (1 GiB output / 100× ratio default caps) so a
|
|
5649
|
+
// hostile gzip stream can't OOM or expand-bomb the host. Mirrors
|
|
5650
|
+
// the v0.11.5 must-compose pattern. lib/archive-gz.js IS the
|
|
5651
|
+
// canonical gunzip site (it wires safeDecompress in directly);
|
|
5652
|
+
// every other lib/ call to zlib.gunzipSync / zlib.createGunzip
|
|
5653
|
+
// must either route through b.safeDecompress OR carry a marker
|
|
5654
|
+
// explaining why it's safe to bypass (e.g. the caller already
|
|
5655
|
+
// applied `maxOutputLength` AND the input is operator-controlled).
|
|
5656
|
+
id: "archive-gz-without-safedecompress",
|
|
5657
|
+
primitive: "every lib/ call to zlib.gunzipSync / zlib.createGunzip / gunzip MUST either go through lib/archive-gz.js (which composes b.safeDecompress) OR carry an `allow:archive-gz-without-safedecompress` marker with the reason the bomb gate is bypassed (typically: `maxOutputLength` is already enforced + the input is operator-trusted).",
|
|
5658
|
+
regex: /zlib\.(?:gunzipSync|createGunzip)\b/,
|
|
5659
|
+
requires: /safeDecompress|maxOutputLength|allow:archive-gz-without-safedecompress/,
|
|
5660
|
+
skipCommentLines: true,
|
|
5661
|
+
allowlist: [
|
|
5662
|
+
// archive-gz.js is the canonical gunzip site — it directly
|
|
5663
|
+
// imports safeDecompress and routes every call through it.
|
|
5664
|
+
// Listed here so the detector doesn't false-positive against
|
|
5665
|
+
// its own enforcement file.
|
|
5666
|
+
"lib/archive-gz.js",
|
|
5667
|
+
],
|
|
5668
|
+
reason: "v0.12.9 — b.archive.read.gz is the framework's gzip read primitive and composes b.safeDecompress for every gunzip. Direct lib/ zlib.gunzipSync / zlib.createGunzip calls must either route through b.archive.read.gz, compose b.safeDecompress inline, OR carry an explicit `maxOutputLength` cap with the bypass marker. The detector locks the contract so v0.13+ primitives that handle a gzip-wrapped payload can't quietly drop the bomb cap.",
|
|
5669
|
+
},
|
|
5670
|
+
|
|
5618
5671
|
{
|
|
5619
5672
|
// Codex P1 on v0.12.8 PR #159 — archive-tar-read.js's walker
|
|
5620
5673
|
// advanced `pos` by the declared padded block size without
|
package/package.json
CHANGED