@blamejs/core 0.14.17 → 0.14.18
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 +2 -0
- package/README.md +2 -2
- package/lib/agent-orchestrator.js +10 -4
- package/lib/ai-prompt.js +1 -1
- package/lib/app-shutdown.js +28 -0
- package/lib/archive-read.js +215 -16
- package/lib/breach-deadline.js +166 -1
- package/lib/cloud-events.js +3 -1
- package/lib/codepoint-class.js +21 -0
- package/lib/db-schema.js +120 -3
- package/lib/db.js +10 -3
- package/lib/error-page.js +88 -8
- package/lib/external-db.js +164 -13
- package/lib/guard-email.js +36 -3
- package/lib/mail-send-deliver.js +15 -5
- package/lib/mail-sieve.js +2 -1
- package/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/middleware/attach-user.js +45 -10
- package/lib/middleware/body-parser.js +70 -14
- package/lib/middleware/csp-report.js +30 -2
- package/lib/middleware/deny-response.js +22 -7
- package/lib/middleware/openapi-serve.js +56 -4
- package/lib/middleware/scim-server.js +7 -4
- package/lib/queue-local.js +148 -38
- package/lib/queue.js +41 -11
- package/lib/render.js +21 -3
- package/lib/safe-buffer.js +55 -0
- package/lib/static.js +46 -17
- package/lib/uri-template.js +3 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.14.x
|
|
10
10
|
|
|
11
|
+
- v0.14.18 (2026-06-01) — **Advertised endpoints that silently failed now work, plus opt-in response-shaping, transport hardening, and ZIP64 read.** This release closes a set of advertised-but-broken surfaces and adds the extensibility hooks operators kept having to work around. Three endpoints silently failed: the CSP-report endpoint returned 413 to every POST (it never parsed a report), the SCIM server broke on any streamed request body, and both came from misusing the bounded-buffer collector as if it consumed a stream — so this release adds the missing b.safeBuffer.collectStream(stream, opts) primitive and routes both through it. The agent orchestrator's graceful-drain phase never registered (wrong method name), the OpenAPI/AsyncAPI doc endpoints ignored a documented single-origin CORS allowlist, and the EU AI Act Article 50 HTML banner skipped Buffer response bodies. Deny-path refusals gained an RFC 9457 problem-type derived from the problem code (a 429 read about:blank before) and now treat a consumer hook that commits headers as terminal. On top of the fixes, several middlewares gain operator hooks (custom JSON/HTML error formatters, problem+json, refusal callbacks), several entry points gain escape-hatch opts (a configurable Authorization scheme, legacy filename charsets, a submission relay port, an exit-after-phases shutdown), the local job queue can point at an operator-supplied database/table/schema, breach-notification deadlines gain a running escalation clock, the archive reader now reads ZIP64, and external databases gain an opt-in TLS-required posture plus OpenTelemetry db.* trace attributes. Every behavior-changing addition is strictly opt-in — an operator who sets no new option sees no change. **Added:** *`b.safeBuffer.collectStream(stream, opts)` — bounded stream-to-Buffer reader* — Reads a Node Readable (a request body, an upstream response) fully into one Buffer with the byte cap enforced at every chunk — the streaming sibling of `boundedChunkCollector`, which is a push-collector, not a stream consumer. Resolves the concatenated Buffer on end; rejects (and destroys the stream) the moment a chunk would overflow `maxBytes`. · *Operator response-shaping hooks* — `b.errorPage` gains `jsonFormatter(info, req)` and `renderHtml(info, req)` overrides plus a `problemDetails: true` RFC 9457 mode (each falls back to the built-in envelope on throw, so the original error is never masked); `b.render.json` gains an `opts.replacer` passthrough (BigInt/Date); `b.middleware.cspReport` gains `onReject(req, res, { status, reason })` for the otherwise-empty-bodied 405/413/400 refusals; `b.static` gains `onError` firing on every refusal path, mirroring `onServe`. · *Escape-hatch opts for non-default deployments* — `b.appShutdown.create({ exitAfterPhases })` lets a manual `shutdown()` exit after its phases (not only a signal-driven shutdown); `b.middleware.attachUser({ bearerScheme, tokenExtractor })` reads `Token`/`DPoP`/gateway Authorization schemes (RFC 6750 / RFC 9449); `b.middleware.bodyParser` multipart `filenameCharsets` opts iso-8859-1 `filename*` decoding in (RFC 5987); `b.mail.send.deliver.create({ port })` routes through a 587/465 submission relay (RFC 6409 / RFC 8314). · *Local job queue: bring-your-own database, table, and schema* — `b.queue.init` local-backend config accepts `db` (an operator-supplied handle), `table`, and `schema`. Table and schema names are validated and quoted as SQL identifiers (refused at init time when unsafe); the sealed `payload`/`lastError` columns stay sealed regardless of the physical table. Defaults are unchanged (`_blamejs_jobs` on the framework/cluster database). · *`b.breach.deadline.createClock` — running breach-notification escalation clock* — A detection-to-notification clock that escalates each affected US state's statutory breach-notification deadline (an approaching warning, a passed alert) and accepts per-state filing acknowledgements, complementing the existing static deadline lookup. It composes the incident-report deadline clock, so there is a single timer to start and stop. · *`b.archive.read.zip` reads ZIP64* — Archives with more than 65535 entries or with sizes/offsets past 4 GiB now decode transparently (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.5.3). Previously such archives were refused outright. The bomb, Zip-Slip, PATH_MAX, and entry-type defenses all continue to apply to the resolved 64-bit values. · *External-database opt-in TLS posture and OpenTelemetry `db.*` traces* — `b.externalDb` backends accept `requireTls: true` (default off), which refuses a backend at config time unless its declared transport is TLS (`tls` / a truthy `ssl` / `sslmode` `require|verify-ca|verify-full`) — for cardholder data or ePHI (PCI-DSS v4.0 Req 4 / HIPAA §164.312(e)). Query/transaction/read traces now also carry OpenTelemetry database semantic-convention attributes (`db.system`, `db.operation`, `db.statement` (sanitized), `db.name`). · *Opt-in schema-drift detection* — `b.db.init({ onDrift })` (and the underlying `reconcile({ onDrift })`) opts into config-vs-live column-drift detection: `"warn"` reports columns present in the live database but absent from the declared schema; `"refuse"` makes the framework refuse to boot on drift (a strict-schema posture — ISO 27001:2022 A.8.9 / SOC 2 CC8.1). The default (`"ignore"`) is unchanged, and drift is never resolved destructively. **Changed:** *`b.guardEmail` documents its actual Unicode scope* — Domain-side IDN/Punycode and mixed-script confusable detection are supported (and unchanged); the local-part is ASCII atext only (RFC 5321/5322). The documentation previously implied RFC 6531 SMTPUTF8/EAI mailbox names were accepted — they are rejected by default, re-openable behind a future `allowUnicodeLocalPart` opt-in, to keep homograph/confusable exposure bounded to the domain side. No validation behavior changed. · *Body-parse 4xx responses send `Connection: close`* — Every body-parse rejection (malformed JSON, poisoned key, oversized payload, bad content-length) now closes the connection, matching the chunked-decode path, to deny an upstream proxy reusing a socket whose request stream the parser abandoned mid-body (RFC 9112 §9.6). · *Archive-reader default entry-count cap raised to 1,048,576* — To read large (ZIP64) archives, the default `bombPolicy.maxEntries` rises from 65535 to 2^20. An operator who set `maxEntries` explicitly is unaffected; the cap can still be lowered. **Fixed:** *`b.middleware.cspReport` returned 413 to every POST; `b.middleware.scimServer` broke on streamed bodies* — Both read the request body by calling `b.safeBuffer.boundedChunkCollector(req, …)` — but that primitive takes a single options object and returns a push-collector, it does not consume a stream. The call passed the request as the options argument (so `maxBytes` was undefined and it threw), turning every CSP-report POST into a 413 and failing every streamed SCIM request. Both now read the body through the new `b.safeBuffer.collectStream`. A valid CSP report now reaches the parse/audit/`onReport` path and returns 204. · *Agent orchestrator graceful-drain phase never registered* — `b.middleware.agentOrchestrator` registered its drain phase against a method the shutdown handle does not expose, so the phase silently never ran — connections were not drained on a graceful shutdown. It now registers through the real `addPhase` so the documented drain actually fires. · *OpenAPI / AsyncAPI doc endpoints ignored `accessControl: { allowOrigin }`* — The documented single-origin CORS form was read by neither serve middleware (only `accessControl: "public"` was handled), so an operator passing `{ allowOrigin: "https://docs.example.com" }` got no `Access-Control-Allow-Origin` header. The object form now emits a validated origin (passed through `b.safeUrl`, rejecting CR/LF injection, userinfo, and non-http(s) schemes) with `Vary: Origin`. · *EU AI Act Article 50 HTML banner skipped Buffer response bodies* — In HTML mode the disclosure banner was injected only when the final response chunk was a string; a `res.end(Buffer.from(html))` body (the common path through templating/render) silently received no banner. Buffer bodies are now decoded under the response charset, injected, and re-encoded (with a one-time warning and untouched bytes for charsets without a transcoder). · *Deny-path 429 (and any refusal) emitted `type: about:blank`* — The shared deny-path writer documented deriving the RFC 9457 problem-type URI from the refusal's problem code, but only ever read an explicit problem type — so a rate-limit 429 (which supplies a code, not a type) reported `about:blank`. The type is now derived from the problem code (`<base>/<code>`) when no explicit type is given, so a 429 reads `…/rate-limit-exceeded`. · *A deny-path `onDeny` hook that commits headers is treated as terminal* — The deny-path writer's terminal check looked only at `res.writableEnded`; a response-wrapping consumer whose `onDeny` sent headers without ending the response fell through into a second `writeHead` and hit "headers already sent". The writer now also treats `res.headersSent` as terminal, so wrapping responders compose cleanly. · *`b.mail.send.deliver` DANE TLSA lookup ignored the configured port* — The DANE TLSA query name (`_<port>._tcp.<host>`) was hardcoded to port 25; a delivery configured for a 587/465 relay still queried port 25. The lookup now uses the configured port. **Security:** *Closed a request-smuggling socket-reuse window on body-parse rejections* — Pairing every body-parse 4xx with `Connection: close` stops an upstream proxy from reusing a connection whose request body the parser abandoned mid-stream — a request/response desync vector that previously applied only to the chunked-decode rejections. · *Opt-in TLS-required posture for regulated external databases* — Set `requireTls: true` on an external-database backend carrying cardholder data or ePHI so a non-TLS (or fallback-permitting `sslmode`) connection is refused at boot rather than silently transmitting in the clear (PCI-DSS v4.0 Req 4 / HIPAA §164.312(e)). **Detectors:** *Regression guards for every fixed bug class* — The internal pattern gate gains detectors so each fixed mistake cannot return: a bounded-collector call used as a stream consumer, a deny-path writer that does not guard `headersSent`, an `appShutdown` phase registered through a non-existent method, a body-parse error writer missing `Connection: close`, a raw `_blamejs_jobs` table reference bypassing the configured-table quoting, and a breach clock that re-rolls its own timer instead of composing the incident-report clock. **Migration:** *No action required; new behavior is opt-in* — Every addition is additive or opt-in: `requireTls`, schema-drift `onDrift`, the response-shaping hooks, the new entry-point opts, and the breach clock all default to prior behavior when unset. Two defaults shift in a backward-compatible direction: body-parse 4xx now send `Connection: close`, and the archive reader's default entry-count ceiling rises to 2^20 (lower it via `bombPolicy.maxEntries` if you relied on the old cap). `b.db.reconcile` now returns a drift report object instead of undefined; the framework's own call ignores the return, so existing callers are unaffected.
|
|
12
|
+
|
|
11
13
|
- v0.14.17 (2026-05-31) — **In-session API-encrypt errors stay confidential, and the encrypted client can read them.** When b.middleware.apiEncrypt is active, a normal response is sealed in the { _ct } envelope, but a terminal error that bypassed res.json (an error page, a validation refusal, an RFC 9457 problem+json document, a deny-middleware body) shipped in plaintext on the otherwise-encrypted channel, and the b.httpClient.encrypted client threw on the response shape because it tried to decrypt every reply. This release makes errors symmetric: those four sinks now seal their body in the same envelope a success uses whenever a session is active (via a new req.apiEncryptEncode the middleware installs after a successful decrypt), and the client gains a responseMode: "passthrough" that reads a non-2xx — decrypting an in-session error and returning a plaintext one verbatim — instead of throwing. Errors raised before a session exists (a Bearer 401, a handshake rejection, a replay refusal) deliberately stay plaintext and human-readable. Two adjacent streaming fixes ride along: a streamed (responseMode "stream") non-2xx now keeps a bounded prefix of the error body on the thrown error instead of draining it, and an SSE channel closed by a transport fault audits as a failure with a reason rather than looking like a clean operator close. **Added:** *`b.httpClient.encrypted` gains `responseMode: "passthrough"`* — The encrypted client defaults to `responseMode: "reject"` (a non-2xx rejects, exactly as before). Set `responseMode: "passthrough"` at create time, or per request, to resolve a non-2xx instead: the result is `{ statusCode, headers, body, ok }`, where `body` is the decrypted object when the reply carries an encrypted `_ct` envelope (an in-session error) and the parsed plaintext otherwise (a pre-session error such as a Bearer 401 or a proxy 502). The additive `ok` boolean (status 200–299) is present on every result. This lets a client read an error's status and detail instead of failing on the response shape. · *`req.apiEncryptEncode` — the in-session error encoder* — `b.middleware.apiEncrypt` installs `req.apiEncryptEncode(obj)` after it successfully decrypts a request body. It returns the same `{ _ct }` (plus `_sid` / `_ctr` in per-session mode) envelope a normal response uses, so a terminal handler that writes its body directly (bypassing the wrapped `res.json`) can keep an error confidential on the encrypted channel. It is present only after a valid decrypt, so every pre-session path has no encoder and its errors stay plaintext. **Changed:** *In-session terminal errors are sealed in the encrypted envelope* — The error page (JSON branch), the router's schema-validation refusal, `b.problemDetails.respond` (now accepting an optional `req`), and the access-deny middleware now seal their error body via `req.apiEncryptEncode` when a session is active — so an in-session error no longer ships as plaintext while the surrounding traffic is encrypted. A sealed problem+json is labelled `application/json` (the envelope), not `application/problem+json`. With no active session (or if encoding fails) the body stays plaintext and readable, unchanged. · *Streamed non-2xx responses preserve a bounded error-body prefix* — `b.httpClient.request({ responseMode: "stream" })` (and `b.httpClient.downloadStream`, which composes it) previously drained and discarded the body of a `>= 400` response. The rejection is unchanged, but the thrown error now carries a bounded (16 KiB) prefix of the body on `err.body`, so a caller can read the problem+json / error detail. The prefix is collected through `b.safeBuffer.boundedChunkCollector`, so a hostile oversized error body can't accumulate unbounded. · *SSE transport-fault close audits as a failure* — An SSE channel closed by a transport fault (a stream `error`, a failed heartbeat write) now emits its `sse.channel_closed` audit event with `outcome: "failure"` and a `reason`, instead of the `"success"` outcome an intentional `channel.close()` records — so an operator's evidence stream can tell a dropped connection from a clean shutdown. **Security:** *Error bodies no longer leak in plaintext on an encrypted channel* — Before this release, a request that established an apiEncrypt session but then failed (validation error, access denial, server error rendered through the error page or problem-details) returned its error body in plaintext, even though every successful response on the same channel was encrypted — exposing error detail (paths, field names, refusal reasons) to a network observer. In-session errors are now encrypted symmetrically with successes; pre-session errors, which a client must be able to read to recover, remain plaintext by design. **Migration:** *Opt into reading non-2xx encrypted errors* — No change is required for existing callers — `b.httpClient.encrypted` still defaults to `responseMode: "reject"` (throw on non-2xx). To read an error's status and decrypted detail, create the client with `responseMode: "passthrough"` (or pass it per request) and branch on the returned `ok` / `statusCode`. The new `ok` field is additive and does not affect existing `{ statusCode, headers, body }` consumers.
|
|
12
14
|
|
|
13
15
|
- v0.14.16 (2026-05-31) — **Connection entry-point ports are validated at config time.** Six connection entry points previously read opts.port with a bare `|| <default>` fallback, silently coercing a string, negative, NaN, or out-of-range port instead of catching the operator's typo. A new b.validateOpts.optionalPort enforces the RFC 6335 §6 wire-valid range and is wired into b.mail.smtpTransport, b.ntpCheck.querySingle, b.networkDns.useDnsOverTls, b.networkNts (KE handshake / query / facade), b.redisClient.create, and createApp().listen — each now throws at construction with a clear message naming the bad value. The app.listen / createApp bind site opts into allowZero so port 0 (the legitimate ephemeral-bind sentinel) still works; the five outbound-connect sites require [1,65535]. **Added:** *`b.validateOpts.optionalPort`* — A config-time port validator: `optionalPort(value, label, errorClass, code, opts?)` returns an omitted (`undefined` / `null`) port unchanged, and otherwise requires an integer in the RFC 6335 §6 wire-valid range [1,65535] — rejecting a string, negative, NaN, Infinity, fractional, or out-of-range value. Pass `{ allowZero: true }` for a listen-bind site where port 0 is the OS ephemeral-bind sentinel. The thrown message reports the offending shape (so `Infinity` / `"443"` stay visible), and routes a caller-supplied typed framework error (or a plain Error when none is given), matching the existing `optionalPositiveFinite` family. **Changed:** *Connection entry points reject a malformed port at construction* — `b.mail.smtpTransport`, `b.ntpCheck.querySingle`, `b.networkDns.useDnsOverTls`, `b.networkNts.performKeHandshake` / `query` / `querySingle`, `b.redisClient.create`, and `createApp().listen` (plus the `createApp` constructor's default port) now validate `opts.port` and throw synchronously on a non-integer / out-of-range value rather than coercing it through `||` to a default. This is a behavior change for a caller that was passing a non-canonical port (e.g. the string `"587"` or a NaN) and relying on the silent fallback — pass an integer in [1,65535] instead (or `0` for an ephemeral `createApp().listen` bind). `b.ntpCheck` gains a typed `NtpCheckError` for this (it had no error class before). **Detectors:** *Connection entry points must compose the port validator* — A new check flags a lib connection entry point that reads `opts.port` / `opts.kePort` / `opts.ntpPort` with a `|| <default>` fallback without composing `b.validateOpts.optionalPort` (or the equivalent `numericBounds.isPositiveFiniteInt` + 65535 cap), so an unvalidated port read can't slip back in. **Migration:** *Pass an integer port to connection primitives* — If you were passing a non-integer or out-of-range `opts.port` to a mail / NTP / NTS / DNS-over-TLS / Redis transport or to `createApp().listen` and relying on the silent `|| default` fallback, that now throws at construction. Pass an integer in [1,65535]; for an ephemeral `createApp().listen` bind, pass `0` (still accepted).
|
package/README.md
CHANGED
|
@@ -64,9 +64,9 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
64
64
|
- **Chainable query builder** — atomic `.increment(col, delta)`, closure-form `.whereGroup` / top-level `.orWhere` OR composition, `.search(fields, term)` LIKE-OR with safe `%`/`_` ESCAPE handling, `.paginate(opts)` returning `{ items, total, page, totalPages }`; a column-membership gate (`db.init({ columnGate })`, default reject) fails a query closed when it names a column the table never declared, and `whereRaw` refuses an embedded string literal so values bind through placeholders
|
|
65
65
|
- **Mongo-style document-store facade** — `b.db.collection(name, opts?)` with `$set` / `$inc` / `$unset` / `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`; schemaless-document opts via `overflow: "<col>"` (folds unknown fields into a JSON-text column; rewrites `WHERE` on virtual fields to `JSON_EXTRACT`), `jsonColumns: [...]` (auto-stringify on write + parse via `b.safeJson` on read), `sealedFields: { email: "emailHash" }` (co-locates a `b.cryptoField` sealed-column / derived-hash declaration so plaintext lookups auto-rewrite to hash-column lookups)
|
|
66
66
|
- **DB lifecycle** — in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs `frameworkTables: false` / `auditSigning: false` and path overrides `encryptedDbPath` / `encryptedDbName` / `dbKeyPath`
|
|
67
|
-
- **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`)
|
|
67
|
+
- **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); an opt-in `requireTls` transport posture refuses a non-TLS backend at boot, and query / transaction / read traces carry OpenTelemetry `db.*` attributes
|
|
68
68
|
- **Object store** — S3 / R2 / B2 / GCS / Azure with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS); S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads (`b.storage`, `b.objectStore`)
|
|
69
|
-
- **Queues + cache** — durable queue with priority + cron + flows on local SQLite, shared Redis, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`)
|
|
69
|
+
- **Queues + cache** — durable queue with priority + cron + flows on local SQLite, shared Redis, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 (`b.queue`, `b.jobs`) — the local backend can target an operator-supplied database / table / schema; cluster-shared cache (`b.cache`)
|
|
70
70
|
### Identity & access
|
|
71
71
|
|
|
72
72
|
- **Passwords** — Argon2id + policy primitive (`b.auth.password`); NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles; HaveIBeenPwned k-anonymity breach check; length / context / dictionary / complexity rules; rotation + history
|
|
@@ -146,7 +146,7 @@ var _saltedFnvBasisCache = null;
|
|
|
146
146
|
* permissions: b.permissions instance, // optional; orchestrator skips RBAC if absent
|
|
147
147
|
* backend: { get, set, delete, list }, // optional; in-memory default
|
|
148
148
|
* cluster: b.cluster module, // optional; defaults to b.cluster
|
|
149
|
-
* appShutdown: b.appShutdown.create() // optional; orchestrator
|
|
149
|
+
* appShutdown: b.appShutdown.create() // optional; orchestrator adds an "agent.orchestrator.drain" phase via addPhase() if supplied
|
|
150
150
|
*
|
|
151
151
|
* @example
|
|
152
152
|
* var orch = b.agent.orchestrator.create({});
|
|
@@ -201,9 +201,15 @@ function create(opts) {
|
|
|
201
201
|
};
|
|
202
202
|
|
|
203
203
|
// Wire the drain phase into b.appShutdown if the operator supplied one.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
// The orchestrator handle exposes addPhase({ name, run, timeoutMs? })
|
|
205
|
+
// (b.appShutdown.create), so the drain registers as a named phase that
|
|
206
|
+
// the orchestrator runs in array order during graceful shutdown.
|
|
207
|
+
if (opts.appShutdown && typeof opts.appShutdown.addPhase === "function") {
|
|
208
|
+
opts.appShutdown.addPhase({
|
|
209
|
+
name: "agent.orchestrator.drain",
|
|
210
|
+
run: function () {
|
|
211
|
+
return _drain(ctx, { timeoutMs: DEFAULT_DRAIN_TIMEOUT_MS });
|
|
212
|
+
},
|
|
207
213
|
});
|
|
208
214
|
}
|
|
209
215
|
|
package/lib/ai-prompt.js
CHANGED
|
@@ -65,7 +65,7 @@ var ROLE_CONTROL_TOKENS = [
|
|
|
65
65
|
|
|
66
66
|
// Escape a string for safe inclusion in a RegExp character/literal body.
|
|
67
67
|
function _reEscape(s) {
|
|
68
|
-
return
|
|
68
|
+
return codepointClass.escapeRegExp(s);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Build the per-render boundary tokens for a role. The nonce binds the
|
package/lib/app-shutdown.js
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
|
|
44
44
|
var safeAsync = require("./safe-async");
|
|
45
45
|
var numericBounds = require("./numeric-bounds");
|
|
46
|
+
var validateOpts = require("./validate-opts");
|
|
46
47
|
var tracing = null;
|
|
47
48
|
try { tracing = require("./tracing"); } catch (_e) { /* tracing optional */ }
|
|
48
49
|
var { defineClass } = require("./framework-error");
|
|
@@ -79,6 +80,7 @@ var FORCE_EXIT_MARGIN_MS = C.TIME.seconds(5);
|
|
|
79
80
|
* phases: array, // [{ name, run: async fn, timeoutMs? }]
|
|
80
81
|
* installSignalHandlers: boolean, // wire SIGTERM/SIGINT (default false)
|
|
81
82
|
* signals: array, // signal names (default ["SIGTERM","SIGINT"])
|
|
83
|
+
* exitAfterPhases: boolean, // when true, a non-signal shutdown() also process.exit()s once phases complete (default false — only the signal path exits)
|
|
82
84
|
* onUncaught: function, // hook for uncaughtException / unhandledRejection
|
|
83
85
|
* installUncaught: boolean, // wire uncaughtException handler unconditionally
|
|
84
86
|
*
|
|
@@ -102,6 +104,17 @@ function create(opts) {
|
|
|
102
104
|
numericBounds.requirePositiveFiniteIntIfPresent(opts.forceExitMarginMs,
|
|
103
105
|
"app-shutdown.create: opts.forceExitMarginMs", AppShutdownError, "app-shutdown/bad-force-exit-margin-ms");
|
|
104
106
|
var forceExitMarginMs = opts.forceExitMarginMs !== undefined ? opts.forceExitMarginMs : FORCE_EXIT_MARGIN_MS;
|
|
107
|
+
// By default the process exits only via the signal-handler path (a
|
|
108
|
+
// received SIGTERM/SIGINT sets process.exitCode and lets the event loop
|
|
109
|
+
// drain). A manual orchestrator.shutdown() — invoked from an admin
|
|
110
|
+
// endpoint, a test harness, or a non-signal lifecycle hook — resolves
|
|
111
|
+
// its Promise but does NOT terminate the process, so exit-coupled
|
|
112
|
+
// teardown (the registered process-exit handlers that run the final DB
|
|
113
|
+
// re-encrypt) never fires. Set exitAfterPhases:true so a non-signal
|
|
114
|
+
// shutdown() also exits once every phase has completed.
|
|
115
|
+
validateOpts.optionalBoolean(opts.exitAfterPhases,
|
|
116
|
+
"app-shutdown.create: opts.exitAfterPhases", AppShutdownError, "app-shutdown/bad-exit-after-phases");
|
|
117
|
+
var exitAfterPhases = opts.exitAfterPhases === true;
|
|
105
118
|
var phases = Array.isArray(opts.phases) ? opts.phases.slice() : [];
|
|
106
119
|
var installSignalHandlers = !!opts.installSignalHandlers;
|
|
107
120
|
for (var i = 0; i < phases.length; i++) {
|
|
@@ -224,6 +237,21 @@ function create(opts) {
|
|
|
224
237
|
log("shutdown complete in " + totalMs + "ms (" +
|
|
225
238
|
phaseResults.filter(function (p) { return p.ok; }).length + "/" +
|
|
226
239
|
phaseResults.length + " phases ok)");
|
|
240
|
+
if (exitAfterPhases) {
|
|
241
|
+
// Caller opted to couple a non-signal shutdown() to process exit.
|
|
242
|
+
// Scheduled on the next tick so the awaiting caller's resolution
|
|
243
|
+
// handler runs first; process.exit() then runs the registered
|
|
244
|
+
// exit handlers (the final DB re-encrypt). Preserve an exit code
|
|
245
|
+
// an operator hook may already have set; otherwise derive from
|
|
246
|
+
// phase success.
|
|
247
|
+
var exitCode = (process.exitCode !== undefined && process.exitCode !== 0)
|
|
248
|
+
? process.exitCode : (allOk ? 0 : 1);
|
|
249
|
+
setImmediate(function () {
|
|
250
|
+
// allow:process-exit — operator opted into exitAfterPhases,
|
|
251
|
+
// delegating process lifecycle to the orchestrator
|
|
252
|
+
process.exit(exitCode);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
227
255
|
return { ok: allOk, phases: phaseResults, totalMs: totalMs, draining: true };
|
|
228
256
|
})();
|
|
229
257
|
return shutdownPromise;
|
package/lib/archive-read.js
CHANGED
|
@@ -64,7 +64,26 @@ var SIG_EOCD = 0x06054b50; // APPNOTE §4.3.16 EOCD magic d
|
|
|
64
64
|
var SIG_EOCD64 = 0x06064b50; // APPNOTE §4.3.14 ZIP64 EOCD magic dword (wire-format-fixed)
|
|
65
65
|
var SIG_EOCD64_LOCATOR = 0x07064b50; // APPNOTE §4.3.15 ZIP64 EOCD locator magic dword (wire-format-fixed)
|
|
66
66
|
var SIG_DATA_DESCRIPTOR = 0x08074b50; // APPNOTE §4.3.9 data-descriptor magic dword (wire-format-fixed)
|
|
67
|
-
|
|
67
|
+
|
|
68
|
+
// ZIP64 sentinels — a classic record field set to its all-ones value
|
|
69
|
+
// means "the real value lives in the ZIP64 record / extra field"
|
|
70
|
+
// (APPNOTE §4.4 + §4.5.3). 16-bit fields use 0xFFFF, 32-bit fields use
|
|
71
|
+
// 0xFFFFFFFF.
|
|
72
|
+
var ZIP64_U16_SENTINEL = 0xffff; // APPNOTE §4.4.21/§4.4.22 16-bit overflow marker
|
|
73
|
+
var ZIP64_U32_SENTINEL = 0xffffffff; // APPNOTE §4.4.8/§4.4.16/§4.4.24 32-bit overflow marker
|
|
74
|
+
|
|
75
|
+
// ZIP64 EOCD locator (§4.3.15) is fixed at 20 bytes:
|
|
76
|
+
// sig(4) diskWithEocd64(4) eocd64Offset(8) totalDisks(4)
|
|
77
|
+
var EOCD64_LOCATOR_BYTES = C.BYTES.bytes(20);
|
|
78
|
+
// ZIP64 EOCD record (§4.3.14) fixed prefix is 56 bytes through the
|
|
79
|
+
// cdOffset field; a variable-length "extensible data sector" may follow,
|
|
80
|
+
// sized by the 8-byte "size of ZIP64 EOCD record" field.
|
|
81
|
+
var EOCD64_FIXED_BYTES = C.BYTES.bytes(56);
|
|
82
|
+
// ZIP64 extended-information extra field (§4.5.3) header is 4 bytes
|
|
83
|
+
// (id(2) + dataSize(2)); each present value is an 8-byte little-endian
|
|
84
|
+
// dword except diskStart which is a 4-byte dword.
|
|
85
|
+
var ZIP64_EXTRA_HEADER_ID = 0x0001; // APPNOTE §4.5.3 ZIP64 extra-field tag
|
|
86
|
+
var EXTRA_FIELD_HEADER_BYTES= C.BYTES.bytes(4);
|
|
68
87
|
|
|
69
88
|
var METHOD_STORE_ID = 0;
|
|
70
89
|
var METHOD_DEFLATE_ID = 8;
|
|
@@ -94,7 +113,7 @@ var MSDOS_EPOCH_YEAR = 1980;
|
|
|
94
113
|
// ---- Default zip-bomb / entry caps ---------------------------------------
|
|
95
114
|
|
|
96
115
|
var DEFAULT_BOMB_POLICY = Object.freeze({
|
|
97
|
-
maxEntries:
|
|
116
|
+
maxEntries: 1048576, // 2^20 entry-count cap (ZIP64 lifts the classic 16-bit 65535 limit; operators raise via bombPolicy)
|
|
98
117
|
maxEntryDecompressedBytes: C.BYTES.mib(128), // per-entry cap
|
|
99
118
|
maxTotalDecompressedBytes: C.BYTES.gib(4), // archive-wide cap
|
|
100
119
|
maxExpansionRatio: 100, // compressed → decompressed ratio cap
|
|
@@ -120,6 +139,22 @@ function _msdosToDate(dosDate, dosTime) {
|
|
|
120
139
|
return new Date(year, month, day, hour, minute, second);
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
// Read an 8-byte little-endian ZIP64 dword as a JS number. ZIP64 sizes
|
|
143
|
+
// and offsets are 64-bit (APPNOTE §4.3.14/§4.5.3); JS numbers address up
|
|
144
|
+
// to 2^53-1 exactly, which is far above any value the reader can act on
|
|
145
|
+
// (a single decompressed entry is bomb-capped well under 2^53 bytes).
|
|
146
|
+
// Anything above MAX_SAFE_INTEGER is refused as unaddressable rather
|
|
147
|
+
// than silently truncated.
|
|
148
|
+
function _readZip64U64(buf, off, fieldLabel) {
|
|
149
|
+
var big = buf.readBigUInt64LE(off);
|
|
150
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
151
|
+
throw new ArchiveReadError("archive-read/zip64-value-too-large",
|
|
152
|
+
"ZIP64 " + fieldLabel + "=" + big.toString() +
|
|
153
|
+
" exceeds the addressable Number.MAX_SAFE_INTEGER ceiling");
|
|
154
|
+
}
|
|
155
|
+
return Number(big);
|
|
156
|
+
}
|
|
157
|
+
|
|
123
158
|
function _isUnixSymlinkAttrs(externalAttrs) {
|
|
124
159
|
// S_IFLNK = 0o120000 (octal). External file attributes' high 16 bits
|
|
125
160
|
// carry the unix mode when "version made by" host == 3 (UNIX).
|
|
@@ -189,7 +224,7 @@ async function _locateEocd(adapter) {
|
|
|
189
224
|
// past EOF and we keep scanning.
|
|
190
225
|
var commentLen = tail.readUInt16LE(i + 20);
|
|
191
226
|
if (i + EOCD_MIN_BYTES + commentLen === tail.length) {
|
|
192
|
-
|
|
227
|
+
var eocd = {
|
|
193
228
|
eocdOffset: scanOffset + i,
|
|
194
229
|
diskNumber: tail.readUInt16LE(i + 4),
|
|
195
230
|
cdDiskNumber: tail.readUInt16LE(i + 6),
|
|
@@ -198,7 +233,18 @@ async function _locateEocd(adapter) {
|
|
|
198
233
|
cdSize: tail.readUInt32LE(i + 12), // APPNOTE §4.3.16 EOCD field offset
|
|
199
234
|
cdOffset: tail.readUInt32LE(i + 16), // APPNOTE §4.3.16 EOCD field offset
|
|
200
235
|
commentLength: commentLen,
|
|
236
|
+
isZip64: false,
|
|
201
237
|
};
|
|
238
|
+
// When any classic field carries the ZIP64 sentinel, the true
|
|
239
|
+
// values live in the ZIP64 EOCD record located via the ZIP64
|
|
240
|
+
// locator that immediately precedes this classic EOCD
|
|
241
|
+
// (APPNOTE §4.3.15). Resolve them in place.
|
|
242
|
+
if (eocd.totalEntries === ZIP64_U16_SENTINEL ||
|
|
243
|
+
eocd.cdSize === ZIP64_U32_SENTINEL ||
|
|
244
|
+
eocd.cdOffset === ZIP64_U32_SENTINEL) {
|
|
245
|
+
await _resolveZip64Eocd(adapter, eocd, size);
|
|
246
|
+
}
|
|
247
|
+
return eocd;
|
|
202
248
|
}
|
|
203
249
|
}
|
|
204
250
|
}
|
|
@@ -206,6 +252,127 @@ async function _locateEocd(adapter) {
|
|
|
206
252
|
"End-of-central-directory record not found in trailing " + scanLen + " bytes");
|
|
207
253
|
}
|
|
208
254
|
|
|
255
|
+
// ---- ZIP64 EOCD resolution ------------------------------------------------
|
|
256
|
+
// Reads the ZIP64 EOCD locator (§4.3.15) that precedes the classic EOCD,
|
|
257
|
+
// follows it to the ZIP64 EOCD record (§4.3.14), and overlays the 64-bit
|
|
258
|
+
// totalEntries / centralDirSize / centralDirOffset onto the eocd object.
|
|
259
|
+
// Mutates `eocd` in place (sets isZip64 + the 64-bit fields).
|
|
260
|
+
async function _resolveZip64Eocd(adapter, eocd, archiveSize) {
|
|
261
|
+
var locatorOffset = eocd.eocdOffset - EOCD64_LOCATOR_BYTES;
|
|
262
|
+
if (locatorOffset < 0) {
|
|
263
|
+
throw new ArchiveReadError("archive-read/zip64-locator-missing",
|
|
264
|
+
"classic EOCD carries a ZIP64 sentinel but no room for the ZIP64 locator before it");
|
|
265
|
+
}
|
|
266
|
+
var locator = await adapter.range(locatorOffset, EOCD64_LOCATOR_BYTES);
|
|
267
|
+
if (locator.readUInt32LE(0) !== SIG_EOCD64_LOCATOR) {
|
|
268
|
+
throw new ArchiveReadError("archive-read/zip64-locator-missing",
|
|
269
|
+
"expected ZIP64 EOCD locator signature before classic EOCD, got 0x" +
|
|
270
|
+
locator.readUInt32LE(0).toString(16)); // radix=16 for hex parse, not byte count
|
|
271
|
+
}
|
|
272
|
+
// diskWithEocd64 (offset 4) + totalDisks (offset 16) — single-disk only.
|
|
273
|
+
if (locator.readUInt32LE(4) !== 0 || locator.readUInt32LE(16) > 1) {
|
|
274
|
+
throw new ArchiveReadError("archive-read/multi-disk",
|
|
275
|
+
"multi-disk ZIP64 archives are not supported (totalDisks=" +
|
|
276
|
+
locator.readUInt32LE(16) + ")");
|
|
277
|
+
}
|
|
278
|
+
var eocd64Offset = _readZip64U64(locator, 8, "EOCD64 record offset"); // §4.3.15 locator field
|
|
279
|
+
if (eocd64Offset + EOCD64_FIXED_BYTES > archiveSize) {
|
|
280
|
+
throw new ArchiveReadError("archive-read/zip64-eocd-out-of-range",
|
|
281
|
+
"ZIP64 EOCD record offset=" + eocd64Offset + " overflows archive size=" + archiveSize);
|
|
282
|
+
}
|
|
283
|
+
var rec = await adapter.range(eocd64Offset, EOCD64_FIXED_BYTES);
|
|
284
|
+
if (rec.readUInt32LE(0) !== SIG_EOCD64) {
|
|
285
|
+
throw new ArchiveReadError("archive-read/zip64-eocd-bad-signature",
|
|
286
|
+
"ZIP64 EOCD record has bad signature 0x" + rec.readUInt32LE(0).toString(16)); // radix=16 for hex parse, not byte count
|
|
287
|
+
}
|
|
288
|
+
// diskNumber (offset 16) + cdDiskNumber (offset 20) — single-disk only.
|
|
289
|
+
eocd.diskNumber = rec.readUInt32LE(16); // §4.3.14 ZIP64 EOCD field
|
|
290
|
+
eocd.cdDiskNumber = rec.readUInt32LE(20); // §4.3.14 ZIP64 EOCD field
|
|
291
|
+
eocd.totalEntries = _readZip64U64(rec, 32, "totalEntries"); // §4.3.14 ZIP64 EOCD field
|
|
292
|
+
eocd.cdSize = _readZip64U64(rec, 40, "centralDirSize"); // §4.3.14 ZIP64 EOCD field
|
|
293
|
+
eocd.cdOffset = _readZip64U64(rec, 48, "centralDirOffset"); // §4.3.14 ZIP64 EOCD field
|
|
294
|
+
eocd.isZip64 = true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---- ZIP64 extended-information extra field (§4.5.3) ----------------------
|
|
298
|
+
// The ZIP64 extra field (header id 0x0001) supplies the true 64-bit
|
|
299
|
+
// values for ONLY the fields that carried the 0xFFFFFFFF / 0xFFFF
|
|
300
|
+
// sentinel in the classic CFH, and they appear in a FIXED ORDER:
|
|
301
|
+
// uncompressedSize, compressedSize, localHeaderOffset, diskStart.
|
|
302
|
+
// The data-block length tells us how many of those are present; a
|
|
303
|
+
// field is present iff its classic value was the sentinel AND the
|
|
304
|
+
// data block is long enough to carry it. Returns the resolved values,
|
|
305
|
+
// leaving any non-sentinel field at its classic value.
|
|
306
|
+
function _applyZip64Extra(classic, extraFields) {
|
|
307
|
+
var resolved = {
|
|
308
|
+
uncompressedSize: classic.uncompressedSize,
|
|
309
|
+
compressedSize: classic.compressedSize,
|
|
310
|
+
lfhOffset: classic.lfhOffset,
|
|
311
|
+
};
|
|
312
|
+
var needUncompressed = classic.uncompressedSize === ZIP64_U32_SENTINEL;
|
|
313
|
+
var needCompressed = classic.compressedSize === ZIP64_U32_SENTINEL;
|
|
314
|
+
var needLfhOffset = classic.lfhOffset === ZIP64_U32_SENTINEL;
|
|
315
|
+
var needDiskStart = classic.diskStart === ZIP64_U16_SENTINEL;
|
|
316
|
+
if (!needUncompressed && !needCompressed && !needLfhOffset && !needDiskStart) {
|
|
317
|
+
return resolved; // no ZIP64 fields needed — classic values stand
|
|
318
|
+
}
|
|
319
|
+
// Walk the extra-field chain (id(2) + size(2) + data) looking for 0x0001.
|
|
320
|
+
var p = 0;
|
|
321
|
+
while (p + EXTRA_FIELD_HEADER_BYTES <= extraFields.length) {
|
|
322
|
+
var id = extraFields.readUInt16LE(p); // §4.5.1 extra-field header id
|
|
323
|
+
var dataSize = extraFields.readUInt16LE(p + 2); // §4.5.1 extra-field data size
|
|
324
|
+
var dataStart = p + EXTRA_FIELD_HEADER_BYTES;
|
|
325
|
+
if (dataStart + dataSize > extraFields.length) break; // truncated extra block — stop
|
|
326
|
+
if (id === ZIP64_EXTRA_HEADER_ID) {
|
|
327
|
+
var q = dataStart;
|
|
328
|
+
var end = dataStart + dataSize;
|
|
329
|
+
// Order-dependent per §4.5.3 — only the fields whose classic value
|
|
330
|
+
// was the sentinel are present, in this exact sequence.
|
|
331
|
+
if (needUncompressed) {
|
|
332
|
+
if (q + 8 > end) {
|
|
333
|
+
throw new ArchiveReadError("archive-read/zip64-extra-truncated",
|
|
334
|
+
"ZIP64 extra field too short for uncompressedSize");
|
|
335
|
+
}
|
|
336
|
+
resolved.uncompressedSize = _readZip64U64(extraFields, q, "extra uncompressedSize");
|
|
337
|
+
q += 8;
|
|
338
|
+
}
|
|
339
|
+
if (needCompressed) {
|
|
340
|
+
if (q + 8 > end) {
|
|
341
|
+
throw new ArchiveReadError("archive-read/zip64-extra-truncated",
|
|
342
|
+
"ZIP64 extra field too short for compressedSize");
|
|
343
|
+
}
|
|
344
|
+
resolved.compressedSize = _readZip64U64(extraFields, q, "extra compressedSize");
|
|
345
|
+
q += 8;
|
|
346
|
+
}
|
|
347
|
+
if (needLfhOffset) {
|
|
348
|
+
if (q + 8 > end) {
|
|
349
|
+
throw new ArchiveReadError("archive-read/zip64-extra-truncated",
|
|
350
|
+
"ZIP64 extra field too short for localHeaderOffset");
|
|
351
|
+
}
|
|
352
|
+
resolved.lfhOffset = _readZip64U64(extraFields, q, "extra localHeaderOffset");
|
|
353
|
+
q += 8;
|
|
354
|
+
}
|
|
355
|
+
if (needDiskStart) {
|
|
356
|
+
if (q + 4 > end) {
|
|
357
|
+
throw new ArchiveReadError("archive-read/zip64-extra-truncated",
|
|
358
|
+
"ZIP64 extra field too short for diskStart");
|
|
359
|
+
}
|
|
360
|
+
// diskStart must be 0 (single-disk). Read but enforce single-disk.
|
|
361
|
+
if (extraFields.readUInt32LE(q) !== 0) {
|
|
362
|
+
throw new ArchiveReadError("archive-read/multi-disk",
|
|
363
|
+
"ZIP64 entry references a non-zero disk start (multi-disk unsupported)");
|
|
364
|
+
}
|
|
365
|
+
q += 4;
|
|
366
|
+
}
|
|
367
|
+
return resolved;
|
|
368
|
+
}
|
|
369
|
+
p = dataStart + dataSize;
|
|
370
|
+
}
|
|
371
|
+
// A sentinel was present but no 0x0001 block resolved it — malformed.
|
|
372
|
+
throw new ArchiveReadError("archive-read/zip64-extra-missing",
|
|
373
|
+
"central directory entry carries a ZIP64 sentinel size but no ZIP64 extended-information extra field (id 0x0001)");
|
|
374
|
+
}
|
|
375
|
+
|
|
209
376
|
// ---- Random-access central-directory walk ---------------------------------
|
|
210
377
|
|
|
211
378
|
async function _readCentralDirectory(adapter, eocd) {
|
|
@@ -213,12 +380,15 @@ async function _readCentralDirectory(adapter, eocd) {
|
|
|
213
380
|
throw new ArchiveReadError("archive-read/multi-disk",
|
|
214
381
|
"multi-disk archives are not supported (diskNumber=" + eocd.diskNumber + ")");
|
|
215
382
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
383
|
+
// ZIP64 sentinels in the classic EOCD are resolved by `_locateEocd`
|
|
384
|
+
// (via the ZIP64 EOCD locator + record). If any sentinel still stands
|
|
385
|
+
// here the classic record claimed ZIP64 but the ZIP64 trailer was
|
|
386
|
+
// absent — refuse rather than reading a sentinel as a literal count.
|
|
387
|
+
if (eocd.totalEntries === ZIP64_U16_SENTINEL ||
|
|
388
|
+
eocd.cdSize === ZIP64_U32_SENTINEL ||
|
|
389
|
+
eocd.cdOffset === ZIP64_U32_SENTINEL) {
|
|
390
|
+
throw new ArchiveReadError("archive-read/zip64-eocd-unresolved",
|
|
391
|
+
"classic EOCD carries a ZIP64 sentinel but the ZIP64 EOCD record did not resolve it");
|
|
222
392
|
}
|
|
223
393
|
if (eocd.cdSize === 0 || eocd.totalEntries === 0) {
|
|
224
394
|
return [];
|
|
@@ -246,6 +416,7 @@ async function _readCentralDirectory(adapter, eocd) {
|
|
|
246
416
|
var nameLen = cdBytes.readUInt16LE(pos + 28); // APPNOTE §4.3.12 CFH field offset
|
|
247
417
|
var extraLen = cdBytes.readUInt16LE(pos + 30); // APPNOTE §4.3.12 CFH field offset
|
|
248
418
|
var commentLen = cdBytes.readUInt16LE(pos + 32); // APPNOTE §4.3.12 CFH field offset
|
|
419
|
+
var diskStart = cdBytes.readUInt16LE(pos + 34); // APPNOTE §4.3.12 CFH field offset (disk number start)
|
|
249
420
|
var externalAttrs = cdBytes.readUInt32LE(pos + 38); // APPNOTE §4.3.12 CFH field offset
|
|
250
421
|
var lfhOffset = cdBytes.readUInt32LE(pos + 42); // APPNOTE §4.3.12 CFH field offset
|
|
251
422
|
var nameStart = pos + CFH_FIXED_BYTES;
|
|
@@ -255,27 +426,31 @@ async function _readCentralDirectory(adapter, eocd) {
|
|
|
255
426
|
throw new ArchiveReadError("archive-read/cd-truncated",
|
|
256
427
|
"central directory entry " + n + " variable-length fields overflow CD");
|
|
257
428
|
}
|
|
258
|
-
if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || lfhOffset === 0xffffffff) {
|
|
259
|
-
throw new ArchiveReadError("archive-read/zip64-unsupported",
|
|
260
|
-
"central directory entry " + n + " carries ZIP64 sentinel sizes (unsupported — use tar for >4 GiB / >65535 entries)");
|
|
261
|
-
}
|
|
262
429
|
// ZIP names are CP437 or UTF-8 (per FLAG_UTF8_NAME bit). Decode
|
|
263
430
|
// as UTF-8 unconditionally — a concern if operators in
|
|
264
431
|
// the wild rely on CP437; v0.12.7 ships UTF-8 only and operators
|
|
265
432
|
// with legacy CP437-only producers reach for an external decoder.
|
|
266
433
|
var name = cdBytes.slice(nameStart, nameStart + nameLen).toString("utf8");
|
|
267
434
|
var extraFields = cdBytes.slice(extraStart, extraStart + extraLen);
|
|
435
|
+
// Resolve ZIP64 sentinel sizes/offsets from the §4.5.3 extra field
|
|
436
|
+
// (id 0x0001) — order-dependent, present only for sentinel values.
|
|
437
|
+
var resolved = _applyZip64Extra({
|
|
438
|
+
uncompressedSize: uncompressedSize,
|
|
439
|
+
compressedSize: compressedSize,
|
|
440
|
+
lfhOffset: lfhOffset,
|
|
441
|
+
diskStart: diskStart,
|
|
442
|
+
}, extraFields);
|
|
268
443
|
entries.push({
|
|
269
444
|
name: name,
|
|
270
445
|
method: method,
|
|
271
446
|
generalFlags: generalFlags,
|
|
272
447
|
crc: crc32,
|
|
273
|
-
compressedSize: compressedSize,
|
|
274
|
-
uncompressedSize: uncompressedSize,
|
|
448
|
+
compressedSize: resolved.compressedSize,
|
|
449
|
+
uncompressedSize: resolved.uncompressedSize,
|
|
275
450
|
mtime: _msdosToDate(dosDate, dosTime),
|
|
276
451
|
externalAttrs: externalAttrs,
|
|
277
452
|
extraFields: extraFields,
|
|
278
|
-
lfhOffset: lfhOffset,
|
|
453
|
+
lfhOffset: resolved.lfhOffset,
|
|
279
454
|
isEncrypted: (generalFlags & FLAG_ENCRYPTED) !== 0,
|
|
280
455
|
hasDataDescriptor:(generalFlags & FLAG_DATA_DESCRIPTOR) !== 0,
|
|
281
456
|
_entryType: null, // memoized on first access
|
|
@@ -306,6 +481,23 @@ async function _verifyLfhMatchesCd(adapter, entry) {
|
|
|
306
481
|
"entry " + JSON.stringify(entry.name) + " method skew: LFH=" +
|
|
307
482
|
lfhMethod + " CD=" + entry.method);
|
|
308
483
|
}
|
|
484
|
+
// ZIP64: when the LFH's 32-bit csize/usize carry the sentinel, the
|
|
485
|
+
// true 64-bit values live in the LFH's ZIP64 extra field (§4.5.3).
|
|
486
|
+
// In the LFH variant both sizes are present (uncompressed then
|
|
487
|
+
// compressed) when either overflowed. Resolve before the skew check
|
|
488
|
+
// so the comparison runs against the CD's resolved 64-bit values.
|
|
489
|
+
if (!hasDataDescriptor &&
|
|
490
|
+
(lfhUsize === ZIP64_U32_SENTINEL || lfhCsize === ZIP64_U32_SENTINEL)) {
|
|
491
|
+
var lfhExtra = await adapter.range(entry.lfhOffset + LFH_FIXED_BYTES + lfhNameLen, lfhExtraLen);
|
|
492
|
+
var lfhResolved = _applyZip64Extra({
|
|
493
|
+
uncompressedSize: lfhUsize,
|
|
494
|
+
compressedSize: lfhCsize,
|
|
495
|
+
lfhOffset: 0, // LFH ZIP64 extra never carries an offset; never a sentinel here
|
|
496
|
+
diskStart: 0, // ditto — no disk-start in the LFH extra
|
|
497
|
+
}, lfhExtra);
|
|
498
|
+
lfhUsize = lfhResolved.uncompressedSize;
|
|
499
|
+
lfhCsize = lfhResolved.compressedSize;
|
|
500
|
+
}
|
|
309
501
|
// When the data-descriptor flag is set, the LFH's crc/csize/usize
|
|
310
502
|
// are all zero per APPNOTE §4.4.4 bit 3 — skip the comparison.
|
|
311
503
|
if (!hasDataDescriptor) {
|
|
@@ -462,6 +654,13 @@ function _emitAudit(opts, action, outcome, metadata) {
|
|
|
462
654
|
* `extract(opts)` (full decompression with bomb caps + path-traversal +
|
|
463
655
|
* entry-type policy).
|
|
464
656
|
*
|
|
657
|
+
* ZIP64 (APPNOTE 6.3.10 §4.3.14 EOCD64 / §4.3.15 locator / §4.5.3
|
|
658
|
+
* extended-information extra field) is read transparently: archives
|
|
659
|
+
* whose entry count exceeds 65535 or whose sizes/offsets exceed 4 GiB
|
|
660
|
+
* carry the ZIP64 trailer, which is resolved into the same entry shape
|
|
661
|
+
* a classic archive yields. The classic-format default entry cap is
|
|
662
|
+
* lifted to 2^20; operators raise it through `bombPolicy.maxEntries`.
|
|
663
|
+
*
|
|
465
664
|
* Defends:
|
|
466
665
|
* - Zip Slip / path traversal (CVE-2025-3445 / 11569 / 23084 / 27210
|
|
467
666
|
* / 11001 / 11002 / 26960 + 2024 jszip / mholt / Python tarfile)
|