@ultimat3/storage 24.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.
- package/CLAUDE.md +36 -40
- package/README.md +86 -17
- package/package.json +3 -3
- package/src/attachment.ts +6 -9
- package/src/driver-local-read.ts +19 -9
- package/src/driver-local-sidecar.ts +61 -3
- package/src/driver-local.ts +98 -33
- package/src/driver-memory-conflict.ts +45 -0
- package/src/driver-memory.ts +75 -16
- package/src/driver-s3-absent.ts +32 -0
- package/src/driver-s3-client.ts +94 -0
- package/src/driver-s3-lock.ts +131 -0
- package/src/driver-s3-signed.ts +220 -0
- package/src/driver-s3.ts +95 -155
- package/src/driver.ts +36 -19
- package/src/errors.ts +23 -6
- package/src/index.ts +15 -7
- package/src/object-lock.ts +173 -0
- package/src/signed-url.ts +0 -4
- package/src/signing-secret.ts +12 -7
- package/src/upload.ts +51 -0
|
@@ -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/signed-url.ts
CHANGED
|
@@ -8,10 +8,6 @@ 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
11
|
/** `v2` since the canonical string carries the base path: a `v1` signature names no disk. */
|
|
16
12
|
export const SIGNED_URL_VERSION = 'v2';
|
|
17
13
|
export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
|
package/src/signing-secret.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// Single responsibility: the secret a disk that mints its OWN URLs signs with — the published
|
|
2
2
|
// development fallback, the env key production must set, and the one rule (`localDriver` and
|
|
3
|
-
// `
|
|
3
|
+
// `memoryStorageDriver` both) for refusing the fallback outside a development or test environment.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
devSecretsRefused,
|
|
7
|
+
type ResolveEnvironmentOptions,
|
|
8
|
+
resolveEnvironment,
|
|
9
|
+
} from '@ultimat3/core';
|
|
6
10
|
import { signingSecretMissing } from './errors';
|
|
7
11
|
|
|
8
12
|
/**
|
|
@@ -43,7 +47,7 @@ export interface SigningSecretOptions {
|
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/**
|
|
46
|
-
* The secret a disk that mints its OWN URLs signs with — `localDriver` and `
|
|
50
|
+
* The secret a disk that mints its OWN URLs signs with — `localDriver` and `memoryStorageDriver` both.
|
|
47
51
|
*
|
|
48
52
|
* A dev disk must work with zero config. Outside development the fallback is refused rather than
|
|
49
53
|
* used: the literal is published, so signing with it hands every reader the power to mint a PUT
|
|
@@ -62,10 +66,11 @@ export function resolveSigningSecret(disk: string, options: SigningSecretOptions
|
|
|
62
66
|
supplied === undefined || supplied === '' || supplied === DEV_SIGNING_SECRET
|
|
63
67
|
? undefined
|
|
64
68
|
: supplied;
|
|
65
|
-
// FAILS CLOSED: a process that names no
|
|
66
|
-
//
|
|
67
|
-
// process that forgot to say signed with the
|
|
68
|
-
|
|
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 }))
|
|
69
74
|
throw signingSecretMissing(resolveEnvironment({ env, fallback: 'production' }), disk);
|
|
70
75
|
return configured ?? DEV_SIGNING_SECRET;
|
|
71
76
|
}
|
package/src/upload.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
contentTypeNotAllowed,
|
|
16
16
|
contentTypeUnrecognised,
|
|
17
17
|
tooLarge,
|
|
18
|
+
xmlBodyUnreadable,
|
|
18
19
|
} from './errors';
|
|
19
20
|
import { HEIF_CONTAINER, ISO_BMFF_CONTAINER, ISO_BMFF_TYPES, sniffIsoBmff } from './iso-bmff';
|
|
20
21
|
import { assertSafeKey } from './path';
|
|
@@ -151,9 +152,49 @@ function sniffText(bytes: Uint8Array): string | undefined {
|
|
|
151
152
|
if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) {
|
|
152
153
|
return 'image/svg+xml';
|
|
153
154
|
}
|
|
155
|
+
if (head.startsWith('<')) return activeMarkupType(text) ?? 'text/plain';
|
|
154
156
|
return 'text/plain';
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* `:` and `:` 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 (`:` 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
|
+
|
|
157
198
|
/** `undefined` means "no rule recognised it", never "it is fine". */
|
|
158
199
|
export function sniffContentType(bytes: Uint8Array): string | undefined {
|
|
159
200
|
for (const rule of MAGIC_RULES) {
|
|
@@ -228,6 +269,10 @@ function hasSignature(declared: string): boolean {
|
|
|
228
269
|
);
|
|
229
270
|
}
|
|
230
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
|
+
|
|
231
276
|
/**
|
|
232
277
|
* Throws the first violated constraint, in this order: key, size, type, checksum. The key comes
|
|
233
278
|
* before the size because a key nothing may store makes the other three moot, and which
|
|
@@ -251,7 +296,13 @@ export function validateUpload(
|
|
|
251
296
|
// says so and this branch read it the other way, so any body the sniffer bailed on (one
|
|
252
297
|
// control byte, one non-UTF-8 sequence) skipped the guard entirely. Measured: an HTML
|
|
253
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.
|
|
254
304
|
if (hasSignature(declared)) throw contentTypeUnrecognised(key, declared);
|
|
305
|
+
if (isXmlType(declared)) throw xmlBodyUnreadable(key, declared);
|
|
255
306
|
} else if (!contentTypeMatches(declared, sniffed)) {
|
|
256
307
|
throw contentTypeMismatch(key, declared, sniffed);
|
|
257
308
|
}
|