@ultimat3/storage 23.0.0 → 25.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.
@@ -0,0 +1,173 @@
1
+ // Single responsibility: S3 Object Lock as this package's vocabulary — a per-object retention (a
2
+ // mode and an instant) and a legal hold, the screen `put()` holds them to, the one "is it locked
3
+ // now?" rule, and the refusals the disks that EMULATE a lock (local, memory) answer with.
4
+
5
+ import {
6
+ assert,
7
+ type Clock,
8
+ describeValue,
9
+ renderFixLiteral,
10
+ renderFixShellArg,
11
+ } from '@ultimat3/core';
12
+ import { StorageError } from './errors';
13
+
14
+ /** `GOVERNANCE` yields to `s3:BypassGovernanceRetention`; `COMPLIANCE` yields to nobody, root included. */
15
+ export const RETENTION_MODES = ['GOVERNANCE', 'COMPLIANCE'] as const;
16
+ export type RetentionMode = (typeof RETENTION_MODES)[number];
17
+
18
+ export interface ObjectRetention {
19
+ readonly mode: RetentionMode;
20
+ /** The instant the lock lapses. UTC-backed; sent as an ISO-8601 instant. */
21
+ readonly retainUntil: Date;
22
+ }
23
+
24
+ /** What locks one object, as the disk reports it. Neither field set means nothing locks it. */
25
+ export interface ObjectLock {
26
+ readonly retention?: ObjectRetention | undefined;
27
+ readonly legalHold: boolean;
28
+ }
29
+
30
+ /** The lock half of `PutOptions`, screened here so all three disks refuse one way. */
31
+ export interface ObjectLockOptions {
32
+ readonly retention?: ObjectRetention | undefined;
33
+ readonly legalHold?: boolean | undefined;
34
+ }
35
+
36
+ export const isRetentionMode = (value: unknown): value is RetentionMode =>
37
+ (RETENTION_MODES as readonly unknown[]).includes(value);
38
+
39
+ /**
40
+ * A retention whose mode is not one of the two, whose instant is not one, or whose instant has
41
+ * already passed — refused before a byte moves. S3 itself answers a past date with a 400; the
42
+ * local disk would store a lock that never held, which reads to a caller as "this is retained".
43
+ */
44
+ export function assertObjectLockOptions(
45
+ driver: string,
46
+ options: ObjectLockOptions | undefined,
47
+ clock: Clock,
48
+ ): void {
49
+ const retention = options?.retention;
50
+ if (retention !== undefined) {
51
+ assert(
52
+ isRetentionMode(retention.mode),
53
+ `put() on the ${driver} disk was given a retention mode that is neither GOVERNANCE nor COMPLIANCE (${describeValue(retention.mode)})`,
54
+ "put(key, body, { retention: { mode: 'GOVERNANCE', retainUntil } })",
55
+ );
56
+ const until = retention.retainUntil;
57
+ assert(
58
+ until instanceof Date && Number.isFinite(until.getTime()),
59
+ `put() on the ${driver} disk was given a retainUntil that is not a valid Date (${describeValue(until)})`,
60
+ 'put(key, body, { retention: { mode, retainUntil: new Date(clock.now().getTime() + days * 86_400_000) } })',
61
+ );
62
+ assert(
63
+ until.getTime() > clock.now().getTime(),
64
+ `put() on the ${driver} disk was given a retainUntil of ${until.toISOString()}, which is not in the future: a lock that has already lapsed retains nothing`,
65
+ 'pass a retainUntil after now — new Date(clock.now().getTime() + days * 86_400_000)',
66
+ );
67
+ }
68
+ assert(
69
+ options?.legalHold === undefined || typeof options.legalHold === 'boolean',
70
+ `put() on the ${driver} disk was given a legalHold that is not a boolean (${describeValue(options?.legalHold)})`,
71
+ 'put(key, body, { legalHold: true }) — or omit legalHold',
72
+ );
73
+ }
74
+
75
+ /** The lock `put()` was asked to set, in the shape a disk stores — `undefined` when none was. */
76
+ export function requestedLock(options: ObjectLockOptions | undefined): ObjectLock | undefined {
77
+ if (options?.retention === undefined && options?.legalHold !== true) return undefined;
78
+ return {
79
+ ...(options.retention === undefined
80
+ ? {}
81
+ : {
82
+ retention: {
83
+ mode: options.retention.mode,
84
+ retainUntil: new Date(options.retention.retainUntil.getTime()),
85
+ },
86
+ }),
87
+ legalHold: options.legalHold === true,
88
+ };
89
+ }
90
+
91
+ /** Locked NOW: a legal hold, or a retention whose instant is still ahead. One rule, every disk. */
92
+ export function isLocked(lock: ObjectLock | undefined, now: Date): boolean {
93
+ if (lock === undefined) return false;
94
+ if (lock.legalHold) return true;
95
+ return lock.retention !== undefined && lock.retention.retainUntil.getTime() > now.getTime();
96
+ }
97
+
98
+ function describeLock(lock: ObjectLock): string {
99
+ const parts: string[] = [];
100
+ if (lock.retention !== undefined) {
101
+ parts.push(
102
+ `${lock.retention.mode} retention until ${lock.retention.retainUntil.toISOString()}`,
103
+ );
104
+ }
105
+ if (lock.legalHold) parts.push('a legal hold');
106
+ return parts.join(' and ');
107
+ }
108
+
109
+ export type LockedAction = 'delete' | 'overwrite' | 'copy onto';
110
+
111
+ /**
112
+ * The fix opens with the read that shows the lock, then where the bytes can go instead. An
113
+ * overwrite or a copy has another key to write to; a delete only has "later".
114
+ */
115
+ function lockFix(
116
+ action: LockedAction,
117
+ disk: string,
118
+ key: string,
119
+ lock: ObjectLock,
120
+ sidecar: string | undefined,
121
+ ): string {
122
+ const show = `disk('${disk}').retentionOf(${renderFixLiteral(key, '"<key>"')}) # shows the lock`;
123
+ const elsewhere =
124
+ action === 'delete'
125
+ ? ''
126
+ : `; or write under a new key: disk('${disk}').put(${renderFixLiteral(`${key}.v2`, '"<a new key>"')}, body)`;
127
+ if (lock.legalHold) {
128
+ return sidecar === undefined
129
+ ? `${show}; a hold on the memory disk lasts as long as the process${elsewhere}`
130
+ : `${show}; release the hold by setting "legalHold": false in ${renderFixShellArg(sidecar, "'<the sidecar file>'")}${elsewhere}`;
131
+ }
132
+ const until = lock.retention?.retainUntil.toISOString() ?? 'the retention lapses';
133
+ return `${show}; retry after ${until}${elsewhere}`;
134
+ }
135
+
136
+ /**
137
+ * `X_STORAGE_OBJECT_LOCKED` — a delete, overwrite or copy onto a locked key, refused by a disk that
138
+ * EMULATES Object Lock (local, memory). Those disks keep ONE version per key, so refusing is the
139
+ * only way to keep the locked bytes; S3 keeps the locked VERSION and lets the call through (a
140
+ * delete marker, a newer version) — `driver-parity.test.ts` pins that divergence.
141
+ */
142
+ export class ObjectLockedError extends StorageError {
143
+ readonly lock: ObjectLock;
144
+
145
+ constructor(input: {
146
+ readonly disk: string;
147
+ readonly key: string;
148
+ readonly action: LockedAction;
149
+ readonly lock: ObjectLock;
150
+ /** The local disk's sidecar path, where a legal hold is released; absent on memory. */
151
+ readonly sidecar?: string | undefined;
152
+ }) {
153
+ const { disk, key, action, lock, sidecar } = input;
154
+ super({
155
+ code: 'X_STORAGE_OBJECT_LOCKED',
156
+ cause: `disk "${disk}" refused to ${action} "${key}": it is under ${describeLock(lock)}; the stored bytes are unchanged`,
157
+ fix: lockFix(action, disk, key, lock, sidecar),
158
+ meta: {
159
+ disk,
160
+ key,
161
+ action,
162
+ legalHold: lock.legalHold,
163
+ ...(lock.retention === undefined
164
+ ? {}
165
+ : {
166
+ mode: lock.retention.mode,
167
+ retainUntil: lock.retention.retainUntil.toISOString(),
168
+ }),
169
+ },
170
+ });
171
+ this.lock = lock;
172
+ }
173
+ }
package/src/path.ts CHANGED
@@ -42,6 +42,9 @@ function hasControlByte(key: string): boolean {
42
42
  }
