@ultimat3/storage 30.9.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,91 @@
|
|
|
1
|
+
// Single responsibility: the scan step of `promoteAttachment` — the installed `UploadScanner` asked
|
|
2
|
+
// about a per-ATTEMPT copy of the upload, and its four outcomes: clean (the attempt is what gets
|
|
3
|
+
// attached), infected (attempt and upload deleted, refused), hold (attempt deleted, upload kept,
|
|
4
|
+
// refused until cleared) and no verdict (as hold, unless the policy accepts unscanned).
|
|
5
|
+
|
|
6
|
+
import { assert, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { StorageDriver, StorageObject } from './driver';
|
|
8
|
+
import { scanUnavailable, uploadHeld, uploadInfected } from './errors-scan';
|
|
9
|
+
import type { UploadPolicy } from './upload';
|
|
10
|
+
import { uploadScanner } from './upload-scanner';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `pending/scan/<random>-<name>`: a key of THIS confirm's own (`attachment.ts` builds it).
|
|
14
|
+
* Server-named, so no presigned URL reaches it and one scan of it is final; never the row's key, so
|
|
15
|
+
* a refusal deletes nothing a doubled confirm attached; inside `pending/`, so an attempt a crash
|
|
16
|
+
* left behind is an orphan `sweepOrphans` collects.
|
|
17
|
+
*/
|
|
18
|
+
export const SCAN_SEGMENT = 'scan';
|
|
19
|
+
|
|
20
|
+
export interface ScanAttemptInput {
|
|
21
|
+
readonly disk: StorageDriver;
|
|
22
|
+
/** The upload as it was confirmed: what a refusal names and a retry promotes again. */
|
|
23
|
+
readonly pendingKey: string;
|
|
24
|
+
/** The attempt copy under `SCAN_SEGMENT`, already sniffed. */
|
|
25
|
+
readonly attempt: StorageObject;
|
|
26
|
+
readonly policy: UploadPolicy;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deleted, or reported as not: a cleanup that fails never replaces the refusal it follows. */
|
|
30
|
+
async function removeAll(disk: StorageDriver, keys: readonly string[]): Promise<boolean> {
|
|
31
|
+
let removed = true;
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
try {
|
|
34
|
+
await disk.delete(key);
|
|
35
|
+
} catch {
|
|
36
|
+
removed = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return removed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when a scanner is installed — `promoteAttachment` stages an attempt copy only then. */
|
|
43
|
+
export const scanning = (): boolean => uploadScanner() !== undefined;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Stream the attempt — pinned to its etag where it has one — to the installed scanner. Returns on
|
|
47
|
+
* clean (or no verdict under `onScanUnavailable: 'accept'`); every refusal deletes the attempt, and
|
|
48
|
+
* infected deletes the upload too; hold keeps it pending. The stream is cancelled however the scanner returns: one that
|
|
49
|
+
* answers off the first chunk would otherwise hold the disk's connection open.
|
|
50
|
+
*/
|
|
51
|
+
export async function scanAttempt(input: ScanAttemptInput): Promise<void> {
|
|
52
|
+
const scanner = uploadScanner();
|
|
53
|
+
if (scanner === undefined) return;
|
|
54
|
+
const { disk, attempt } = input;
|
|
55
|
+
let body: ReadableStream<Uint8Array> | undefined;
|
|
56
|
+
let verdict: Awaited<ReturnType<typeof scanner.scan>>;
|
|
57
|
+
try {
|
|
58
|
+
// Inside the `try`: an attempt that cannot be READ for the scanner (an S3 GET reset, an
|
|
59
|
+
// etag-pinned read refused) is no verdict too, never a raw error past the cleanup.
|
|
60
|
+
body = await disk.stream(attempt.key, attempt.etag === '' ? undefined : { etag: attempt.etag });
|
|
61
|
+
verdict = await scanner.scan({
|
|
62
|
+
key: attempt.key,
|
|
63
|
+
pendingKey: input.pendingKey,
|
|
64
|
+
size: attempt.size,
|
|
65
|
+
contentType: attempt.contentType,
|
|
66
|
+
body,
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// A holding scanner's outage is never a pass: it may be the store that knows what is held.
|
|
70
|
+
if (input.policy.onScanUnavailable === 'accept' && scanner.holds !== true) return;
|
|
71
|
+
// The attempt goes, the upload stays: a retry once the scanner is back is a fresh confirm.
|
|
72
|
+
await removeAll(disk, [attempt.key]);
|
|
73
|
+
throw scanUnavailable(input.pendingKey, scanner.name, renderThrowable(error));
|
|
74
|
+
} finally {
|
|
75
|
+
await body?.cancel().catch(() => undefined);
|
|
76
|
+
}
|
|
77
|
+
if (verdict.verdict === 'clean') return;
|
|
78
|
+
if (verdict.verdict === 'hold') {
|
|
79
|
+
// A decision, not an outage: no `onScanUnavailable` reaches here. The upload is what a later
|
|
80
|
+
// confirm promotes; the attempt was this call's.
|
|
81
|
+
await removeAll(disk, [attempt.key]);
|
|
82
|
+
assert(
|
|
83
|
+
scanner.holds === true,
|
|
84
|
+
`the ${scanner.name} upload scanner answered hold without declaring holds: true, so its outages would have been promoted unscanned under onScanUnavailable: 'accept'`,
|
|
85
|
+
'uploadScanner: { ...yourScanner, holds: true } # in apps/<app>/runtime.ts',
|
|
86
|
+
);
|
|
87
|
+
throw uploadHeld(input.pendingKey, scanner.name, verdict.reason);
|
|
88
|
+
}
|
|
89
|
+
const removed = await removeAll(disk, [attempt.key, input.pendingKey]);
|
|
90
|
+
throw uploadInfected(input.pendingKey, scanner.name, verdict.signature, removed);
|
|
91
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The reference `UploadScanner`: finds the EICAR test file, the one byte string every antivirus
|
|
2
|
+
// agrees is a virus and no file legitimately contains. A test's scanner and the shape an app's
|
|
3
|
+
// ClamAV adapter copies — streamed, matched across chunk boundaries, no state between calls.
|
|
4
|
+
|
|
5
|
+
import type { ScanInput, ScanVerdict, UploadScanner } from './upload-scanner';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The 68-byte EICAR anti-malware test string. Assembled from two halves so this source file is not
|
|
9
|
+
* itself a detection on a developer's machine or in a registry scan.
|
|
10
|
+
*/
|
|
11
|
+
export const EICAR_TEST_STRING = `${'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR'}${'-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'}`;
|
|
12
|
+
|
|
13
|
+
const SIGNATURE = 'EICAR-Test-File';
|
|
14
|
+
const NEEDLE = new TextEncoder().encode(EICAR_TEST_STRING);
|
|
15
|
+
|
|
16
|
+
function indexOf(haystack: Uint8Array, needle: Uint8Array): number {
|
|
17
|
+
outer: for (let at = 0; at + needle.byteLength <= haystack.byteLength; at += 1) {
|
|
18
|
+
for (let i = 0; i < needle.byteLength; i += 1) {
|
|
19
|
+
if (haystack[at + i] !== needle[i]) continue outer;
|
|
20
|
+
}
|
|
21
|
+
return at;
|
|
22
|
+
}
|
|
23
|
+
return -1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reads the body to the end (or to the first match, then cancels it), keeping only the last
|
|
28
|
+
* `NEEDLE.length - 1` bytes between chunks: a match split by the stream is still a match, and the
|
|
29
|
+
* heap never holds more than one chunk plus that tail.
|
|
30
|
+
*/
|
|
31
|
+
async function containsEicar(body: ReadableStream<Uint8Array>): Promise<boolean> {
|
|
32
|
+
const reader = body.getReader();
|
|
33
|
+
let tail = new Uint8Array();
|
|
34
|
+
try {
|
|
35
|
+
for (;;) {
|
|
36
|
+
const { done, value } = await reader.read();
|
|
37
|
+
if (done) return false;
|
|
38
|
+
const window = new Uint8Array(tail.byteLength + value.byteLength);
|
|
39
|
+
window.set(tail);
|
|
40
|
+
window.set(value, tail.byteLength);
|
|
41
|
+
if (indexOf(window, NEEDLE) !== -1) {
|
|
42
|
+
await reader.cancel();
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
tail = window.slice(Math.max(0, window.byteLength - (NEEDLE.byteLength - 1)));
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
reader.releaseLock();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Infected exactly when the bytes contain the EICAR string, anywhere. Never unavailable. */
|
|
53
|
+
export function memoryUploadScanner(): UploadScanner {
|
|
54
|
+
return {
|
|
55
|
+
name: 'memory',
|
|
56
|
+
async scan(input: ScanInput): Promise<ScanVerdict> {
|
|
57
|
+
return (await containsEicar(input.body))
|
|
58
|
+
? { verdict: 'infected', signature: SIGNATURE }
|
|
59
|
+
: { verdict: 'clean' };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The `uploadScanner` port (`ADAPTER_PORTS`): what a content scanner is asked, what it answers, and
|
|
2
|
+
// the one slot the boot installs `runtime.ts`'s scanner into. `promoteAttachment` asks it; the
|
|
3
|
+
// framework ships the seam and a memory reference, never a vendor (axiom 8 — ClamAV is the app's).
|
|
4
|
+
|
|
5
|
+
import { adapterSlot } from '@ultimat3/core/adapter';
|
|
6
|
+
|
|
7
|
+
/** One stored object, handed over as a stream: a scanner never forces the whole file into a heap. */
|
|
8
|
+
export interface ScanInput {
|
|
9
|
+
/** Where the scanned bytes live: this confirm's own copy under `pending/scan/`, no URL rewrites. */
|
|
10
|
+
readonly key: string;
|
|
11
|
+
/**
|
|
12
|
+
* The upload being promoted — the `pending/` key `grantUpload` minted, stable across retries. What
|
|
13
|
+
* an out-of-band decision (a review queue, a vendor's async job) is keyed by; `key` changes per
|
|
14
|
+
* attempt.
|
|
15
|
+
*/
|
|
16
|
+
readonly pendingKey: string;
|
|
17
|
+
readonly size: number;
|
|
18
|
+
/** The type the object is stored under, already sniffed against its bytes. */
|
|
19
|
+
readonly contentType: string;
|
|
20
|
+
/** Every byte of the object. Read it to the end or cancel it; it is not reused. */
|
|
21
|
+
readonly body: ReadableStream<Uint8Array>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A verdict about the bytes, never about the scanner. A scanner that cannot decide — its daemon is
|
|
26
|
+
* down, it timed out, the file is past its own limit — REJECTS instead: `promoteAttachment` turns
|
|
27
|
+
* that into `X_STORAGE_SCAN_UNAVAILABLE` (or a pass, under `onScanUnavailable: 'accept'`).
|
|
28
|
+
*
|
|
29
|
+
* `hold` is a decision too: not yet cleared, by something outside this call — a human review, a
|
|
30
|
+
* vendor's asynchronous sandbox. The upload stays pending and promotion answers
|
|
31
|
+
* `X_STORAGE_QUARANTINED` until the scanner answers `clean` for that `pendingKey`. Neither `hold`
|
|
32
|
+
* nor `infected` is something a policy can wave through.
|
|
33
|
+
*/
|
|
34
|
+
export type ScanVerdict =
|
|
35
|
+
| { readonly verdict: 'clean' }
|
|
36
|
+
| { readonly verdict: 'infected'; readonly signature: string }
|
|
37
|
+
| { readonly verdict: 'hold'; readonly reason: string };
|
|
38
|
+
|
|
39
|
+
export interface UploadScanner {
|
|
40
|
+
/** Named in every refusal's `meta.scanner` — `clamd`, `memory`. */
|
|
41
|
+
readonly name: string;
|
|
42
|
+
/**
|
|
43
|
+
* `true` for a scanner that answers `hold` (a review queue, an async vendor scan). Its
|
|
44
|
+
* no-verdict then ALWAYS refuses — `onScanUnavailable: 'accept'` never applies — because a
|
|
45
|
+
* scanner that cannot reach its own record of holds cannot say an upload was cleared. A `hold`
|
|
46
|
+
* from a scanner that did not declare this is refused as a bug (`X_INVARIANT`), so the
|
|
47
|
+
* declaration cannot be forgotten past the first held upload.
|
|
48
|
+
*/
|
|
49
|
+
readonly holds?: boolean | undefined;
|
|
50
|
+
scan(input: ScanInput): Promise<ScanVerdict>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ambient = /* @__PURE__ */ adapterSlot<UploadScanner | undefined>(() => undefined);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The boot's install, from `uploadScanner` in `apps/<app>/runtime.ts` — exported from
|
|
57
|
+
* `@ultimat3/storage/adapter` only, so that file is the one place an app chooses a scanner. Answers
|
|
58
|
+
* the release that removes THIS install and no other.
|
|
59
|
+
*/
|
|
60
|
+
export function installUploadScanner(scanner: UploadScanner): () => void {
|
|
61
|
+
return ambient.install(scanner);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The installed scanner, or `undefined`: no scanner installed means promotion scans nothing. */
|
|
65
|
+
export function uploadScanner(): UploadScanner | undefined {
|
|
66
|
+
return ambient.current();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resetUploadScanner(): void {
|
|
70
|
+
ambient.reset();
|
|
71
|
+
}
|
package/src/upload.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// confirmed. A type no rule can ever confirm (`text/csv`, `application/json`) is still accepted
|
|
8
8
|
// on the client's word: there is nothing to compare it against.
|
|
9
9
|
|
|
10
|
-
import { finiteCount } from '@ultimat3/core';
|
|
10
|
+
import { assert, finiteCount } from '@ultimat3/core';
|
|
11
11
|
import { sha256Base64 } from './driver';
|
|
12
12
|
import {
|
|
13
13
|
checksumMismatch,
|
|
@@ -55,12 +55,33 @@ export interface UploadPolicy {
|
|
|
55
55
|
readonly allowedContentTypes: readonly string[];
|
|
56
56
|
/** When true a candidate without a `checksum` is rejected, not silently trusted. */
|
|
57
57
|
readonly requireChecksum: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* What `promoteAttachment` does when the installed `uploadScanner` gives no verdict (its daemon is
|
|
60
|
+
* down, it timed out). `'refuse'` — fail closed, `X_STORAGE_SCAN_UNAVAILABLE`, the upload stays
|
|
61
|
+
* pending for a retry — is the default and what an absent field means. `'accept'` promotes the
|
|
62
|
+
* file unscanned. An INFECTED verdict is refused under either: this decides availability only.
|
|
63
|
+
*/
|
|
64
|
+
readonly onScanUnavailable?: ScanUnavailablePolicy | undefined;
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
export type ScanUnavailablePolicy = 'refuse' | 'accept';
|
|
68
|
+
|
|
60
69
|
export interface UploadPolicyInit {
|
|
61
70
|
readonly maxBytes?: number | undefined;
|
|
62
71
|
readonly allowedContentTypes?: readonly string[] | undefined;
|
|
63
72
|
readonly requireChecksum?: boolean | undefined;
|
|
73
|
+
readonly onScanUnavailable?: ScanUnavailablePolicy | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Screened where it is declared: a typo (`'allow'`) must not read as the fail-open answer. */
|
|
77
|
+
function scanUnavailablePolicy(value: ScanUnavailablePolicy | undefined): ScanUnavailablePolicy {
|
|
78
|
+
const policy = value === undefined ? 'refuse' : value;
|
|
79
|
+
assert(
|
|
80
|
+
policy === 'refuse' || policy === 'accept',
|
|
81
|
+
`uploadPolicy was given onScanUnavailable ${JSON.stringify(policy)}, which is neither 'refuse' nor 'accept'`,
|
|
82
|
+
"uploadPolicy({ onScanUnavailable: 'refuse' }) — or 'accept' to promote unscanned files while the scanner is down",
|
|
83
|
+
);
|
|
84
|
+
return policy;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
export function uploadPolicy(init: UploadPolicyInit = {}): UploadPolicy {
|
|
@@ -84,6 +105,7 @@ export function uploadPolicy(init: UploadPolicyInit = {}): UploadPolicy {
|
|
|
84
105
|
normalizeContentType,
|
|
85
106
|
),
|
|
86
107
|
requireChecksum: init.requireChecksum ?? false,
|
|
108
|
+
onScanUnavailable: scanUnavailablePolicy(init.onScanUnavailable),
|
|
87
109
|
};
|
|
88
110
|
}
|
|
89
111
|
|
|
@@ -296,8 +318,19 @@ function hasSignature(declared: string): boolean {
|
|
|
296
318
|
);
|
|
297
319
|
}
|
|
298
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Whether a head opens with markup (after whitespace): then `sniffText` asks `activeMarkupType` of
|
|
323
|
+
* the WHOLE body, so a streamed accept must read it whole too — or text with XHTML past the head
|
|
324
|
+
* passes as plain text. A binary body never opens with `<`, so it keeps streaming.
|
|
325
|
+
*/
|
|
326
|
+
export const opensWithMarkup = (head: Uint8Array): boolean =>
|
|
327
|
+
new TextDecoder('utf-8')
|
|
328
|
+
.decode(head.subarray(0, MAX_LEADING_WHITESPACE + 1))
|
|
329
|
+
.trimStart()
|
|
330
|
+
.startsWith('<');
|
|
331
|
+
|
|
299
332
|
/** `application/xml`, `text/xml` and every `+xml` suffix type (RFC 6839): what a browser parses as XML. */
|
|
300
|
-
const isXmlType = (type: string): boolean =>
|
|
333
|
+
export const isXmlType = (type: string): boolean =>
|
|
301
334
|
type === 'application/xml' || type === 'text/xml' || type.endsWith('+xml');
|
|
302
335
|
|
|
303
336
|
/**
|