@ultimat3/storage 30.8.0 → 31.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 +56 -113
- package/README.md +117 -24
- package/package.json +4 -3
- package/src/accept-stream.ts +125 -0
- package/src/accept.ts +24 -6
- package/src/adapter.ts +46 -0
- package/src/attachment.ts +43 -61
- package/src/driver-local-list.ts +1 -1
- package/src/driver-local-open.ts +112 -0
- package/src/driver-local-read.ts +1 -1
- package/src/driver-local-stream.ts +14 -3
- package/src/driver-local.ts +48 -16
- package/src/driver-memory.ts +39 -9
- package/src/driver-s3-copy-parts.ts +177 -0
- package/src/driver-s3-copy.ts +14 -7
- package/src/driver-s3-lock.ts +1 -3
- package/src/driver-s3-put-stream.ts +81 -17
- package/src/driver-s3-range.ts +14 -7
- package/src/driver-s3-signed.ts +1 -1
- package/src/driver-s3-stream.ts +36 -0
- package/src/driver-s3.ts +44 -27
- package/src/driver.ts +30 -5
- package/src/errors-promote.ts +3 -15
- package/src/errors-scan.ts +61 -0
- package/src/errors-stream.ts +13 -0
- package/src/errors-variant.ts +40 -0
- package/src/errors.ts +6 -0
- package/src/grant.ts +15 -10
- package/src/image-variant.ts +106 -0
- package/src/index.ts +21 -40
- package/src/path.ts +27 -1
- package/src/request-ceiling.ts +45 -0
- package/src/storage-instrument.ts +108 -0
- package/src/storage.ts +8 -2
- package/src/upload-scan.ts +91 -0
- package/src/upload-scanner-memory.ts +62 -0
- package/src/upload-scanner.ts +71 -0
- package/src/upload.ts +35 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Single responsibility: a signed upload taken as a STREAM — the head read and sniffed, the rest
|
|
2
|
+
// piped into `disk.putStream`, so memory per upload is the sniff window, not the body. A body that
|
|
3
|
+
// ends inside the window, an XML type or a head opening with `<` (a script can sit anywhere in
|
|
4
|
+
// either), and a required checksum nobody declared are read whole by `acceptSignedUpload`'s rule.
|
|
5
|
+
|
|
6
|
+
import { type AcceptSignedUploadInput, acceptSignedUpload, signedObjectPut } from './accept';
|
|
7
|
+
import type { StorageObject } from './driver';
|
|
8
|
+
import { checksumMismatch, tooLarge } from './errors';
|
|
9
|
+
import {
|
|
10
|
+
isXmlType,
|
|
11
|
+
opensWithMarkup,
|
|
12
|
+
SNIFF_WINDOW_BYTES,
|
|
13
|
+
uploadPolicy,
|
|
14
|
+
validateStoredHead,
|
|
15
|
+
} from './upload';
|
|
16
|
+
|
|
17
|
+
export interface AcceptSignedUploadStreamInput extends Omit<AcceptSignedUploadInput, 'bytes'> {
|
|
18
|
+
/** The request's body; `null` is an empty one. Read once, never buffered past the window. */
|
|
19
|
+
readonly body: ReadableStream<Uint8Array> | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Chunks read until `window` bytes are in hand or the body ends; past `ceiling` it is refused. */
|
|
23
|
+
async function readHead(
|
|
24
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
25
|
+
key: string,
|
|
26
|
+
window: number,
|
|
27
|
+
ceiling: number,
|
|
28
|
+
): Promise<{ readonly chunks: Uint8Array[]; readonly size: number; readonly done: boolean }> {
|
|
29
|
+
const chunks: Uint8Array[] = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
while (size < window) {
|
|
32
|
+
const next = await reader.read();
|
|
33
|
+
if (next.done) return { chunks, size, done: true };
|
|
34
|
+
chunks.push(next.value);
|
|
35
|
+
size += next.value.byteLength;
|
|
36
|
+
if (size > ceiling) throw tooLarge(key, size, ceiling);
|
|
37
|
+
}
|
|
38
|
+
return { chunks, size, done: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The uploader's body, no longer wanted: cancelled so the socket stops, whatever cancel answers. */
|
|
42
|
+
async function stopReading(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
await reader.cancel();
|
|
45
|
+
} catch {
|
|
46
|
+
// An already-errored body cannot be cancelled, and has stopped anyway.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const concat = (chunks: readonly Uint8Array[]): Uint8Array =>
|
|
51
|
+
new Uint8Array(Bun.concatArrayBuffers([...chunks]));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `acceptSignedUpload` for a body that may not fit in memory: the same gates, in the same order,
|
|
55
|
+
* with the magic bytes judged on the head. Past the ceiling, or a declared checksum the bytes do
|
|
56
|
+
* not hash to, the stream errors and `putStream` abandons the write: the key is as it was.
|
|
57
|
+
*/
|
|
58
|
+
export async function acceptSignedUploadStream(
|
|
59
|
+
input: AcceptSignedUploadStreamInput,
|
|
60
|
+
): Promise<StorageObject> {
|
|
61
|
+
const { key, contentType, maxBytes } = await signedObjectPut(input);
|
|
62
|
+
const policy = input.policy ?? uploadPolicy();
|
|
63
|
+
const ceiling = Math.min(policy.maxBytes, maxBytes ?? policy.maxBytes);
|
|
64
|
+
const reader = (input.body ?? new Blob([]).stream()).getReader();
|
|
65
|
+
let head: Awaited<ReturnType<typeof readHead>>;
|
|
66
|
+
try {
|
|
67
|
+
head = await readHead(reader, key, SNIFF_WINDOW_BYTES, ceiling);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await stopReading(reader);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
const whole =
|
|
73
|
+
head.done ||
|
|
74
|
+
isXmlType(contentType) ||
|
|
75
|
+
opensWithMarkup(concat(head.chunks)) ||
|
|
76
|
+
(policy.requireChecksum && input.checksum === undefined);
|
|
77
|
+
if (whole) {
|
|
78
|
+
const chunks = [...head.chunks];
|
|
79
|
+
let size = head.size;
|
|
80
|
+
try {
|
|
81
|
+
for (
|
|
82
|
+
let next = head.done ? undefined : await reader.read();
|
|
83
|
+
next !== undefined && !next.done;
|
|
84
|
+
next = await reader.read()
|
|
85
|
+
) {
|
|
86
|
+
size += next.value.byteLength;
|
|
87
|
+
if (size > ceiling) throw tooLarge(key, size, ceiling);
|
|
88
|
+
chunks.push(next.value);
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
await stopReading(reader);
|
|
92
|
+
}
|
|
93
|
+
return acceptSignedUpload({ ...input, bytes: concat(chunks) });
|
|
94
|
+
}
|
|
95
|
+
// The head says more follows: `size + 1` is "at least one byte past it", so an XML or a cut
|
|
96
|
+
// character is judged as a head, never as a whole body.
|
|
97
|
+
validateStoredHead(
|
|
98
|
+
{ key, declaredContentType: contentType, head: concat(head.chunks), size: head.size + 1 },
|
|
99
|
+
policy,
|
|
100
|
+
);
|
|
101
|
+
const hasher = input.checksum === undefined ? undefined : new Bun.CryptoHasher('sha256');
|
|
102
|
+
let size = 0;
|
|
103
|
+
const counted = (chunk: Uint8Array): Uint8Array => {
|
|
104
|
+
size += chunk.byteLength;
|
|
105
|
+
if (size > ceiling) throw tooLarge(key, size, ceiling);
|
|
106
|
+
hasher?.update(chunk);
|
|
107
|
+
return chunk;
|
|
108
|
+
};
|
|
109
|
+
const queued = head.chunks.slice();
|
|
110
|
+
const body = new ReadableStream<Uint8Array>({
|
|
111
|
+
async pull(controller) {
|
|
112
|
+
const first = queued.shift();
|
|
113
|
+
if (first !== undefined) return controller.enqueue(counted(first));
|
|
114
|
+
const next = await reader.read();
|
|
115
|
+
if (!next.done) return controller.enqueue(counted(next.value));
|
|
116
|
+
const actual = hasher?.digest('base64');
|
|
117
|
+
if (actual !== undefined && actual !== input.checksum) {
|
|
118
|
+
throw checksumMismatch(key, input.checksum ?? 'none', actual);
|
|
119
|
+
}
|
|
120
|
+
controller.close();
|
|
121
|
+
},
|
|
122
|
+
cancel: (reason) => reader.cancel(reason),
|
|
123
|
+
});
|
|
124
|
+
return input.disk.putStream(key, body, { maxBytes: ceiling, contentType });
|
|
125
|
+
}
|
package/src/accept.ts
CHANGED
|
@@ -133,13 +133,21 @@ export async function signedUploadConstraints(
|
|
|
133
133
|
return constraintsFor(input, 'PUT');
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** What a verified object PUT may store: the key, the type the grant signed, its ceiling. */
|
|
137
|
+
export interface SignedObjectPut {
|
|
138
|
+
readonly key: string;
|
|
139
|
+
readonly contentType: string;
|
|
140
|
+
readonly maxBytes: number | undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
136
143
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* magic bytes. `validateUpload` runs last because it is the only one that reads the whole body.
|
|
144
|
+
* Every gate a PUT passes before its body is read: the signature, the expiry, the tenant, the
|
|
145
|
+
* method, an OBJECT url (never a part's), and the declared type against the signed one. Shared by
|
|
146
|
+
* `acceptSignedUpload` and the streamed `acceptSignedUploadStream`, so the two are one rule.
|
|
141
147
|
*/
|
|
142
|
-
export async function
|
|
148
|
+
export async function signedObjectPut(
|
|
149
|
+
input: SignedRequestInput & { readonly declaredContentType?: string | undefined },
|
|
150
|
+
): Promise<SignedObjectPut> {
|
|
143
151
|
const constraints = await constraintsFor(input, 'PUT');
|
|
144
152
|
const key = constraints.key;
|
|
145
153
|
// A part URL writes one part of an open upload, never the object: `acceptSignedPart` takes it.
|
|
@@ -169,9 +177,19 @@ export async function acceptSignedUpload(input: AcceptSignedUploadInput): Promis
|
|
|
169
177
|
);
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
return { key, contentType: signed, maxBytes: constraints.maxBytes };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Take a presigned PUT and write it, or refuse. Four gates the client cannot move because all
|
|
185
|
+
* four are inside the signature or inside the bytes: the signature itself, the expiry, the byte
|
|
186
|
+
* count against the signed ceiling, and the declared type against both the signature and the
|
|
187
|
+
* magic bytes. `validateUpload` runs last because it is the only one that reads the whole body.
|
|
188
|
+
*/
|
|
189
|
+
export async function acceptSignedUpload(input: AcceptSignedUploadInput): Promise<StorageObject> {
|
|
190
|
+
const { key, contentType: signed, maxBytes } = await signedObjectPut(input);
|
|
172
191
|
// The signed ceiling, checked before the policy's: the two can differ once a policy is edited
|
|
173
192
|
// between the grant and the upload, and the narrower one is the one that was granted.
|
|
174
|
-
const maxBytes = constraints.maxBytes;
|
|
175
193
|
const size = input.bytes.byteLength;
|
|
176
194
|
if (maxBytes !== undefined && size > maxBytes) throw tooLarge(key, size, maxBytes);
|
|
177
195
|
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `@ultimat3/storage/adapter`: two ports as an adapter author needs them. `storage`: `StorageDriver`,
|
|
2
|
+
// its shapes, errors and shared refusal helpers — no installer, the boot READS `runtime.ts`'s
|
|
3
|
+
// driver as its default disk (`@ultimat3/cli`'s `runtime-storage-boot.ts`). `uploadScanner`:
|
|
4
|
+
// `UploadScanner`, what it is asked and answers, and the boot's installer.
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
ByteLimit,
|
|
8
|
+
CopyOptions,
|
|
9
|
+
ListOptions,
|
|
10
|
+
PutOptions,
|
|
11
|
+
ServerSideEncryption,
|
|
12
|
+
SignedUrlMethod,
|
|
13
|
+
SignedUrlOptions,
|
|
14
|
+
StorageBody,
|
|
15
|
+
StorageDriver,
|
|
16
|
+
StorageListEntry,
|
|
17
|
+
StorageObject,
|
|
18
|
+
StorageRead,
|
|
19
|
+
StreamOptions,
|
|
20
|
+
} from './driver';
|
|
21
|
+
export { assertPutOptions, etagOf, resolveListLimit, toBytes } from './driver';
|
|
22
|
+
export type { LocalDriverOptions } from './driver-local';
|
|
23
|
+
export type { MemoryStorageDriverOptions } from './driver-memory';
|
|
24
|
+
export type { S3DriverOptions } from './driver-s3';
|
|
25
|
+
export type { StorageErrorCode, StorageErrorInit } from './errors';
|
|
26
|
+
export {
|
|
27
|
+
checksumMismatch,
|
|
28
|
+
deleteFailed,
|
|
29
|
+
getTooLarge,
|
|
30
|
+
isStorageError,
|
|
31
|
+
keyConflict,
|
|
32
|
+
listFailed,
|
|
33
|
+
objectNotFound,
|
|
34
|
+
pathUnsafe,
|
|
35
|
+
putFailed,
|
|
36
|
+
putTooLarge,
|
|
37
|
+
readFailed,
|
|
38
|
+
StorageError,
|
|
39
|
+
} from './errors';
|
|
40
|
+
export type { MultipartCapability } from './multipart';
|
|
41
|
+
export type { ObjectLock, ObjectLockOptions } from './object-lock';
|
|
42
|
+
export type { PutStreamOptions } from './put-stream';
|
|
43
|
+
export type { ByteRange, GetOptions } from './range';
|
|
44
|
+
export type { SignedPart, SignedUrlVerification } from './signed-url';
|
|
45
|
+
export type { ScanInput, ScanVerdict, UploadScanner } from './upload-scanner';
|
|
46
|
+
export { installUploadScanner, resetUploadScanner } from './upload-scanner';
|
package/src/attachment.ts
CHANGED
|
@@ -5,31 +5,18 @@
|
|
|
5
5
|
// so the tenant prefix is a construction, not a check somebody remembered to write.
|
|
6
6
|
|
|
7
7
|
import type { Clock } from '@ultimat3/core';
|
|
8
|
-
import { assert, finiteCount, renderThrowable, systemClock } from '@ultimat3/core';
|
|
8
|
+
import { assert, finiteCount, nanoid, renderThrowable, systemClock } from '@ultimat3/core';
|
|
9
9
|
import type { StorageDriver, StorageListEntry, StorageObject } from './driver';
|
|
10
10
|
import { objectNotFound, orgMismatch, tooLarge } from './errors';
|
|
11
|
-
import { alreadyPromoted, notPending, objectChanged
|
|
11
|
+
import { alreadyPromoted, notPending, objectChanged } from './errors-promote';
|
|
12
12
|
import { assertSafeKey, isWithinOrg, orgPrefix, scopedKey } from './path';
|
|
13
13
|
import type { UploadPolicy } from './upload';
|
|
14
14
|
import { SNIFF_WINDOW_BYTES, validateStoredHead } from './upload';
|
|
15
|
+
import { SCAN_SEGMENT, scanAttempt, scanning } from './upload-scan';
|
|
15
16
|
|
|
16
17
|
/** The one segment an unattached upload lives under. `sweepOrphans` reads only this prefix. */
|
|
17
18
|
export const PENDING_SEGMENT = 'pending';
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
* One more segment inside `pending/`, and the whole of Ultimate's content-scanning mechanism.
|
|
21
|
-
*
|
|
22
|
-
* Magic-byte sniffing closes stored XSS; it is not malware scanning, and `application/zip` is
|
|
23
|
-
* deliberately accepted as the OOXML container, so a macro-laden `.docx` passes `validateUpload`
|
|
24
|
-
* exactly as a clean one does. Scanning is the APP's — a scanner is a business decision with a
|
|
25
|
-
* vendor, a licence and a latency budget (axiom 8) — so what ships is the place to put one: an
|
|
26
|
-
* upload granted with `quarantine: true` lands under this prefix, `promoteAttachment` REFUSES a
|
|
27
|
-
* key that is still there (`X_STORAGE_QUARANTINED`), and the app's scan job calls
|
|
28
|
-
* `releaseQuarantine` on a verdict of clean. Inside `pending/` on purpose: an upload nobody ever
|
|
29
|
-
* scanned is still an orphan, so `sweepOrphans` collects it with no second prefix to walk.
|
|
30
|
-
*/
|
|
31
|
-
export const QUARANTINE_SEGMENT = 'quarantine';
|
|
32
|
-
|
|
33
20
|
export interface AttachmentTarget {
|
|
34
21
|
/** Entity name as the app declares it — `post`, `user`. Exactly one key segment. */
|
|
35
22
|
readonly entity: string;
|
|
@@ -78,48 +65,9 @@ export function attachmentKey(orgId: string, target: AttachmentTarget, name: str
|
|
|
78
65
|
return scopedKey(orgId, target.entity, target.id, target.field, name);
|
|
79
66
|
}
|
|
80
67
|
|
|
81
|
-
/** `org/<orgId>/pending/quarantine/<name>` — uploaded, validated, and not yet cleared to use. */
|
|
82
|
-
export function quarantineKey(orgId: string, name: string): string {
|
|
83
|
-
return scopedKey(orgId, PENDING_SEGMENT, QUARANTINE_SEGMENT, name);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function quarantinePrefix(orgId: string): string {
|
|
87
|
-
return `${pendingPrefix(orgId)}${QUARANTINE_SEGMENT}/`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
68
|
export const isPendingKey = (key: string, orgId: string): boolean =>
|
|
91
69
|
key.startsWith(pendingPrefix(orgId));
|
|
92
70
|
|
|
93
|
-
/** True while nothing has cleared the key. `promoteAttachment` refuses exactly this. */
|
|
94
|
-
export const isQuarantinedKey = (key: string, orgId: string): boolean =>
|
|
95
|
-
key.startsWith(quarantinePrefix(orgId));
|
|
96
|
-
|
|
97
|
-
export interface ReleaseQuarantineInput {
|
|
98
|
-
readonly disk: StorageDriver;
|
|
99
|
-
/** A key under `quarantinePrefix(orgId)`. */
|
|
100
|
-
readonly key: string;
|
|
101
|
-
readonly orgId: string;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* The app's scan said clean: move the object out of quarantine and onto the ordinary `pending/`
|
|
106
|
-
* key, which `promoteAttachment` will accept. Copy then delete, for the reason promotion does —
|
|
107
|
-
* a delete that ran first loses the bytes on a failed write.
|
|
108
|
-
*
|
|
109
|
-
* Releasing a key that is not quarantined returns it untouched rather than copying it onto
|
|
110
|
-
* itself: a scan job that retries after a crash must not be a second round trip, and a `pending/`
|
|
111
|
-
* key is already the released state.
|
|
112
|
-
*/
|
|
113
|
-
export async function releaseQuarantine(input: ReleaseQuarantineInput): Promise<string> {
|
|
114
|
-
if (!isWithinOrg(input.key, input.orgId)) throw orgMismatch(input.key, input.orgId);
|
|
115
|
-
if (!isQuarantinedKey(input.key, input.orgId)) return input.key;
|
|
116
|
-
const name = input.key.slice(input.key.lastIndexOf('/') + 1);
|
|
117
|
-
const released = pendingKey(input.orgId, name);
|
|
118
|
-
await input.disk.copy(input.key, released);
|
|
119
|
-
await input.disk.delete(input.key);
|
|
120
|
-
return released;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
71
|
export interface PromoteAttachmentInput {
|
|
124
72
|
readonly disk: StorageDriver;
|
|
125
73
|
/** A key `grantUpload` minted with no `target`. */
|
|
@@ -197,6 +145,14 @@ async function assertCopyIsWhatWasJudged(
|
|
|
197
145
|
* pending too. A ranged read because `maxGetBytes` bounds the range, never the object. An overwrite
|
|
198
146
|
* after that read is `X_STORAGE_OBJECT_CHANGED`, or the copy itself is judged, and never lands.
|
|
199
147
|
*
|
|
148
|
+
* SCANNED before it attaches, when `runtime.ts` installs an `uploadScanner`: on a per-attempt copy
|
|
149
|
+
* no presigned URL can rewrite (`scannedAttempt`), which is then what is attached. Infected is
|
|
150
|
+
* `X_STORAGE_UPLOAD_INFECTED` with attempt and upload deleted; no verdict is
|
|
151
|
+
* `X_STORAGE_SCAN_UNAVAILABLE` with the attempt deleted and the upload still pending, unless
|
|
152
|
+
* `policy.onScanUnavailable` accepts; a `hold` verdict (a review, an async vendor scan) is
|
|
153
|
+
* `X_STORAGE_QUARANTINED`, the upload pending, and no policy overrides it. None ever touches the
|
|
154
|
+
* row's key. This is the ONE scan path: the v30 `quarantine: true` segment is retired.
|
|
155
|
+
*
|
|
200
156
|
* A source that is gone with the destination already there is `X_STORAGE_ALREADY_PROMOTED`
|
|
201
157
|
* (409, `meta.attachedKey`), never the destination answered as if this call had moved it. 27.1.0
|
|
202
158
|
* answered it: a confirm sent twice then inserted a second row for one key, that insert failed,
|
|
@@ -213,7 +169,6 @@ export async function promoteAttachment(input: PromoteAttachmentInput): Promise<
|
|
|
213
169
|
// Pending, not merely in-org: an attached key a client sent back belongs to another row, and the
|
|
214
170
|
// copy-then-delete below would move that row's file here and delete theirs.
|
|
215
171
|
if (!isPendingKey(input.key, input.orgId)) throw notPending(input.key, input.orgId);
|
|
216
|
-
if (isQuarantinedKey(input.key, input.orgId)) throw quarantined(input.key, input.orgId);
|
|
217
172
|
// The type says required; an untyped caller upgrading from the four-field call does not read
|
|
218
173
|
// types, and `input.policy.maxBytes` on `undefined` is a bare `TypeError` two lines down.
|
|
219
174
|
assert(
|
|
@@ -235,16 +190,43 @@ export async function promoteAttachment(input: PromoteAttachmentInput): Promise<
|
|
|
235
190
|
throw tooLarge(input.key, pending.size, input.policy.maxBytes);
|
|
236
191
|
}
|
|
237
192
|
await judge(disk, pending, input.key, input.policy);
|
|
193
|
+
const scanned = scanning() ? await scannedAttempt(disk, pending, input, name) : undefined;
|
|
194
|
+
const source = scanned ?? pending;
|
|
238
195
|
const object = await disk.copy(
|
|
239
|
-
input.key,
|
|
196
|
+
scanned?.key ?? input.key,
|
|
240
197
|
attached,
|
|
241
|
-
|
|
198
|
+
source.etag === '' ? undefined : { ifMatch: source.etag },
|
|
242
199
|
);
|
|
243
|
-
await assertCopyIsWhatWasJudged(disk,
|
|
200
|
+
await assertCopyIsWhatWasJudged(disk, source, object, input.policy);
|
|
201
|
+
// The attempt is this call's scratch: a delete that fails leaves an orphan the sweep collects,
|
|
202
|
+
// never a refusal of a promotion that already happened.
|
|
203
|
+
if (scanned !== undefined) await disk.delete(scanned.key).catch(() => undefined);
|
|
244
204
|
await disk.delete(input.key);
|
|
245
205
|
return object;
|
|
246
206
|
}
|
|
247
207
|
|
|
208
|
+
/**
|
|
209
|
+
* With an `uploadScanner` installed, what is attached is a copy THIS call made and scanned: under
|
|
210
|
+
* `pending/scan/`, server-named (no presigned URL rewrites it after the scan) and its own (a
|
|
211
|
+
* refusal deletes it and nothing a doubled confirm attached). Judged like any copy first.
|
|
212
|
+
*/
|
|
213
|
+
async function scannedAttempt(
|
|
214
|
+
disk: StorageDriver,
|
|
215
|
+
pending: StorageObject,
|
|
216
|
+
input: PromoteAttachmentInput,
|
|
217
|
+
name: string,
|
|
218
|
+
): Promise<StorageObject> {
|
|
219
|
+
const key = scopedKey(input.orgId, PENDING_SEGMENT, SCAN_SEGMENT, `${nanoid()}-${name}`);
|
|
220
|
+
const attempt = await disk.copy(
|
|
221
|
+
input.key,
|
|
222
|
+
key,
|
|
223
|
+
pending.etag === '' ? undefined : { ifMatch: pending.etag },
|
|
224
|
+
);
|
|
225
|
+
await assertCopyIsWhatWasJudged(disk, pending, attempt, input.policy);
|
|
226
|
+
await scanAttempt({ disk, pendingKey: input.key, attempt, policy: input.policy });
|
|
227
|
+
return attempt;
|
|
228
|
+
}
|
|
229
|
+
|
|
248
230
|
export interface SweepOrphansInput {
|
|
249
231
|
readonly disk: StorageDriver;
|
|
250
232
|
readonly orgId: string;
|
|
@@ -277,8 +259,8 @@ export interface SweepResult {
|
|
|
277
259
|
|
|
278
260
|
/**
|
|
279
261
|
* Delete every unattached upload past the window, and answer with what went and what did not.
|
|
280
|
-
* Scoped to one org and to the `pending/` prefix —
|
|
281
|
-
*
|
|
262
|
+
* Scoped to one org and to the `pending/` prefix — a `hold` included, since a held upload is still
|
|
263
|
+
* pending: a sweep that could reach an attached key is a job that deletes production data
|
|
282
264
|
* the first time an app forgets to promote something.
|
|
283
265
|
*
|
|
284
266
|
* A refusal is recorded and the sweep continues. Stopping at the first one would leave the caller
|
package/src/driver-local-list.ts
CHANGED
|
@@ -57,7 +57,7 @@ export async function listLocalPage(input: LocalListInput): Promise<Page<Storage
|
|
|
57
57
|
'local',
|
|
58
58
|
prefix,
|
|
59
59
|
error,
|
|
60
|
-
`
|
|
60
|
+
`ls -ld ${renderFixShellArg(root, "'<the disk root>'")} # make the disk root readable by this process, then retry`,
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
keys.sort();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Single responsibility: the local disk's PINNED read — the file opened and its revision checked
|
|
2
|
+
// against the etag, at the call and again at the first pull, so the bytes streamed are that
|
|
3
|
+
// revision's or the read refuses.
|
|
4
|
+
|
|
5
|
+
// why: Bun has no handle API — `Bun.file(path)` reopens the path at every read, which is the race
|
|
6
|
+
// this file exists to close, and `Bun.file(fd)` has no positioned read nor a close.
|
|
7
|
+
import { type FileHandle, open } from 'node:fs/promises';
|
|
8
|
+
import { etagOfFile, headObject, refusedRead } from './driver-local-read';
|
|
9
|
+
import { objectNotFound } from './errors';
|
|
10
|
+
import { revisionChanged } from './errors-stream';
|
|
11
|
+
import { type ByteRange, streamedRange } from './range';
|
|
12
|
+
|
|
13
|
+
/** What one pull reads: big enough to amortise a syscall, small enough to never matter. */
|
|
14
|
+
const CHUNK_BYTES = 256 * 1024;
|
|
15
|
+
|
|
16
|
+
export interface PinnedRead {
|
|
17
|
+
readonly root: string;
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly disk: string;
|
|
20
|
+
readonly etag: string;
|
|
21
|
+
readonly range?: ByteRange | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Closed, whatever the close says: a read that has its answer does not trade it for a close error. */
|
|
25
|
+
const closeQuietly = async (handle: FileHandle | undefined): Promise<void> => {
|
|
26
|
+
try {
|
|
27
|
+
await handle?.close();
|
|
28
|
+
} catch {
|
|
29
|
+
// Already closed, or the OS refused: either way the descriptor is no longer this read's.
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One open file, proved to be revision `etag`: opened FIRST, then described by path. A write landing
|
|
35
|
+
* in between leaves the handle on the old bytes and the description on the new ones, so it refuses
|
|
36
|
+
* — never serves a mismatch. A commit writes the sidecar before it renames the bytes, so a handle
|
|
37
|
+
* on new bytes always meets a description at least that new.
|
|
38
|
+
*/
|
|
39
|
+
async function openChecked(read: PinnedRead): Promise<{ handle: FileHandle; size: number }> {
|
|
40
|
+
const { root, key, disk, etag } = read;
|
|
41
|
+
const handle = await open(`${root}/${key}`, 'r').catch(refusedRead(root, key, disk));
|
|
42
|
+
try {
|
|
43
|
+
const entry = await headObject(root, key, () => etagOfFile(root, key, disk));
|
|
44
|
+
if (entry === undefined) throw objectNotFound(disk, key);
|
|
45
|
+
if (entry.etag !== etag) throw revisionChanged(disk, key, etag);
|
|
46
|
+
return { handle, size: (await handle.stat()).size };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
await closeQuietly(handle);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The object at `key` as revision `etag`, or `X_STORAGE_OBJECT_CHANGED`; `range` as `streamRange`.
|
|
55
|
+
* Checked NOW and closed again, so a stream nobody reads holds no descriptor; the first pull reopens
|
|
56
|
+
* and checks the ETAG again. Never the inode alone: ext4 hands a freed inode straight back, so two
|
|
57
|
+
* puts left v3 at v1's inode number and it was served under v1's etag.
|
|
58
|
+
*/
|
|
59
|
+
export async function streamPinned(read: PinnedRead): Promise<ReadableStream<Uint8Array>> {
|
|
60
|
+
const { key, range } = read;
|
|
61
|
+
const checked = await openChecked(read);
|
|
62
|
+
await closeQuietly(checked.handle);
|
|
63
|
+
const { size } = checked;
|
|
64
|
+
const [first, last] =
|
|
65
|
+
range === undefined ? [0, size - 1] : streamedRange('local', key, range, size);
|
|
66
|
+
// The same etag is the same bytes, so the same size: the etag check is the whole proof.
|
|
67
|
+
const reopen = async (): Promise<FileHandle> => (await openChecked(read)).handle;
|
|
68
|
+
return streamOf(reopen, first, last + 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `[first, end)` off one handle opened at the first pull, closed on the last byte, a cancel or a failure. */
|
|
72
|
+
function streamOf(
|
|
73
|
+
opener: () => Promise<FileHandle>,
|
|
74
|
+
first: number,
|
|
75
|
+
end: number,
|
|
76
|
+
): ReadableStream<Uint8Array> {
|
|
77
|
+
let position = first;
|
|
78
|
+
let handle: FileHandle | undefined;
|
|
79
|
+
const close = async (): Promise<void> => {
|
|
80
|
+
await closeQuietly(handle);
|
|
81
|
+
handle = undefined;
|
|
82
|
+
};
|
|
83
|
+
return new ReadableStream<Uint8Array>(
|
|
84
|
+
{
|
|
85
|
+
async pull(controller) {
|
|
86
|
+
try {
|
|
87
|
+
const want = Math.min(CHUNK_BYTES, end - position);
|
|
88
|
+
if (want <= 0) {
|
|
89
|
+
await close();
|
|
90
|
+
return controller.close();
|
|
91
|
+
}
|
|
92
|
+
handle ??= await opener();
|
|
93
|
+
const buffer = new Uint8Array(want);
|
|
94
|
+
const { bytesRead } = await handle.read(buffer, 0, want, position);
|
|
95
|
+
if (bytesRead === 0) {
|
|
96
|
+
await close();
|
|
97
|
+
return controller.close();
|
|
98
|
+
}
|
|
99
|
+
position += bytesRead;
|
|
100
|
+
controller.enqueue(bytesRead === want ? buffer : buffer.subarray(0, bytesRead));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await close();
|
|
103
|
+
controller.error(error);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
cancel: close,
|
|
107
|
+
},
|
|
108
|
+
// Pulled only when read: the default high-water mark of 1 pulls once at construction, which
|
|
109
|
+
// opened a descriptor for a body nobody would ever read (a 304, a dropped client).
|
|
110
|
+
{ highWaterMark: 0 },
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/driver-local-read.ts
CHANGED
|
@@ -33,7 +33,7 @@ async function pendingEtag(root: string, key: string): Promise<string | undefine
|
|
|
33
33
|
* that is simply gone — deleted between the `exists()` and the read — is "not found", as it
|
|
34
34
|
* would have been one call earlier.
|
|
35
35
|
*/
|
|
36
|
-
const refusedRead =
|
|
36
|
+
export const refusedRead =
|
|
37
37
|
(root: string, key: string, disk: string) =>
|
|
38
38
|
(error: unknown): never => {
|
|
39
39
|
if (isStorageError(error)) throw error;
|
|
@@ -76,6 +76,13 @@ export const discardStaged = async (path: string): Promise<void> => {
|
|
|
76
76
|
.catch(() => undefined);
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* What the writer holds before a flush: one per MiB, not one per chunk, so a 64 KiB-chunk sender
|
|
81
|
+
* pays a sixteenth of the syscalls and backpressure still holds at a MiB of heap. Measured
|
|
82
|
+
* 2026-11-09, 512 MiB in 64 KiB chunks: 2.72 s → 2.53 s (the hash is most of the rest).
|
|
83
|
+
*/
|
|
84
|
+
export const LOCAL_FLUSH_BYTES = 1024 * 1024;
|
|
85
|
+
|
|
79
86
|
/** Backpressure is the `flush`: awaiting it is what keeps a fast sender from filling the heap. */
|
|
80
87
|
export async function stageStream(input: StageStreamInput): Promise<StagedStream> {
|
|
81
88
|
const { root, key, disk, body, maxBytes } = input;
|
|
@@ -84,12 +91,16 @@ export async function stageStream(input: StageStreamInput): Promise<StagedStream
|
|
|
84
91
|
let sink: ReturnType<ReturnType<typeof Bun.file>['writer']> | undefined;
|
|
85
92
|
try {
|
|
86
93
|
await mkdir(`${root}/${STAGING_DIR}`, { recursive: true });
|
|
87
|
-
const open = Bun.file(path).writer();
|
|
94
|
+
const open = Bun.file(path).writer({ highWaterMark: LOCAL_FLUSH_BYTES });
|
|
88
95
|
sink = open;
|
|
89
|
-
|
|
96
|
+
let buffered = 0;
|
|
97
|
+
const size = await pumpBounded(body, streamLimit('local', key, maxBytes), (chunk) => {
|
|
90
98
|
hasher.update(chunk);
|
|
91
99
|
open.write(chunk);
|
|
92
|
-
|
|
100
|
+
buffered += chunk.byteLength;
|
|
101
|
+
if (buffered < LOCAL_FLUSH_BYTES) return;
|
|
102
|
+
buffered = 0;
|
|
103
|
+
return Promise.resolve(open.flush()).then(() => undefined);
|
|
93
104
|
});
|
|
94
105
|
await open.end();
|
|
95
106
|
sink = undefined;
|