43
43
 
44
44
  function unsafeReason(key: string): string | undefined {
45
+ // The type says string; a key off an untyped caller is whatever it is, and `.length` on
46
+ // `undefined` was a bare `TypeError` out of every driver method at once.
47
+ if (typeof key !== 'string') return 'is not a string';
45
48
  if (key.length === 0) return 'is empty';
46
49
  const bytes = utf8Bytes(key);
47
50
  if (bytes > MAX_KEY_LENGTH) {
@@ -110,9 +113,21 @@ export function scopedKey(orgId: string, ...parts: readonly string[]): string {
110
113
  return joinKey(ORG_PREFIX, assertOrgId(orgId), ...parts);
111
114
  }
112
115
 
113
- /** Guard for read paths: a key handed in by a client must still belong to the actor's org. */
116
+ /**
117
+ * Guard for read paths: a key handed in by a client must still belong to the actor's org.
118
+ *
119
+ * A PREDICATE, so it answers and never throws. An org id that cannot be one — empty, or carrying
120
+ * a separator — has no keys inside it, and that is `false`: it used to raise
121
+ * `X_STORAGE_PATH_UNSAFE` through `orgPrefix`, blaming the KEY for an actor with no org, and every
122
+ * caller that knew wrote `orgId === '' ||` in front of it while the ones that did not threw.
123
+ */
114
124
  export function isWithinOrg(key: string, orgId: string): boolean {
115
- return isSafeKey(key) && key.startsWith(orgPrefix(orgId));
125
+ // `typeof`, because the caller is a guard on a request: an actor whose org claim is `undefined`
126
+ // at runtime must read as "inside no org", not as a `TypeError` out of `.includes`.
127
+ if (typeof key !== 'string' || typeof orgId !== 'string') return false;
128
+ const prefix = `${ORG_PREFIX}/${orgId}`;
129
+ if (orgId.includes('/') || !isSafeKey(prefix)) return false;
130
+ return isSafeKey(key) && key.startsWith(`${prefix}/`);
116
131
  }
117
132
 
118
133
  /**
package/src/signed-url.ts CHANGED
@@ -8,11 +8,8 @@ import { type Clock, finiteCount, systemClock, timingSafeEqual } from '@ultimat3
8
8
  import type { SignedUrlMethod } from './driver';
9
9
  import { assertSafeKey, isSafeKey } from './path';
10
10
 
11
- /** Re-exported so every existing `from '@ultimat3/storage'` import keeps working — the
12
- * implementation now lives in `@ultimat3/core`, shared with `@ultimat3/auth`. */
13
- export { timingSafeEqual };
14
-
15
- export const SIGNED_URL_VERSION = 'v1';
11
+ /** `v2` since the canonical string carries the base path: a `v1` signature names no disk. */
12
+ export const SIGNED_URL_VERSION = 'v2';
16
13
  export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
17
14
  /** The dev server mounts the download/upload route here; S3 disks never use it. */
18
15
  export const DEFAULT_SIGNED_URL_BASE = '/_storage';
@@ -70,10 +67,34 @@ export type SignedUrlVerification =
70
67
  | { readonly ok: true; readonly constraints: SignedUrlConstraints }
71
68
  | { readonly ok: false; readonly reason: SignedUrlFailure; readonly detail: string };
72
69
 
73
- /** Newline-separated and order-fixed: an ambiguous canonical form is a forgeable one. */
74
- export function canonicalRequest(constraints: SignedUrlConstraints): string {
70
+ const trimBase = (base: string): string => base.replace(/\/+$/, '');
71
+
72
+ /**
73
+ * The PATH a base mounts at — what a route sees, and so the only part of a base both halves can
74
+ * agree on. An absolute `baseUrl` (`https://cdn.example.com/_storage/local`) names an origin too,
75
+ * and the verifier compared a request's pathname against the whole string: every URL minted under
76
+ * one was `malformed`. Never throws — a base `URL` cannot parse is compared as written.
77
+ */
78
+ export function signedUrlBasePath(baseUrl: string): string {
79
+ try {
80
+ return trimBase(new URL(baseUrl, 'http://storage.invalid').pathname);
81
+ } catch {
82
+ return trimBase(baseUrl);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Newline-separated and order-fixed: an ambiguous canonical form is a forgeable one.
88
+ *
89
+ * `basePath` is the disk: `/_storage/<registered name>`. Every local disk signs with the one
90
+ * `STORAGE_SIGNING_SECRET`, so without it a URL for `uploads/<key>` verified on `private/<key>`
91
+ * with only the path segment edited — a grant for one disk was a grant for all of them. The
92
+ * origin is deliberately outside it: the verifying route is handed a path.
93
+ */
94
+ export function canonicalRequest(constraints: SignedUrlConstraints, basePath: string): string {
75
95
  return [
76
96
  SIGNED_URL_VERSION,
97
+ basePath,
77
98
  constraints.method,
78
99
  constraints.key,
79
100
  String(constraints.expiresAt),
@@ -87,6 +108,7 @@ const encoder = new TextEncoder();
87
108
  export async function signConstraints(
88
109
  secret: string,
89
110
  constraints: SignedUrlConstraints,
111
+ basePath: string,
90
112
  ): Promise<string> {
91
113
  const key = await crypto.subtle.importKey(
92
114
  'raw',
@@ -95,11 +117,14 @@ export async function signConstraints(
95
117
  false,
96
118
  ['sign'],
97
119
  );
98
- const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(canonicalRequest(constraints)));
120
+ const mac = await crypto.subtle.sign(
121
+ 'HMAC',
122
+ key,
123
+ encoder.encode(canonicalRequest(constraints, basePath)),
124
+ );
99
125
  return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
100
126
  }
101
127
 
102
- const trimBase = (base: string): string => base.replace(/\/+$/, '');
103
128
  const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
104
129
 
105
130
  /**
@@ -148,8 +173,11 @@ export async function buildSignedUrl(input: SignedUrlInput): Promise<string> {
148
173
  if (constraints.contentType !== undefined) {
149
174
  params.set(SIGNED_URL_PARAMS.contentType, constraints.contentType);
150
175
  }
151
- params.set(SIGNED_URL_PARAMS.signature, await signConstraints(input.secret, constraints));
152
176
  const base = trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
177
+ params.set(
178
+ SIGNED_URL_PARAMS.signature,
179
+ await signConstraints(input.secret, constraints, signedUrlBasePath(base)),
180
+ );
153
181
  return `${base}/${encodeKey(key)}?${params.toString()}`;
154
182
  }
155
183
 
@@ -222,10 +250,11 @@ export async function verifySignedUrl(input: VerifySignedUrlInput): Promise<Sign
222
250
  }
223
251
  const signature = url.searchParams.get(SIGNED_URL_PARAMS.signature);
224
252
  if (signature === null) return fail('malformed', `no ${SIGNED_URL_PARAMS.signature} parameter`);
225
- const parsed = parseConstraints(url, trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE));
253
+ const basePath = signedUrlBasePath(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
254
+ const parsed = parseConstraints(url, basePath);
226
255
  if (typeof parsed === 'string') return fail(parsed, `${url.pathname} is not a signable request`);
227
256
 
228
- const expected = await signConstraints(input.secret, parsed);
257
+ const expected = await signConstraints(input.secret, parsed, basePath);
229
258
  if (!timingSafeEqual(expected, signature)) {
230
259
  return fail('signature-mismatch', 'the constraints do not match the signature');
231
260
  }
@@ -0,0 +1,76 @@
1
+ // Single responsibility: the secret a disk that mints its OWN URLs signs with — the published
2
+ // development fallback, the env key production must set, and the one rule (`localDriver` and
3
+ // `memoryStorageDriver` both) for refusing the fallback outside a development or test environment.
4
+
5
+ import {
6
+ devSecretsRefused,
7
+ type ResolveEnvironmentOptions,
8
+ resolveEnvironment,
9
+ } from '@ultimat3/core';
10
+ import { signingSecretMissing } from './errors';
11
+
12
+ /**
13
+ * The dev-only fallback signing key. A literal, not a per-process random one, so a restart does
14
+ * not invalidate every URL `x dev` handed out — and published in this repo, which is exactly why
15
+ * `localDriver` refuses to use it outside a development or test environment.
16
+ */
17
+ export const DEV_SIGNING_SECRET = 'ultimate-dev-signing-secret';
18
+
19
+ /** The env key production must set. Named once, read by the driver and by the predicate below. */
20
+ export const STORAGE_SIGNING_SECRET_KEY = 'STORAGE_SIGNING_SECRET';
21
+
22
+ /**
23
+ * True while a local disk built without an explicit `signingSecret` would sign with the shipped
24
+ * development key — `x doctor` reports it, exactly as it reports `usesDevCursorSecret()`.
25
+ *
26
+ * Reads the environment, not a driver instance: this is the same question `x doctor` asks about
27
+ * the cursor secret, and a disk handed an explicit `signingSecret` in `app.config.ts` never
28
+ * consults the variable at all.
29
+ *
30
+ * `env` is core's own slot, so this half of the guard reads the SAME table its other half does:
31
+ * `dev-runtime.ts` asks `!isLocal({ env }) && usesDevStorageSecret({ env })`, and an embedding
32
+ * caller (`serveApp({ env })`, a test fixture) whose `env` is not `process.env` used to get one
33
+ * answer about the boot and one about the process — for the decision of whether a disk may be
34
+ * signed with the published development key. Defaulted to `process.env`, so a bare call is
35
+ * unchanged.
36
+ */
37
+ export function usesDevStorageSecret(options?: Pick<ResolveEnvironmentOptions, 'env'>): boolean {
38
+ const source = options?.env ?? (process.env as Record<string, string | undefined>);
39
+ const configured = source[STORAGE_SIGNING_SECRET_KEY];
40
+ return configured === undefined || configured === '' || configured === DEV_SIGNING_SECRET;
41
+ }
42
+
43
+ /** The two options the rule reads; `LocalDriverOptions` carries both. */
44
+ export interface SigningSecretOptions {
45
+ readonly signingSecret?: string | undefined;
46
+ readonly env?: ResolveEnvironmentOptions['env'];
47
+ }
48
+
49
+ /**
50
+ * The secret a disk that mints its OWN URLs signs with — `localDriver` and `memoryStorageDriver` both.
51
+ *
52
+ * A dev disk must work with zero config. Outside development the fallback is refused rather than
53
+ * used: the literal is published, so signing with it hands every reader the power to mint a PUT
54
+ * for any key with any size and type limit — which `acceptSignedUpload` then trusts over the app's
55
+ * own `uploadPolicy`. Refused at construction, so the boot fails rather than the first upload.
56
+ *
57
+ * The published literal counts as no secret at all, whichever way it arrives: an env var or an
58
+ * `app.config.ts` that pasted it in signs exactly as weakly as the fallback does. One table for
59
+ * all three reads — the secret, the environment test and the environment the refusal names.
60
+ * Splitting them is how the guard and the disk came to answer about two different processes.
61
+ */
62
+ export function resolveSigningSecret(disk: string, options: SigningSecretOptions): string {
63
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
64
+ const supplied = options.signingSecret ?? env[STORAGE_SIGNING_SECRET_KEY];
65
+ const configured =
66
+ supplied === undefined || supplied === '' || supplied === DEV_SIGNING_SECRET
67
+ ? undefined
68
+ : supplied;
69
+ // FAILS CLOSED, through core's one rule (`devSecretsRefused`): a process that names no
70
+ // environment resolves as `production` here, the answer `assertNoDevSecretsOutsideLocal` gives.
71
+ // `isLocal`'s own fallback is `development`, so the process that forgot to say signed with the
72
+ // published key — exactly the one that must not.
73
+ if (configured === undefined && devSecretsRefused({ env }))
74
+ throw signingSecretMissing(resolveEnvironment({ env, fallback: 'production' }), disk);
75
+ return configured ?? DEV_SIGNING_SECRET;
76
+ }
package/src/upload.ts CHANGED
@@ -15,7 +15,9 @@ import {
15
15
  contentTypeNotAllowed,
16
16
  contentTypeUnrecognised,
17
17
  tooLarge,
18
+ xmlBodyUnreadable,
18
19
  } from './errors';
20
+ import { HEIF_CONTAINER, ISO_BMFF_CONTAINER, ISO_BMFF_TYPES, sniffIsoBmff } from './iso-bmff';
19
21
  import { assertSafeKey } from './path';
20
22
 
21
23
  export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
@@ -65,7 +67,12 @@ export function uploadPolicy(init: UploadPolicyInit = {}): UploadPolicy {
65
67
  init.maxBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : init.maxBytes,
66
68
  1,
67
69
  ),
68
- allowedContentTypes: init.allowedContentTypes ?? IMAGE_CONTENT_TYPES,
70
+ // Normalised, because the declared type is: `validateUpload` and `grantUpload` compare the
71
+ // NORMALISED declaration against this list, so an allowlist spelling an alias (`image/jpg`,
72
+ // `audio/x-m4a`) named a type no upload could ever match.
73
+ allowedContentTypes: (init.allowedContentTypes ?? IMAGE_CONTENT_TYPES).map(
74
+ normalizeContentType,
75
+ ),
69
76
  requireChecksum: init.requireChecksum ?? false,
70
77
  };
71
78
  }
@@ -111,7 +118,6 @@ const MAGIC_RULES: readonly MagicRule[] = [
111
118
  ],
112
119
  },
113
120
  { type: 'application/pdf', parts: [{ offset: 0, pattern: ascii('%PDF-') }] },
114
- { type: 'video/mp4', parts: [{ offset: 4, pattern: ascii('ftyp') }] },
115
121
  // Every OOXML document and epub is a zip; the container is as far as magic bytes go.
116
122
  { type: 'application/zip', parts: [{ offset: 0, pattern: [0x50, 0x4b, 0x03, 0x04] }] },
117
123
  ];
@@ -146,15 +152,57 @@ function sniffText(bytes: Uint8Array): string | undefined {
146
152
  if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) {
147
153
  return 'image/svg+xml';
148
154
  }
155
+ if (head.startsWith('<')) return activeMarkupType(text) ?? 'text/plain';
149
156
  return 'text/plain';
150
157
  }
151
158
 
159
+ /**
160
+ * `&#58;` and `&#x3a;` are `:` to an XML parser, so they are `:` to the scan below too. The digit
161
+ * run is UNBOUNDED because XML puts no cap on leading zeros (`&#x000000003a;` is `:`); the range is
162
+ * checked on the value instead, and a reference past U+10FFFF stays as written, as it is not a
163
+ * character a parser would produce.
164
+ */
165
+ const decodeCharacterReferences = (text: string): string =>
166
+ text.replace(/&#(x[0-9a-f]+|[0-9]+);/gi, (whole, ref: string) => {
167
+ const hex = ref[0] === 'x' || ref[0] === 'X';
168
+ const digits = (hex ? ref.slice(1) : ref).replace(/^0+(?=.)/, '');
169
+ if (digits.length > (hex ? 6 : 7)) return whole;
170
+ const code = Number.parseInt(digits, hex ? 16 : 10);
171
+ return code <= 0x10ffff ? String.fromCodePoint(code) : whole;
172
+ });
173
+
174
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
175
+ /** XHTML elements and XSLT output are pages; a DTD's entities can assemble either out of parts. */
176
+ const PAGE_MARKERS = [
177
+ 'http://www.w3.org/1999/xhtml',
178
+ 'http://www.w3.org/1999/xsl/transform',
179
+ '<?xml-stylesheet',
180
+ '<!entity',
181
+ ] as const;
182
+
183
+ /**
184
+ * Whether markup that looked like plain text is a document a browser RUNS — asked of the WHOLE
185
+ * body, never its head, because XML puts no rule on where a namespace is declared. Under
186
+ * `application/xml` a browser executes an XHTML-namespaced `<script>` anywhere in the tree, an
187
+ * SVG-namespaced element likewise, and an `<?xml-stylesheet?>` makes the document whatever HTML
188
+ * its XSLT emits. An internal `<!ENTITY>` subset is refused with them: an entity expands inside
189
+ * an attribute value, so it can build a namespace out of two halves no substring scan can see.
190
+ * A data document — a namespace of its own, no DTD, no stylesheet — stays plain text.
191
+ */
192
+ function activeMarkupType(text: string): 'text/html' | 'image/svg+xml' | undefined {
193
+ const scanned = decodeCharacterReferences(text).toLowerCase();
194
+ if (PAGE_MARKERS.some((marker) => scanned.includes(marker))) return 'text/html';
195
+ return scanned.includes(SVG_NAMESPACE) ? 'image/svg+xml' : undefined;
196
+ }
197
+
152
198
  /** `undefined` means "no rule recognised it", never "it is fine". */
153
199
  export function sniffContentType(bytes: Uint8Array): string | undefined {
154
200
  for (const rule of MAGIC_RULES) {
155
201
  if (matches(bytes, rule)) return rule.type;
156
202
  }
157
- return sniffText(bytes);
203
+ // Not a `MagicRule`: every ISO base media file opens with the same `ftyp` box, and the one rule
204
+ // that matched it labelled AVIF, HEIC, MOV and M4A all `video/mp4`. The major brand decides.
205
+ return sniffIsoBmff(bytes) ?? sniffText(bytes);
158
206
  }
159
207
 
160
208
  // A `Map`, not an object literal: `base` is the transport's own `Content-Type` header by the time
@@ -164,6 +212,10 @@ const ALIASES: ReadonlyMap<string, string> = new Map([
164
212
  ['image/jpg', 'image/jpeg'],
165
213
  ['image/x-png', 'image/png'],
166
214
  ['application/x-pdf', 'application/pdf'],
215
+ // What Chrome and Safari report for an `.m4a` / `.m4v` picked from disk.
216
+ ['audio/x-m4a', 'audio/mp4'],
217
+ ['audio/m4a', 'audio/mp4'],
218
+ ['video/x-m4v', 'video/mp4'],
167
219
  ]);
168
220
 
169
221
  /** Strip parameters and case: `IMAGE/PNG; charset=binary` and `image/png` are one type. */
@@ -182,11 +234,23 @@ const TEXT_FAMILY = new Set([
182
234
  'text/xml',
183
235
  ]);
184
236
 
185
- /** A generic sniff (zip container, plain text) may stand in for a specific declared type. */
237
+ // A brand that names only the container: `isom` is on an audio-only MP4 as often as on a video,
238
+ // and `mif1` on a still HEIC and a still AVIF alike. `image/heif` is also the general name of a
239
+ // file whose brand says `heic`, so that pair matches in the other direction too.
240
+ const MP4_CONTAINED = new Set(['audio/mp4']);
241
+ const HEIF_CONTAINED = new Set(['image/heic', 'image/avif']);
242
+
243
+ /**
244
+ * A generic sniff (zip container, ISO-BMFF container, plain text) may stand in for a specific
245
+ * declared type.
246
+ */
186
247
  export function contentTypeMatches(declared: string, sniffed: string): boolean {
187
248
  const type = normalizeContentType(declared);
188
249
  if (type === sniffed) return true;
189
250
  if (sniffed === 'application/zip') return ZIP_CONTAINERS.has(type);
251
+ if (sniffed === ISO_BMFF_CONTAINER) return MP4_CONTAINED.has(type);
252
+ if (sniffed === HEIF_CONTAINER) return HEIF_CONTAINED.has(type);
253
+ if (sniffed === 'image/heic') return type === HEIF_CONTAINER;
190
254
  if (sniffed === 'text/plain') return TEXT_FAMILY.has(type);
191
255
  return false;
192
256
  }
@@ -199,9 +263,16 @@ export function contentTypeMatches(declared: string, sniffed: string): boolean {
199
263
  * as provably wrong as one under `image/png`.
200
264
  */
201
265
  function hasSignature(declared: string): boolean {
202
- return MAGIC_RULES.some((rule) => contentTypeMatches(declared, rule.type));
266
+ return (
267
+ MAGIC_RULES.some((rule) => contentTypeMatches(declared, rule.type)) ||
268
+ ISO_BMFF_TYPES.some((type) => contentTypeMatches(declared, type))
269
+ );
203
270
  }
204
271
 
272
+ /** `application/xml`, `text/xml` and every `+xml` suffix type (RFC 6839): what a browser parses as XML. */
273
+ const isXmlType = (type: string): boolean =>
274
+ type === 'application/xml' || type === 'text/xml' || type.endsWith('+xml');
275
+
205
276
  /**
206
277
  * Throws the first violated constraint, in this order: key, size, type, checksum. The key comes
207
278
  * before the size because a key nothing may store makes the other three moot, and which
@@ -225,7 +296,13 @@ export function validateUpload(
225
296
  // says so and this branch read it the other way, so any body the sniffer bailed on (one
226
297
  // control byte, one non-UTF-8 sequence) skipped the guard entirely. Measured: an HTML
227
298
  // document with a trailing `0x01` was accepted as `image/png`.
299
+ //
300
+ // An XML type is refused the same way, though no signature names it: a browser decodes XML by
301
+ // its BOM or `encoding=` declaration (UTF-16, a legacy charset) and runs the XHTML or SVG it
302
+ // finds, so a body the sniffer could not read as UTF-8 text is one whose script it never saw.
303
+ // Plain text types (`text/csv`) stay accepted: a Latin-1 CSV is real, and nothing executes it.
228
304
  if (hasSignature(declared)) throw contentTypeUnrecognised(key, declared);
305
+ if (isXmlType(declared)) throw xmlBodyUnreadable(key, declared);
229
306
  } else if (!contentTypeMatches(declared, sniffed)) {
230
307
  throw contentTypeMismatch(key, declared, sniffed);
231
308
  }