partial-content 1.0.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +601 -0
  4. package/SECURITY.md +92 -0
  5. package/dist/azure.d.ts +79 -0
  6. package/dist/azure.d.ts.map +1 -0
  7. package/dist/azure.js +251 -0
  8. package/dist/azure.js.map +1 -0
  9. package/dist/content-disposition.d.ts +74 -0
  10. package/dist/content-disposition.d.ts.map +1 -0
  11. package/dist/content-disposition.js +253 -0
  12. package/dist/content-disposition.js.map +1 -0
  13. package/dist/fs.d.ts +86 -0
  14. package/dist/fs.d.ts.map +1 -0
  15. package/dist/fs.js +375 -0
  16. package/dist/fs.js.map +1 -0
  17. package/dist/gcs.d.ts +72 -0
  18. package/dist/gcs.d.ts.map +1 -0
  19. package/dist/gcs.js +202 -0
  20. package/dist/gcs.js.map +1 -0
  21. package/dist/hono.d.ts +92 -0
  22. package/dist/hono.d.ts.map +1 -0
  23. package/dist/hono.js +61 -0
  24. package/dist/hono.js.map +1 -0
  25. package/dist/http.d.ts +70 -0
  26. package/dist/http.d.ts.map +1 -0
  27. package/dist/http.js +281 -0
  28. package/dist/http.js.map +1 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +27 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/kernel.d.ts +541 -0
  34. package/dist/kernel.d.ts.map +1 -0
  35. package/dist/kernel.js +1218 -0
  36. package/dist/kernel.js.map +1 -0
  37. package/dist/memory.d.ts +55 -0
  38. package/dist/memory.d.ts.map +1 -0
  39. package/dist/memory.js +107 -0
  40. package/dist/memory.js.map +1 -0
  41. package/dist/mime.d.ts +49 -0
  42. package/dist/mime.d.ts.map +1 -0
  43. package/dist/mime.js +150 -0
  44. package/dist/mime.js.map +1 -0
  45. package/dist/node.d.ts +84 -0
  46. package/dist/node.d.ts.map +1 -0
  47. package/dist/node.js +215 -0
  48. package/dist/node.js.map +1 -0
  49. package/dist/object-store.d.ts +472 -0
  50. package/dist/object-store.d.ts.map +1 -0
  51. package/dist/object-store.js +335 -0
  52. package/dist/object-store.js.map +1 -0
  53. package/dist/r2.d.ts +94 -0
  54. package/dist/r2.d.ts.map +1 -0
  55. package/dist/r2.js +150 -0
  56. package/dist/r2.js.map +1 -0
  57. package/dist/s3.d.ts +49 -0
  58. package/dist/s3.d.ts.map +1 -0
  59. package/dist/s3.js +263 -0
  60. package/dist/s3.js.map +1 -0
  61. package/dist/web.d.ts +336 -0
  62. package/dist/web.d.ts.map +1 -0
  63. package/dist/web.js +1094 -0
  64. package/dist/web.js.map +1 -0
  65. package/docs/DESIGN.md +426 -0
  66. package/package.json +182 -0
  67. package/src/azure.ts +329 -0
  68. package/src/content-disposition.ts +300 -0
  69. package/src/fs.ts +469 -0
  70. package/src/gcs.ts +290 -0
  71. package/src/hono.ts +123 -0
  72. package/src/http.ts +351 -0
  73. package/src/index.ts +85 -0
  74. package/src/kernel.ts +1498 -0
  75. package/src/memory.ts +148 -0
  76. package/src/mime.ts +160 -0
  77. package/src/node.ts +261 -0
  78. package/src/object-store.ts +665 -0
  79. package/src/r2.ts +232 -0
  80. package/src/s3.ts +324 -0
  81. package/src/web.ts +1603 -0
