@fabrika/app 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,292 @@
1
+ // The archive check of the scan pass. ClamAV answers OK for a ZIP whose entries it skipped (LZMA, a damaged deflate
2
+ // stream), so its OK cannot vouch for an archive. Before a scanning collection's file reaches the scanner, the
3
+ // framework reads every ZIP itself — an Office Open XML document is one — and admits only what ClamAV reads in full:
4
+ // stored or deflated entries, no encryption, a layout in which every byte belongs to an entry or the directory, sizes
5
+ // and CRCs that match, bounded counts and sizes, and nested ZIPs to a small depth. Any other archive is refused.
6
+
7
+ import { inflateRawSync } from 'node:zlib'
8
+
9
+ export const ARCHIVE_LIMITS = {
10
+ /** Entries of one archive, nested ones counted with their parent's. */
11
+ maxEntries: 1000,
12
+ /** Uncompressed bytes of one archive and everything nested in it. */
13
+ maxUnpackedBytes: 256 * 1024 * 1024,
14
+ /** The outer archive is depth 0; an archive inside it is 1; one inside that is refused. */
15
+ maxDepth: 1,
16
+ } as const
17
+
18
+ /** Why an archive is refused. Each is a reject, never a retry: the bytes will not change. */
19
+ export type ArchiveRefusal =
20
+ | 'encrypted'
21
+ | 'unsupported_method'
22
+ | 'zip64'
23
+ | 'malformed'
24
+ | 'too_many_entries'
25
+ | 'too_large_unpacked'
26
+ | 'nested_too_deep'
27
+ | 'unsupported_archive'
28
+ /** A ZIP signature inside a file that is no ZIP, or before a ZIP's first entry. */
29
+ | 'embedded_archive'
30
+ /** More than one end record a scanner could pick, or one whose comment does not end the file. */
31
+ | 'ambiguous_directory'
32
+
33
+ export type ArchiveCheck = { readonly ok: true } | { readonly ok: false; readonly refusal: ArchiveRefusal }
34
+
35
+ const LOCAL = 0x04034b50
36
+ const CENTRAL = 0x02014b50
37
+ const END = 0x06054b50
38
+ const DESCRIPTOR = 0x08074b50
39
+ const STORED = 0
40
+ const DEFLATED = 8
41
+ /** Bit 0: encrypted; bit 6: strong encryption; bit 13: the directory is encrypted. */
42
+ const ENCRYPTION_FLAGS = 0x0001 | 0x0040 | 0x2000
43
+ const HAS_DESCRIPTOR = 0x0008
44
+
45
+ /** Other archive and compression formats, by their first bytes: the scan cannot vouch for what they hold. */
46
+ const OTHER_ARCHIVES: readonly (readonly number[])[] = [
47
+ [0x1f, 0x8b], // gzip
48
+ [0x42, 0x5a, 0x68], // bzip2
49
+ [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00], // xz
50
+ [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c], // 7z
51
+ [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07], // rar
52
+ [0x4d, 0x53, 0x43, 0x46], // cab
53
+ [0x28, 0xb5, 0x2f, 0xfd], // zstd
54
+ [0x4c, 0x5a, 0x49, 0x50], // lzip
55
+ [0x5d, 0x00, 0x00], // lzma
56
+ ]
57
+
58
+ class Refused extends Error {
59
+ readonly refusal: ArchiveRefusal
60
+
61
+ constructor(refusal: ArchiveRefusal) {
62
+ super(refusal)
63
+ this.refusal = refusal
64
+ }
65
+ }
66
+
67
+ function startsWith(bytes: Uint8Array, prefix: readonly number[], at = 0): boolean {
68
+ return bytes.length >= at + prefix.length && prefix.every((byte, index) => bytes[at + index] === byte)
69
+ }
70
+
71
+ function isZip(bytes: Uint8Array): boolean {
72
+ return startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]) || startsWith(bytes, [0x50, 0x4b, 0x05, 0x06])
73
+ }
74
+
75
+ /** `ustar` at offset 257: a tar archive, which ClamAV opens but this check does not. */
76
+ function isTar(bytes: Uint8Array): boolean {
77
+ return startsWith(bytes, [0x75, 0x73, 0x74, 0x61, 0x72], 257)
78
+ }
79
+
80
+ function isOtherArchive(bytes: Uint8Array): boolean {
81
+ return isTar(bytes) || OTHER_ARCHIVES.some((magic) => startsWith(bytes, magic))
82
+ }
83
+
84
+ /** A ZIP's local header, central header and end record: ClamAV opens a ZIP it finds by any of them, wherever it sits. */
85
+ const ZIP_SIGNATURES = [Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from([0x50, 0x4b, 0x01, 0x02]), Buffer.from([0x50, 0x4b, 0x05, 0x06])]
86
+
87
+ /**
88
+ * Whether a ZIP signature appears anywhere: in a file that is no ZIP, it is a ZIP embedded where the check does not
89
+ * look, so it is refused. Random bytes hold one with a chance of about three in 2^32 per byte (roughly one in 1400 for
90
+ * 1 MB of compressed data); such a file is rejected too, which fails closed.
91
+ */
92
+ function containsZipSignature(bytes: Uint8Array): boolean {
93
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
94
+ return ZIP_SIGNATURES.some((signature) => buffer.includes(signature))
95
+ }
96
+
97
+ /** How often `signature` occurs in `bytes`. */
98
+ function occurrences(bytes: Uint8Array, signature: Buffer): number {
99
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
100
+ let count = 0
101
+ for (let at = buffer.indexOf(signature); at !== -1; at = buffer.indexOf(signature, at + 1)) count++
102
+ return count
103
+ }
104
+
105
+ class Reader {
106
+ readonly #view: DataView
107
+
108
+ constructor(readonly bytes: Uint8Array) {
109
+ this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
110
+ }
111
+
112
+ u16(at: number): number {
113
+ if (at < 0 || at + 2 > this.bytes.length) throw new Refused('malformed')
114
+ return this.#view.getUint16(at, true)
115
+ }
116
+
117
+ u32(at: number): number {
118
+ if (at < 0 || at + 4 > this.bytes.length) throw new Refused('malformed')
119
+ return this.#view.getUint32(at, true)
120
+ }
121
+
122
+ slice(start: number, end: number): Uint8Array {
123
+ if (start < 0 || end < start || end > this.bytes.length) throw new Refused('malformed')
124
+ return this.bytes.subarray(start, end)
125
+ }
126
+ }
127
+
128
+ interface Entry {
129
+ readonly flags: number
130
+ readonly method: number
131
+ readonly crc: number
132
+ readonly compressed: number
133
+ readonly uncompressed: number
134
+ readonly name: Uint8Array
135
+ readonly offset: number
136
+ }
137
+
138
+ interface Budget {
139
+ entries: number
140
+ unpacked: number
141
+ }
142
+
143
+ /**
144
+ * The end-of-directory record: the only one a scanner could find in the tail, its comment ending the file exactly and
145
+ * holding no ZIP signature, one disk, no ZIP64. A scanner that picked another record would read another directory.
146
+ */
147
+ function endRecord(zip: Reader): { readonly entries: number; readonly directoryStart: number; readonly directoryEnd: number } {
148
+ const length = zip.bytes.length
149
+ const tail = zip.bytes.subarray(Math.max(0, length - 22 - 0xffff))
150
+ if (occurrences(tail, ZIP_SIGNATURES[2] ?? Buffer.alloc(0)) !== 1) throw new Refused('ambiguous_directory')
151
+ for (let at = length - 22; at >= Math.max(0, length - 22 - 0xffff); at--) {
152
+ if (zip.u32(at) !== END) continue
153
+ if (at + 22 + zip.u16(at + 20) !== length) throw new Refused('ambiguous_directory')
154
+ if (containsZipSignature(zip.slice(at + 22, length))) throw new Refused('ambiguous_directory')
155
+ const entries = zip.u16(at + 10)
156
+ const size = zip.u32(at + 12)
157
+ const start = zip.u32(at + 16)
158
+ if (entries === 0xffff || size === 0xffffffff || start === 0xffffffff) throw new Refused('zip64')
159
+ if (zip.u16(at + 4) !== 0 || zip.u16(at + 6) !== 0 || zip.u16(at + 8) !== entries) throw new Refused('malformed')
160
+ if (start + size !== at) throw new Refused('malformed')
161
+ return { entries, directoryStart: start, directoryEnd: at }
162
+ }
163
+ throw new Refused('malformed')
164
+ }
165
+
166
+ function directory(zip: Reader, end: ReturnType<typeof endRecord>): Entry[] {
167
+ const entries: Entry[] = []
168
+ let at = end.directoryStart
169
+ for (let index = 0; index < end.entries; index++) {
170
+ if (zip.u32(at) !== CENTRAL) throw new Refused('malformed')
171
+ const flags = zip.u16(at + 8)
172
+ const method = zip.u16(at + 10)
173
+ const compressed = zip.u32(at + 20)
174
+ const uncompressed = zip.u32(at + 24)
175
+ const nameLength = zip.u16(at + 28)
176
+ const next = at + 46 + nameLength + zip.u16(at + 30) + zip.u16(at + 32)
177
+ if ((flags & ENCRYPTION_FLAGS) !== 0) throw new Refused('encrypted')
178
+ if (method !== STORED && method !== DEFLATED) throw new Refused('unsupported_method')
179
+ if (compressed === 0xffffffff || uncompressed === 0xffffffff || zip.u32(at + 42) === 0xffffffff) throw new Refused('zip64')
180
+ if (zip.u16(at + 34) !== 0 || (method === STORED && compressed !== uncompressed)) throw new Refused('malformed')
181
+ entries.push({
182
+ flags,
183
+ method,
184
+ crc: zip.u32(at + 16),
185
+ compressed,
186
+ uncompressed,
187
+ name: zip.slice(at + 46, at + 46 + nameLength),
188
+ offset: zip.u32(at + 42),
189
+ })
190
+ at = next
191
+ }
192
+ if (at !== end.directoryEnd) throw new Refused('malformed')
193
+ return entries
194
+ }
195
+
196
+ const sameBytes = (left: Uint8Array, right: Uint8Array): boolean => left.length === right.length && left.every((byte, index) => byte === right[index])
197
+
198
+ /** The entry's compressed bytes, after a local header that agrees with the directory; and where the entry ends. */
199
+ function localData(zip: Reader, entry: Entry): { readonly data: Uint8Array; readonly end: number } {
200
+ const at = entry.offset
201
+ if (zip.u32(at) !== LOCAL) throw new Refused('malformed')
202
+ const flags = zip.u16(at + 6)
203
+ if ((flags & ENCRYPTION_FLAGS) !== 0) throw new Refused('encrypted')
204
+ if (zip.u16(at + 8) !== entry.method || (flags & HAS_DESCRIPTOR) !== (entry.flags & HAS_DESCRIPTOR)) throw new Refused('malformed')
205
+ const nameLength = zip.u16(at + 26)
206
+ if (!sameBytes(zip.slice(at + 30, at + 30 + nameLength), entry.name)) throw new Refused('malformed')
207
+ const start = at + 30 + nameLength + zip.u16(at + 28)
208
+ const dataEnd = start + entry.compressed
209
+ const data = zip.slice(start, dataEnd)
210
+ if ((flags & HAS_DESCRIPTOR) === 0) {
211
+ if (zip.u32(at + 14) !== entry.crc || zip.u32(at + 18) !== entry.compressed || zip.u32(at + 22) !== entry.uncompressed) {
212
+ throw new Refused('malformed')
213
+ }
214
+ return { data, end: dataEnd }
215
+ }
216
+ // A data descriptor follows the data, with or without its signature.
217
+ const signed = dataEnd + 4 <= zip.bytes.length && zip.u32(dataEnd) === DESCRIPTOR
218
+ const fields = signed ? dataEnd + 4 : dataEnd
219
+ if (zip.u32(fields) !== entry.crc || zip.u32(fields + 4) !== entry.compressed || zip.u32(fields + 8) !== entry.uncompressed) {
220
+ throw new Refused('malformed')
221
+ }
222
+ return { data, end: fields + 12 }
223
+ }
224
+
225
+ function unpacked(entry: Entry, data: Uint8Array, budget: Budget): Uint8Array {
226
+ budget.unpacked += entry.uncompressed
227
+ if (budget.unpacked > ARCHIVE_LIMITS.maxUnpackedBytes) throw new Refused('too_large_unpacked')
228
+ let content: Uint8Array
229
+ if (entry.method === STORED) {
230
+ content = data
231
+ } else {
232
+ try {
233
+ // One byte more than the directory claims: a longer stream lies about its size.
234
+ content = inflateRawSync(data, { maxOutputLength: entry.uncompressed + 1 })
235
+ } catch {
236
+ throw new Refused('malformed')
237
+ }
238
+ }
239
+ if (content.length !== entry.uncompressed || Bun.hash.crc32(content) !== entry.crc) throw new Refused('malformed')
240
+ return content
241
+ }
242
+
243
+ function checkZip(bytes: Uint8Array, depth: number, budget: Budget): void {
244
+ const zip = new Reader(bytes)
245
+ const end = endRecord(zip)
246
+ const entries = directory(zip, end).sort((left, right) => left.offset - right.offset)
247
+ budget.entries += entries.length
248
+ if (budget.entries > ARCHIVE_LIMITS.maxEntries) throw new Refused('too_many_entries')
249
+ // Every byte belongs to an entry or the directory: nothing hides between them, before the first or after the last.
250
+ let expected = 0
251
+ for (const entry of entries) {
252
+ if (entry.offset !== expected) throw new Refused('malformed')
253
+ const { data, end: entryEnd } = localData(zip, entry)
254
+ expected = entryEnd
255
+ checkContent(unpacked(entry, data, budget), depth, budget)
256
+ }
257
+ if (expected !== end.directoryStart) throw new Refused('malformed')
258
+ }
259
+
260
+ /** A nested entry's content: a ZIP from its first byte is checked in turn; any other archive or ZIP signature is refused. */
261
+ function checkContent(content: Uint8Array, depth: number, budget: Budget): void {
262
+ if (isZip(content)) {
263
+ if (depth + 1 > ARCHIVE_LIMITS.maxDepth) throw new Refused('nested_too_deep')
264
+ checkZip(content, depth + 1, budget)
265
+ } else if (isOtherArchive(content)) {
266
+ throw new Refused('unsupported_archive')
267
+ } else if (containsZipSignature(content)) {
268
+ throw new Refused('embedded_archive')
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Whether the scanner can vouch for these bytes. A file of a ZIP-based type (`zipBased`: ZIP and Office Open XML) must
274
+ * be a ZIP from its first byte whose every entry, nested ZIPs included, ClamAV reads in full. Any other file passes
275
+ * only when it holds no archive: no other archive format, and no ZIP signature at any offset.
276
+ */
277
+ export function checkArchive(bytes: Uint8Array, zipBased: boolean): ArchiveCheck {
278
+ try {
279
+ if (zipBased) {
280
+ if (!isZip(bytes)) throw new Refused('embedded_archive')
281
+ checkZip(bytes, 0, { entries: 0, unpacked: 0 })
282
+ } else if (isOtherArchive(bytes)) {
283
+ throw new Refused('unsupported_archive')
284
+ } else if (containsZipSignature(bytes)) {
285
+ throw new Refused('embedded_archive')
286
+ }
287
+ return { ok: true }
288
+ } catch (error) {
289
+ if (error instanceof Refused) return { ok: false, refusal: error.refusal }
290
+ throw error
291
+ }
292
+ }
@@ -0,0 +1,253 @@
1
+ // The ClamAV scanner of the scan pass, on Bun: one `clamscan` over a batch written to a private temporary directory,
2
+ // so the signatures load once per batch. Its limits make every unscannable file an alert rather than a silent pass:
3
+ // an encrypted file, one past a size, recursion or time limit, all come back FOUND and are rejected. Signatures older
4
+ // than `maxSignatureAgeDays` make clamscan refuse the batch. Only an exact `<path>: OK` line is a clean verdict.
5
+
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { FILE_LIMITS } from '../contract/defaults'
10
+ import { type FileScanner, SCAN_LIMITS, type ScanBatchResult, type ScanFailure, type ScanObject, type ScanVerdict } from './scan'
11
+
12
+ export const CLAMSCAN_LIMITS = {
13
+ /** The scanner refuses signatures older than this; the provider refreshes them at start and daily. */
14
+ maxSignatureAgeDays: 3,
15
+ /** One file's scan time, after which ClamAV alerts instead of passing it. */
16
+ maxScanTimeMs: 120_000,
17
+ maxRecursion: 10,
18
+ maxFilesPerContainer: 1000,
19
+ /** What clamscan may print; past it the run is stopped and the batch gets no verdict. */
20
+ maxOutputBytes: 1024 * 1024,
21
+ } as const
22
+
23
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
24
+
25
+ export interface ClamscanOptions {
26
+ /** The `clamscan` binary; by default the one on `PATH`. */
27
+ readonly binary?: string
28
+ /** The signature directory; by default ClamAV's configured one. */
29
+ readonly database?: string
30
+ /** Where the batch's directory is made; by default the system's temporary directory. */
31
+ readonly directory?: string
32
+ readonly timeoutMs?: number
33
+ /** The largest file ClamAV reads in full; a larger one alerts. By default `FILE_LIMITS.maxScannedBytes`. */
34
+ readonly maxFileBytes?: number
35
+ }
36
+
37
+ /** The arguments of one run; every limit alerts, so a file ClamAV could not read in full is never reported OK. */
38
+ export function clamscanArguments(database: string | undefined, size: number = FILE_LIMITS.maxScannedBytes): readonly string[] {
39
+ return [
40
+ '--no-summary',
41
+ '--stdout',
42
+ '--alert-encrypted=yes',
43
+ '--alert-exceeds-max=yes',
44
+ `--fail-if-cvd-older-than=${CLAMSCAN_LIMITS.maxSignatureAgeDays}`,
45
+ `--max-filesize=${size}`,
46
+ `--max-scansize=${size * 4}`,
47
+ `--max-scantime=${CLAMSCAN_LIMITS.maxScanTimeMs}`,
48
+ `--max-recursion=${CLAMSCAN_LIMITS.maxRecursion}`,
49
+ `--max-files=${CLAMSCAN_LIMITS.maxFilesPerContainer}`,
50
+ ...(database === undefined ? [] : [`--database=${database}`]),
51
+ ]
52
+ }
53
+
54
+ /**
55
+ * The verdicts of one finished run. Exit 0 (nothing found) and 1 (something found) carry per-file lines; any other exit
56
+ * is a failed batch, classified from stderr. A file without exactly one `<path>: OK` or `<path>: <name> FOUND` line,
57
+ * a conflicting second line included, gets no clean verdict.
58
+ */
59
+ export function clamscanVerdicts(paths: ReadonlyMap<string, string>, exitCode: number, stdout: string, stderr: string): ScanBatchResult {
60
+ if (exitCode !== 0 && exitCode !== 1) {
61
+ // `--stdout` moves clamscan's own errors to stdout; libclamav's stay on stderr.
62
+ return { ok: false, failure: failureOf(`${stdout}\n${stderr}`) }
63
+ }
64
+ const verdicts = new Map<string, ScanVerdict>()
65
+ const seen = new Set<string>()
66
+ for (const line of stdout.split('\n')) {
67
+ for (const [id, path] of paths) {
68
+ const prefix = `${path}: `
69
+ if (!line.startsWith(prefix)) {
70
+ continue
71
+ }
72
+ const answer = line.slice(prefix.length)
73
+ const found = /^(\S+) FOUND$/.exec(answer)
74
+ const verdict: ScanVerdict = answer === 'OK'
75
+ ? { kind: 'clean' }
76
+ : found?.[1] !== undefined
77
+ ? { kind: 'infected', signature: found[1] }
78
+ : { kind: 'unscanned' }
79
+ verdicts.set(id, seen.has(id) ? { kind: 'unscanned' } : verdict)
80
+ seen.add(id)
81
+ }
82
+ }
83
+ return { ok: true, verdicts }
84
+ }
85
+
86
+ function failureOf(output: string): ScanFailure {
87
+ if (output.includes('Virus database is older than')) {
88
+ return 'signatures_stale'
89
+ }
90
+ if (output.includes('cl_load') || output.includes('No supported database files')) {
91
+ return 'signatures_missing'
92
+ }
93
+ return 'scanner_failed'
94
+ }
95
+
96
+ /**
97
+ * The stream's text, or `null` once it passes `limit` bytes (when `overflow` runs) or `halt` aborts: a process the run
98
+ * killed may have left a child holding the pipe open.
99
+ */
100
+ async function bounded(stream: ReadableStream<Uint8Array>, limit: number, overflow: () => void, halt: AbortSignal): Promise<string | null> {
101
+ const decoder = new TextDecoder()
102
+ let text = ''
103
+ let length = 0
104
+ const reader = stream.getReader()
105
+ const halted = new Promise<null>((resolve) => halt.addEventListener('abort', () => resolve(null), { once: true }))
106
+ const read = () => Promise.race([reader.read(), halted])
107
+ for (let next = await read(); next !== null && !next.done; next = await read()) {
108
+ length += next.value.byteLength
109
+ if (length > limit) {
110
+ overflow()
111
+ await reader.cancel()
112
+ return null
113
+ }
114
+ text += decoder.decode(next.value, { stream: true })
115
+ }
116
+ if (halt.aborted) {
117
+ await reader.cancel()
118
+ return null
119
+ }
120
+ return text + decoder.decode()
121
+ }
122
+
123
+ /** `promise`'s value, or `null` once `signal` aborts first: a read that never answers does not hold the run. */
124
+ function unlessAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T | null> {
125
+ if (signal.aborted) return Promise.resolve(null)
126
+ return new Promise((resolve, reject) => {
127
+ const stop = () => resolve(null)
128
+ signal.addEventListener('abort', stop, { once: true })
129
+ promise.then(
130
+ (value) => {
131
+ signal.removeEventListener('abort', stop)
132
+ resolve(value)
133
+ },
134
+ (error: unknown) => {
135
+ signal.removeEventListener('abort', stop)
136
+ reject(error)
137
+ },
138
+ )
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Streams `body` to `path`; `false` when `signal` aborted it, which cancels the reader. A reader and a file sink:
144
+ * `Bun.write` of a stream that errors never settles (Bun 1.3).
145
+ */
146
+ async function writeStream(path: string, body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<boolean> {
147
+ const sink = Bun.file(path).writer()
148
+ const reader = body.getReader()
149
+ try {
150
+ for (let next = await unlessAborted(reader.read(), signal); next !== null; next = await unlessAborted(reader.read(), signal)) {
151
+ if (next.done) return true
152
+ sink.write(next.value)
153
+ }
154
+ return false
155
+ } finally {
156
+ await reader.cancel().catch(() => undefined)
157
+ await sink.end()
158
+ }
159
+ }
160
+
161
+ /** Writes each object to `<directory>/<id>`; a file whose object could not be read in full is left out. */
162
+ async function write(directory: string, objects: readonly ScanObject[], signal: AbortSignal): Promise<Map<string, string>> {
163
+ const paths = new Map<string, string>()
164
+ for (const object of objects) {
165
+ if (signal.aborted) break
166
+ if (!UUID.test(object.id)) continue
167
+ const path = join(directory, object.id)
168
+ try {
169
+ const body = await unlessAborted(object.open(signal), signal)
170
+ if (body !== null && (await writeStream(path, body, signal))) paths.set(object.id, path)
171
+ } catch {
172
+ await rm(path, { force: true })
173
+ }
174
+ }
175
+ return paths
176
+ }
177
+
178
+ interface Finished {
179
+ readonly exitCode: number
180
+ readonly stdout: string
181
+ readonly stderr: string
182
+ }
183
+
184
+ /** One clamscan run, stopped on `signal` or once its output passes the bound; never started once `signal` aborted. */
185
+ async function run(binary: string, args: readonly string[], signal: AbortSignal): Promise<Finished | ScanFailure> {
186
+ if (signal.aborted) return 'timeout'
187
+ let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
188
+ try {
189
+ // Only PATH: the job's own environment carries the application's secrets.
190
+ child = Bun.spawn([binary, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: { PATH: process.env['PATH'] ?? '/usr/bin:/bin' } })
191
+ } catch {
192
+ return 'scanner_missing'
193
+ }
194
+ let stopped = false
195
+ const halt = new AbortController()
196
+ const stop = (): void => {
197
+ stopped = true
198
+ child.kill('SIGKILL')
199
+ halt.abort()
200
+ }
201
+ signal.addEventListener('abort', stop, { once: true })
202
+ try {
203
+ let overflowed = false
204
+ const overflow = (): void => {
205
+ overflowed = true
206
+ child.kill('SIGKILL')
207
+ halt.abort()
208
+ }
209
+ const [stdout, stderr, exitCode] = await Promise.all([
210
+ bounded(child.stdout, CLAMSCAN_LIMITS.maxOutputBytes, overflow, halt.signal),
211
+ bounded(child.stderr, CLAMSCAN_LIMITS.maxOutputBytes, overflow, halt.signal),
212
+ child.exited,
213
+ ])
214
+ if (overflowed) {
215
+ return 'output_overflow'
216
+ }
217
+ if (stopped || stdout === null || stderr === null) {
218
+ return 'timeout'
219
+ }
220
+ return { exitCode, stdout, stderr }
221
+ } finally {
222
+ signal.removeEventListener('abort', stop)
223
+ }
224
+ }
225
+
226
+ /** ClamAV's `clamscan` as the scan pass's scanner. Fails closed: see `ScanFailure` for what makes a batch fail. */
227
+ export function clamscanScanner(options: ClamscanOptions = {}): FileScanner {
228
+ const binary = options.binary ?? 'clamscan'
229
+ const timeoutMs = options.timeoutMs ?? SCAN_LIMITS.scanTimeoutMs
230
+ return {
231
+ async scan(objects, signal) {
232
+ // One deadline over the whole run: reading the objects, writing them, the scan and the cleanup.
233
+ const deadline = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
234
+ const directory = await mkdtemp(join(options.directory ?? tmpdir(), 'fabrika-scan-'))
235
+ try {
236
+ const paths = await write(directory, objects, deadline)
237
+ if (deadline.aborted) {
238
+ return { ok: false, failure: 'timeout' }
239
+ }
240
+ if (paths.size === 0) {
241
+ return { ok: true, verdicts: new Map() }
242
+ }
243
+ const args = [...clamscanArguments(options.database, options.maxFileBytes), ...paths.values()]
244
+ const finished = await run(binary, args, deadline)
245
+ return typeof finished === 'string'
246
+ ? { ok: false, failure: finished }
247
+ : clamscanVerdicts(paths, finished.exitCode, finished.stdout, finished.stderr)
248
+ } finally {
249
+ await rm(directory, { recursive: true, force: true })
250
+ }
251
+ },
252
+ }
253
+ }