package/SECURITY.md ADDED
@@ -0,0 +1,92 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ The latest published minor release receives security fixes.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Email **oss@nordvec.com** with the details. Please include:
10
+
11
+ - The affected module (`partial-content`, a store adapter, or a framework adapter)
12
+ - A minimal reproduction (a failing request/response pair is ideal)
13
+ - The impact you believe it has (header injection, cache poisoning, path traversal, byte-splicing, DoS)
14
+
15
+ You will receive an acknowledgement within 72 hours. Please do not open a
16
+ public issue for undisclosed vulnerabilities.
17
+
18
+ ## Threat model (what this library defends against)
19
+
20
+ `partial-content` sits between attacker-controlled request headers and
21
+ storage-backend metadata. Defenses under test:
22
+
23
+ - **Header injection (CWE-113) and header-write crashes:** every
24
+ metadata-derived or caller-per-request response header value (ETag,
25
+ Last-Modified, Content-Type, digest, Cache-Control, and the signed-URL
26
+ `Location`) is stripped of ALL bytes outside RFC 9110 field-value grammar
27
+ -- not just CRLF, but every control byte that Node `writeHead`, undici
28
+ `Headers`, and Workers reject by throwing, so a poisoned backend value can
29
+ neither inject nor crash the process. ETag values are additionally held to
30
+ `etagc` grammar (interior DQUOTE removed) so a backend hash can never emit
31
+ a structurally-broken entity-tag. Content-Disposition filenames AND the
32
+ disposition type are fully sanitized (controls, bidi overrides, path
33
+ components; the type is coerced to `inline`/`attachment`).
34
+ - **Path traversal / null bytes:** the fs adapter resolves keys against a
35
+ fixed root and rejects `..`, absolute paths (including Windows cross-drive
36
+ `D:\...` keys), and `\0`. On Windows it also rejects reserved device names
37
+ (`NUL`, `CON`, `COM1`, ...) and alternate-data-stream keys (`file::$DATA`),
38
+ neither of which resolves to a file under the root.
39
+ - **Cache poisoning / byte splicing:** strong-validator enforcement for
40
+ `If-Match` and `If-Range`, exact-match If-Range dates, pinned reads with
41
+ one re-validation, and truthful Content-Range derived from the backend's
42
+ actual response.
43
+ - **Range-amplification DoS (CWE-400):** multi-range requests are served as
44
+ `multipart/byteranges`, but overlapping and adjacent ranges are coalesced
45
+ first, and a request whose coalesced parts exceed `maxRanges` (default 50)
46
+ or already cover the whole representation degrades to a single full 200.
47
+ A client cannot force a large or redundant multipart response by sending
48
+ many tiny or overlapping ranges (the mitigation in Go `net/http.ServeContent`
49
+ and nginx `max_ranges`). The multipart `Content-Length` is computed exactly,
50
+ never chunked, so the response size is known and bounded up front.
51
+ - **MIME sniffing / encoding-sniffing XSS:** `X-Content-Type-Options: nosniff`
52
+ on every success + error response (the `200`/`206`/`404`/`502`/`503` bodies
53
+ and the bodyless `412`/`416` denials; `304`/`302` carry none), `charset=utf-8`
54
+ enforcement on textual types, `text/html`
55
+ deliberately absent from the built-in MIME map. Note that `svg` IS in the
56
+ map (`image/svg+xml`) and SVG is active content: served `inline` from your
57
+ own origin it can run same-origin script. For untrusted uploads, serve SVG
58
+ as `attachment` (per-MIME `disposition` hook) or add a sandboxing CSP via
59
+ `securityHeaders`.
60
+ - **Parser robustness:** the range/Content-Range/disposition parsers are
61
+ covered by seeded randomized invariant tests (never throw; bounds always
62
+ valid; output always header-safe).
63
+ - **Transparent-compression corruption:** the http adapter requests
64
+ `Accept-Encoding: identity` and refuses (fails loudly) any response that
65
+ still carries a non-identity `Content-Encoding`, so a decompressed body can
66
+ never be served under compressed-size headers.
67
+ - **Conditional-date strictness:** client-supplied `If-Modified-Since`,
68
+ `If-Unmodified-Since`, and `If-Range` dates are honored only in the three
69
+ RFC 9110 HTTP-date formats; anything else (including ISO 8601) is ignored,
70
+ as the spec mandates.
71
+
72
+ ## Server-side request forgery (httpStore)
73
+
74
+ `partial-content/http` fetches whatever your `url(key)` returns; the origin
75
+ is part of YOUR trust boundary:
76
+
77
+ - **Never interpolate untrusted input into `url()` unencoded.** A key like
78
+ `../other-bucket/secret` changes the fetched path. Encode path segments or
79
+ validate keys before they reach the store.
80
+ - **Redirects error by default** (`redirect: "error"`). A serving layer whose
81
+ `url()` may carry untrusted keys must not let a compromised or misconfigured
82
+ origin bounce the store to internal endpoints such as cloud metadata IPs, so
83
+ a 3xx is refused unless you opt in. Object-storage origins answer GET/HEAD
84
+ with a direct 200, so the default costs nothing there. Set `redirect: "follow"`
85
+ for origins that legitimately redirect, and pair it with a validating `fetch`
86
+ when keys are untrusted.
87
+ - **Network-level SSRF defenses are deliberately not built in** (DNS pinning
88
+ and IP allowlists are deployment-specific). Supply them via the `fetch`
89
+ option, which every request goes through.
90
+
91
+ Out of scope: authentication/authorization (gate access before calling the
92
+ handler) and rate limiting (deploy-level concern).
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Azure Blob Storage ObjectStore adapter for partial-content.
3
+ *
4
+ * Wraps `@azure/storage-blob` to implement the {@link ObjectStore} interface.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { BlobServiceClient } from "@azure/storage-blob";
9
+ * import { azureStore } from "partial-content/azure";
10
+ *
11
+ * const client = BlobServiceClient.fromConnectionString(connectionString);
12
+ * const store = azureStore({
13
+ * containerClient: client.getContainerClient("documents"),
14
+ * });
15
+ * ```
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+ import { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError, type ObjectStore } from "./index.js";
20
+ export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
21
+ /**
22
+ * Minimal Azure ContainerClient interface.
23
+ *
24
+ * Declared locally to avoid a hard dependency on `@azure/storage-blob`.
25
+ * Users who import `partial-content/azure` will have it installed as an
26
+ * optional peer dependency.
27
+ */
28
+ interface AzureContainerClient {
29
+ getBlobClient(blobName: string): AzureBlobClient;
30
+ }
31
+ interface AzureBlobClient {
32
+ getProperties(options?: {
33
+ abortSignal?: AbortSignal;
34
+ }): Promise<AzureBlobProperties>;
35
+ download(offset?: number, count?: number, options?: {
36
+ conditions?: {
37
+ ifMatch?: string;
38
+ };
39
+ abortSignal?: AbortSignal;
40
+ }): Promise<AzureBlobDownloadResponse>;
41
+ }
42
+ interface AzureBlobProperties {
43
+ contentLength?: number;
44
+ etag?: string;
45
+ lastModified?: Date;
46
+ }
47
+ interface AzureBlobDownloadResponse {
48
+ readableStreamBody?: NodeJS.ReadableStream & AsyncIterable<Buffer> & {
49
+ destroy?: () => void;
50
+ };
51
+ blobBody?: Promise<Blob>;
52
+ contentLength?: number;
53
+ /** Present on ranged downloads: "bytes 5-9/20". Carries the total size. */
54
+ contentRange?: string;
55
+ etag?: string;
56
+ lastModified?: Date;
57
+ }
58
+ export interface AzureStoreOptions {
59
+ /** Pre-configured Azure ContainerClient. */
60
+ containerClient: AzureContainerClient;
61
+ }
62
+ /**
63
+ * Create an {@link ObjectStore} backed by an Azure Blob Storage container.
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * import { BlobServiceClient } from "@azure/storage-blob";
68
+ * import { azureStore } from "partial-content/azure";
69
+ *
70
+ * const blobService = BlobServiceClient.fromConnectionString(conn);
71
+ * const store = azureStore({
72
+ * containerClient: blobService.getContainerClient("documents"),
73
+ * });
74
+ *
75
+ * const meta = await store.headObject("reports/q4.pdf");
76
+ * ```
77
+ */
78
+ export declare function azureStore(opts: AzureStoreOptions): ObjectStore;
79
+ //# sourceMappingURL=azure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"azure.d.ts","sourceRoot":"","sources":["../src/azure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,qBAAqB,EAOrB,KAAK,WAAW,EAKjB,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,CAAC;AAI1E;;;;;;GAMG;AACH,UAAU,oBAAoB;IAC5B,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,CAAC;CAClD;AAED,UAAU,eAAe;IACvB,aAAa,CAAC,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACrF,QAAQ,CACN,MAAM,CAAC,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE;YAAE,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,WAAW,CAAC,EAAE,WAAW,CAAA;KAAE,GACzE,OAAO,CAAC,yBAAyB,CAAC,CAAC;CACvC;AAED,UAAU,mBAAmB;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,IAAI,CAAC;CACrB;AAED,UAAU,yBAAyB;IACjC,kBAAkB,CAAC,EAAE,MAAM,CAAC,cAAc,GAAG,aAAa,CAAC,MAAM,CAAC,GAAG;QAAE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC;IAC9F,QAAQ,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,IAAI,CAAC;CACrB;AAID,MAAM,WAAW,iBAAiB;IAChC,4CAA4C;IAC5C,eAAe,EAAE,oBAAoB,CAAC;CACvC;AAID;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,WAAW,CA2G/D"}
package/dist/azure.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Azure Blob Storage ObjectStore adapter for partial-content.
3
+ *
4
+ * Wraps `@azure/storage-blob` to implement the {@link ObjectStore} interface.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { BlobServiceClient } from "@azure/storage-blob";
9
+ * import { azureStore } from "partial-content/azure";
10
+ *
11
+ * const client = BlobServiceClient.fromConnectionString(connectionString);
12
+ * const store = azureStore({
13
+ * containerClient: client.getContainerClient("documents"),
14
+ * });
15
+ * ```
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+ import { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError, classifyStoreRead, nodeStreamToWeb, guardStreamLength, resolveServedRange, parseRetryAfterSeconds, isOpenEndedRange, } from "./index.js";
20
+ // Re-export for convenience
21
+ export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
22
+ // ─── Factory ────────────────────────────────────────────────────────────────
23
+ /**
24
+ * Create an {@link ObjectStore} backed by an Azure Blob Storage container.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * import { BlobServiceClient } from "@azure/storage-blob";
29
+ * import { azureStore } from "partial-content/azure";
30
+ *
31
+ * const blobService = BlobServiceClient.fromConnectionString(conn);
32
+ * const store = azureStore({
33
+ * containerClient: blobService.getContainerClient("documents"),
34
+ * });
35
+ *
36
+ * const meta = await store.headObject("reports/q4.pdf");
37
+ * ```
38
+ */
39
+ export function azureStore(opts) {
40
+ const { containerClient } = opts;
41
+ return {
42
+ supportsRange: true,
43
+ // 206 bounds/total come from Azure's actual contentRange: the
44
+ // orchestrator may skip the validating HEAD for plain range requests.
45
+ authoritativeRange: true,
46
+ async headObject(key, opts) {
47
+ opts?.signal?.throwIfAborted();
48
+ // Forward the signal so a client disconnect during the request/header
49
+ // phase cancels the Azure call (the pre-check only covers already-aborted;
50
+ // the body phase is covered by nodeStreamToWeb's signal wiring).
51
+ const props = await classifyStoreRead(key, () => containerClient.getBlobClient(key).getProperties({ abortSignal: opts?.signal }), azureClassifiers);
52
+ if (props.contentLength == null) {
53
+ throw new Error(`Azure getProperties returned no contentLength for ${key}`);
54
+ }
55
+ return {
56
+ contentLength: props.contentLength,
57
+ etag: props.etag,
58
+ lastModified: props.lastModified?.toUTCString(),
59
+ };
60
+ },
61
+ async getObject(key, opts) {
62
+ const { range, signal, ifMatch } = opts ?? {};
63
+ signal?.throwIfAborted();
64
+ // Single round-trip: download() carries everything needed. For ranged
65
+ // reads the response's Content-Range ("bytes 5-9/20") supplies the
66
+ // total size; for full reads contentLength IS the total. One response
67
+ // also means one representation -- no metadata/body race to guard.
68
+ const offset = range ? range.start : 0;
69
+ // An open-ended fast-path range (OPEN_ENDED sentinel end) reads to the
70
+ // end of the blob: leave count undefined so download() streams the
71
+ // tail rather than requesting ~9e15 bytes.
72
+ const count = range && !isOpenEndedRange(range)
73
+ ? (range.end - range.start + 1)
74
+ : undefined;
75
+ const response = await classifyStoreRead(key, () => containerClient.getBlobClient(key).download(offset, count, {
76
+ // Pin the read to the validated representation: Azure rejects
77
+ // with 412 ConditionNotMet if the blob changed since HEAD.
78
+ ...(ifMatch ? { conditions: { ifMatch } } : {}),
79
+ // Forward the signal so the request/header phase is cancellable too.
80
+ abortSignal: signal,
81
+ }), azureClassifiers);
82
+ if (response.contentLength == null) {
83
+ destroyAzureDownload(response);
84
+ throw new Error(`Azure download returned no contentLength for ${key}`);
85
+ }
86
+ const contentLength = response.contentLength;
87
+ const resolved = response.contentRange
88
+ ? resolveServedRange(response.contentRange)
89
+ : null;
90
+ // A Content-Range Azure emits but the resolver cannot parse means the
91
+ // byte accounting is untrustworthy: release the live socket and fail
92
+ // loudly rather than guess.
93
+ if (response.contentRange && !resolved) {
94
+ destroyAzureDownload(response);
95
+ throw new Error(`Azure returned unparseable Content-Range for ${key}: ${response.contentRange}`);
96
+ }
97
+ // For a full read (no Content-Range) contentLength IS the total; a ranged
98
+ // read carries the resolver's honest total (undefined for `bytes a-b/*`).
99
+ const totalSize = resolved ? resolved.totalSize : contentLength;
100
+ // Azure SDK returns different body types depending on the environment
101
+ let webStream;
102
+ if (response.readableStreamBody) {
103
+ // Node.js environment: use shared stream conversion
104
+ const nodeStream = response.readableStreamBody;
105
+ // nodeStreamToWeb auto-detects the stream's destroy() capability.
106
+ // expectedBytes guards a graceful short-read: the Azure SDK can end
107
+ // readableStreamBody cleanly when its in-stream retries are exhausted
108
+ // mid-download, which would otherwise under-run the committed
109
+ // Content-Length undetected.
110
+ webStream = nodeStreamToWeb(nodeStream, { signal, expectedBytes: contentLength });
111
+ }
112
+ else if (response.blobBody) {
113
+ // Browser environment: guard the buffered blob's byte count against the
114
+ // committed length for parity with the Node path's expectedBytes check.
115
+ const blob = await response.blobBody;
116
+ webStream = guardStreamLength(blob.stream(), contentLength);
117
+ }
118
+ else {
119
+ throw new Error(`Azure download returned no body for ${key}`);
120
+ }
121
+ return {
122
+ body: webStream,
123
+ contentLength,
124
+ totalSize,
125
+ // The bounds Azure ACTUALLY served, from its response Content-Range.
126
+ range: resolved ? resolved.served : undefined,
127
+ etag: response.etag,
128
+ lastModified: response.lastModified?.toUTCString(),
129
+ };
130
+ },
131
+ };
132
+ }
133
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
134
+ /**
135
+ * Azure `RestError`'s numeric HTTP status, when present. This is the
136
+ * AUTHORITATIVE classification signal: when a numeric `statusCode` exists, the
137
+ * classifiers key off it exactly and never fall through to message heuristics,
138
+ * so a `503 ServerBusy` whose message text happens to contain "412" (a request
139
+ * id or timestamp digit run) can never be misread as a 412. A present-but-
140
+ * non-numeric status returns `undefined` so the code/message fallback still runs.
141
+ */
142
+ function azureStatusCode(err) {
143
+ if (typeof err === "object" && err !== null && "statusCode" in err) {
144
+ const s = err.statusCode;
145
+ if (typeof s === "number")
146
+ return s;
147
+ }
148
+ return undefined;
149
+ }
150
+ /**
151
+ * Azure `RestError`'s stable error code (`BlobNotFound`, `ConditionNotMet`,
152
+ * `ServerBusy`, ...). Matched exactly, so unlike a message substring it cannot
153
+ * collide with arbitrary text in the error message.
154
+ */
155
+ function azureErrorCode(err) {
156
+ if (typeof err === "object" && err !== null && "code" in err) {
157
+ const c = err.code;
158
+ if (typeof c === "string")
159
+ return c;
160
+ }
161
+ return undefined;
162
+ }
163
+ function isAzureNotFound(err) {
164
+ const status = azureStatusCode(err);
165
+ if (status !== undefined)
166
+ return status === 404;
167
+ if (azureErrorCode(err) === "BlobNotFound")
168
+ return true;
169
+ return err instanceof Error && err.name === "RestError" && err.message.includes("BlobNotFound");
170
+ }
171
+ /**
172
+ * Release Azure's live download socket before bailing out on a malformed
173
+ * response. `download()` resolves once headers arrive with an OPEN
174
+ * `readableStreamBody`; throwing before that stream is handed to
175
+ * `nodeStreamToWeb` would leak the socket (and its connection-pool slot)
176
+ * until GC. The browser `blobBody` path buffers in memory and needs no
177
+ * teardown.
178
+ */
179
+ function destroyAzureDownload(response) {
180
+ response.readableStreamBody?.destroy?.();
181
+ }
182
+ /**
183
+ * Check if an Azure error is a failed ifMatch condition (blob changed).
184
+ * The SDK surfaces this as a RestError with statusCode 412 (ConditionNotMet).
185
+ * A numeric status is authoritative; the `code`/message fallback only runs for
186
+ * status-less shapes, so a throttle carrying "412" in its message text is never
187
+ * misreported as a precondition failure.
188
+ */
189
+ function isAzurePreconditionFailed(err) {
190
+ const status = azureStatusCode(err);
191
+ if (status !== undefined)
192
+ return status === 412;
193
+ if (azureErrorCode(err) === "ConditionNotMet")
194
+ return true;
195
+ return err instanceof Error && err.name === "RestError" && err.message.includes("ConditionNotMet");
196
+ }
197
+ /**
198
+ * The back-off Azure advises on a throttle, parsed from the `Retry-After`
199
+ * response header (whole seconds; the HTTP-date form is ignored). Surfaced so
200
+ * the 503 echoes it and shared caches wait the advised interval.
201
+ */
202
+ function azureRetryAfterSeconds(err) {
203
+ const headers = err
204
+ .response?.headers;
205
+ // The classifier runs on arbitrary (S3-compatible/proxy) error shapes, so
206
+ // harden the structural access: optional-call guards only null/undefined, but
207
+ // a truthy non-function `.get` would throw, and a throwing `.get` propagates
208
+ // -- either way the thrown error would replace the throttle and turn a 429/503
209
+ // into a 502. Gate on the type and swallow a throwing getter.
210
+ const get = headers?.get;
211
+ if (typeof get !== "function")
212
+ return undefined;
213
+ let raw;
214
+ try {
215
+ raw = get.call(headers, "retry-after");
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ return parseRetryAfterSeconds(raw);
221
+ }
222
+ /**
223
+ * Whether an Azure error is a transient throttle/overload the client should
224
+ * retry (mapped to a 503, not a 502). Azure's retryable transients are
225
+ * `ServerBusy` (HTTP 503) and `OperationTimedOut` (HTTP **500**), so they are
226
+ * matched by ERROR CODE first: keying only off the numeric status would demote
227
+ * `OperationTimedOut` to a non-retryable 502 (500 is neither 429 nor 503). A
228
+ * numeric 429/503 still catches throttles that arrive without a code, and the
229
+ * message fallback (gated on a status-less shape) preserves the classifier's
230
+ * exclusivity -- a 503 whose message merely contains "412" is classified by
231
+ * code/status, never by substring. Returns the advised `Retry-After` when set.
232
+ */
233
+ function isAzureThrottled(err) {
234
+ const code = azureErrorCode(err);
235
+ const status = azureStatusCode(err);
236
+ const throttled = code === "ServerBusy" || code === "OperationTimedOut"
237
+ || status === 429 || status === 503
238
+ || (status === undefined && err instanceof Error && err.name === "RestError"
239
+ && (err.message.includes("ServerBusy") || err.message.includes("OperationTimedOut")));
240
+ if (!throttled)
241
+ return false;
242
+ const retryAfterSeconds = azureRetryAfterSeconds(err);
243
+ return retryAfterSeconds !== undefined ? { retryAfterSeconds } : true;
244
+ }
245
+ /** The ordered error-classification set shared by getProperties and download. */
246
+ const azureClassifiers = {
247
+ notFound: isAzureNotFound,
248
+ changed: isAzurePreconditionFailed,
249
+ throttled: isAzureThrottled,
250
+ };
251
+ //# sourceMappingURL=azure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"azure.js","sourceRoot":"","sources":["../src/azure.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Content-Disposition header builder (RFC 6266 + RFC 8187).
3
+ *
4
+ * Builds Content-Disposition headers for HTTP responses that serve files.
5
+ * Handles both inline (in-app preview) and attachment (download) dispositions
6
+ * with proper RFC compliance for non-ASCII filenames.
7
+ *
8
+ * Security: Prevents CRLF header injection, path traversal, bidi override
9
+ * spoofing, and control character injection in filenames from untrusted
10
+ * sources (integration APIs, user uploads).
11
+ *
12
+ * Emits dual filename parameters for cross-browser compatibility:
13
+ * - `filename="ascii-safe.pdf"` for legacy browsers
14
+ * - `filename*=UTF-8''%C3%85rlig.pdf` for modern browsers (RFC 8187)
15
+ */
16
+ export interface ContentDispositionOptions {
17
+ /**
18
+ * Disposition type.
19
+ * - `"attachment"`: Force download (safe default for untrusted content)
20
+ * - `"inline"`: Render in browser (only for previewable types: PDF, image, video, audio)
21
+ * @default "attachment"
22
+ */
23
+ type?: "attachment" | "inline";
24
+ /**
25
+ * Fallback filename when input is null, undefined, or empty after sanitization.
26
+ * @default "document"
27
+ */
28
+ fallback?: string;
29
+ }
30
+ /**
31
+ * Build a Content-Disposition header value with RFC compliance and
32
+ * security sanitization.
33
+ *
34
+ * Accepts untrusted filenames from integration APIs, user uploads, and
35
+ * database records. Sanitizes for:
36
+ * - CRLF injection (strips \r, \n, and all control characters)
37
+ * - Path traversal (strips ../ and ..\\ sequences)
38
+ * - Basename extraction (strips directory paths)
39
+ * - Bidi override stripping (prevents RLO filename spoofing)
40
+ * - Double-quote and backslash escaping (RFC 2616 quoted-pair)
41
+ *
42
+ * Emits dual filename parameters for cross-browser compatibility:
43
+ * - `filename="ascii-safe.pdf"` for legacy browsers (IE, old Safari)
44
+ * - `filename*=UTF-8''%C3%85rlig.pdf` for modern browsers (RFC 8187)
45
+ *
46
+ * Token optimization: Simple ASCII filenames like `plans.pdf` are emitted
47
+ * unquoted (`filename=plans.pdf`).
48
+ *
49
+ * @param filename - Raw filename from untrusted source (upload, API, DB)
50
+ * @param options - Disposition type and fallback configuration
51
+ * @returns Complete Content-Disposition header value string
52
+ *
53
+ * @example
54
+ * // Simple ASCII attachment
55
+ * buildContentDisposition("report.pdf")
56
+ * // => 'attachment; filename=report.pdf'
57
+ *
58
+ * @example
59
+ * // Non-ASCII filename (Danish)
60
+ * buildContentDisposition("Årlig_Rapport.pdf")
61
+ * // => 'attachment; filename="?rlig_Rapport.pdf"; filename*=UTF-8\'\'%C3%85rlig_Rapport.pdf'
62
+ *
63
+ * @example
64
+ * // Inline disposition for preview
65
+ * buildContentDisposition("slides.pdf", { type: "inline" })
66
+ * // => 'inline; filename=slides.pdf'
67
+ *
68
+ * @example
69
+ * // Null input with custom fallback
70
+ * buildContentDisposition(null, { fallback: "export.csv" })
71
+ * // => 'attachment; filename=export.csv'
72
+ */
73
+ export declare function buildContentDisposition(filename: string | null | undefined, options?: ContentDispositionOptions): string;
74
+ //# sourceMappingURL=content-disposition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-disposition.d.ts","sourceRoot":"","sources":["../src/content-disposition.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,MAAM,WAAW,yBAAyB;IACtC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,YAAY,GAAG,QAAQ,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AA2DD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,uBAAuB,CACnC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,OAAO,CAAC,EAAE,yBAAyB,GACpC,MAAM,CAkBR"}