@mikeargento/bitgraph-audit 0.1.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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/anchors.d.ts +31 -0
- package/dist/anchors.d.ts.map +1 -0
- package/dist/anchors.js +116 -0
- package/dist/anchors.js.map +1 -0
- package/dist/anomalies.d.ts +42 -0
- package/dist/anomalies.d.ts.map +1 -0
- package/dist/anomalies.js +642 -0
- package/dist/anomalies.js.map +1 -0
- package/dist/attestation.d.ts +20 -0
- package/dist/attestation.d.ts.map +1 -0
- package/dist/attestation.js +795 -0
- package/dist/attestation.js.map +1 -0
- package/dist/audit.d.ts +19 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +116 -0
- package/dist/audit.js.map +1 -0
- package/dist/authority.d.ts +37 -0
- package/dist/authority.d.ts.map +1 -0
- package/dist/authority.js +126 -0
- package/dist/authority.js.map +1 -0
- package/dist/aws-nitro-root-ca.d.ts +24 -0
- package/dist/aws-nitro-root-ca.d.ts.map +1 -0
- package/dist/aws-nitro-root-ca.js +44 -0
- package/dist/aws-nitro-root-ca.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/contents-hash.d.ts +30 -0
- package/dist/contents-hash.d.ts.map +1 -0
- package/dist/contents-hash.js +73 -0
- package/dist/contents-hash.js.map +1 -0
- package/dist/export.d.ts +64 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +192 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +36 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +828 -0
- package/dist/ingest.js.map +1 -0
- package/dist/reconstruct.d.ts +40 -0
- package/dist/reconstruct.d.ts.map +1 -0
- package/dist/reconstruct.js +400 -0
- package/dist/reconstruct.js.map +1 -0
- package/dist/report-json.d.ts +3 -0
- package/dist/report-json.d.ts.map +1 -0
- package/dist/report-json.js +290 -0
- package/dist/report-json.js.map +1 -0
- package/dist/report-md.d.ts +3 -0
- package/dist/report-md.d.ts.map +1 -0
- package/dist/report-md.js +853 -0
- package/dist/report-md.js.map +1 -0
- package/dist/rlp.d.ts +37 -0
- package/dist/rlp.d.ts.map +1 -0
- package/dist/rlp.js +120 -0
- package/dist/rlp.js.map +1 -0
- package/dist/tar.d.ts +47 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +483 -0
- package/dist/tar.js.map +1 -0
- package/dist/temporal.d.ts +55 -0
- package/dist/temporal.d.ts.map +1 -0
- package/dist/temporal.js +461 -0
- package/dist/temporal.js.map +1 -0
- package/dist/types.d.ts +1389 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validity.d.ts +21 -0
- package/dist/validity.d.ts.map +1 -0
- package/dist/validity.js +86 -0
- package/dist/validity.js.map +1 -0
- package/dist/verify-tiers.d.ts +11 -0
- package/dist/verify-tiers.d.ts.map +1 -0
- package/dist/verify-tiers.js +120 -0
- package/dist/verify-tiers.js.map +1 -0
- package/dist/witness.d.ts +25 -0
- package/dist/witness.d.ts.map +1 -0
- package/dist/witness.js +308 -0
- package/dist/witness.js.map +1 -0
- package/package.json +42 -0
- package/src/anchors.ts +168 -0
- package/src/anomalies.ts +773 -0
- package/src/attestation.ts +878 -0
- package/src/audit.ts +136 -0
- package/src/authority.ts +165 -0
- package/src/aws-nitro-root-ca.ts +47 -0
- package/src/cli.ts +284 -0
- package/src/contents-hash.ts +95 -0
- package/src/export.ts +278 -0
- package/src/index.ts +122 -0
- package/src/ingest.ts +967 -0
- package/src/reconstruct.ts +491 -0
- package/src/report-json.ts +331 -0
- package/src/report-md.ts +1067 -0
- package/src/rlp.ts +142 -0
- package/src/tar.ts +587 -0
- package/src/temporal.ts +629 -0
- package/src/types.ts +1528 -0
- package/src/validity.ts +90 -0
- package/src/verify-tiers.ts +137 -0
- package/src/witness.ts +407 -0
package/src/tar.ts
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal streaming tar reader.
|
|
5
|
+
*
|
|
6
|
+
* Coverage, per docs/BUNDLE-FORMAT.md section 4: POSIX ustar headers, PAX
|
|
7
|
+
* extended headers (typeflags 'x' and 'g'; the 'path' and 'size' keywords
|
|
8
|
+
* are applied, other keywords are parsed and ignored), and GNU long-name
|
|
9
|
+
* entries (typeflag 'L'; 'K' long-linkname entries are consumed and
|
|
10
|
+
* discarded because links are not bundle entries). GNU base-256 numeric
|
|
11
|
+
* fields are supported for large sizes.
|
|
12
|
+
*
|
|
13
|
+
* Design constraints:
|
|
14
|
+
* - Pure streaming over an AsyncIterable of byte chunks. Nothing is
|
|
15
|
+
* written to disk and no entry is buffered by this module; entry bodies
|
|
16
|
+
* are exposed as async chunk generators the caller consumes (or
|
|
17
|
+
* abandons; unconsumed bytes are skipped automatically).
|
|
18
|
+
* - Only regular-file entries carry content of interest. Directories,
|
|
19
|
+
* symlinks, hard links, devices, and FIFOs are yielded as kind "other"
|
|
20
|
+
* so callers can count them, but their bodies are never interpreted and
|
|
21
|
+
* links are never followed or dereferenced.
|
|
22
|
+
* - Corrupt structure (bad checksum, truncation, malformed PAX records)
|
|
23
|
+
* throws; a corrupt container is not a readable bundle.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const BLOCK = 512;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resource caps for the streaming reader, defended against untrusted
|
|
30
|
+
* archives (decompression bombs, absurd declared sizes, entry floods).
|
|
31
|
+
* When any cap is exceeded the reader throws so the caller aborts cleanly
|
|
32
|
+
* instead of exhausting memory.
|
|
33
|
+
*/
|
|
34
|
+
export interface TarLimits {
|
|
35
|
+
/** Ceiling on total bytes pulled from the underlying (decompressed) stream. */
|
|
36
|
+
maxTotalBytes: number;
|
|
37
|
+
/** Ceiling on the number of header blocks processed. */
|
|
38
|
+
maxEntryCount: number;
|
|
39
|
+
/** Ceiling on a single metadata entry (PAX x/g, GNU L) buffered whole. */
|
|
40
|
+
maxMetadataEntryBytes: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TarEntry {
|
|
44
|
+
/** Entry path exactly as recorded (after PAX / GNU long-name resolution). Not normalized. */
|
|
45
|
+
path: string;
|
|
46
|
+
/** Content size in bytes. */
|
|
47
|
+
size: number;
|
|
48
|
+
/** "file" for regular files; "other" for everything else. */
|
|
49
|
+
kind: "file" | "other";
|
|
50
|
+
/** Raw typeflag character. */
|
|
51
|
+
typeflag: string;
|
|
52
|
+
/**
|
|
53
|
+
* Content chunks. Valid only until the next entry is requested from the
|
|
54
|
+
* outer generator; any unconsumed remainder is skipped automatically.
|
|
55
|
+
*/
|
|
56
|
+
body: AsyncGenerator<Uint8Array, void, void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read tar entries sequentially from a byte stream.
|
|
61
|
+
*
|
|
62
|
+
* When `limits` is supplied, the reader enforces caps against untrusted
|
|
63
|
+
* archives: a total decompressed-byte ceiling, a maximum entry count, and
|
|
64
|
+
* a maximum size for metadata entries buffered whole (PAX headers and GNU
|
|
65
|
+
* long names). Exceeding any cap throws so the caller aborts cleanly.
|
|
66
|
+
*/
|
|
67
|
+
export async function* readTarEntries(
|
|
68
|
+
source: AsyncIterable<Uint8Array>,
|
|
69
|
+
limits?: TarLimits
|
|
70
|
+
): AsyncGenerator<TarEntry, void, void> {
|
|
71
|
+
const reader = new ChunkReader(source, limits?.maxTotalBytes);
|
|
72
|
+
const maxEntryCount = limits?.maxEntryCount ?? Number.POSITIVE_INFINITY;
|
|
73
|
+
const maxMetadataEntryBytes = limits?.maxMetadataEntryBytes ?? Number.POSITIVE_INFINITY;
|
|
74
|
+
let pendingPax: Map<string, string> | null = null;
|
|
75
|
+
let globalPax: Map<string, string> | null = null;
|
|
76
|
+
let pendingLongName: string | null = null;
|
|
77
|
+
let entryCount = 0;
|
|
78
|
+
|
|
79
|
+
const guardMetadata = (size: number, kind: string): void => {
|
|
80
|
+
if (size > maxMetadataEntryBytes) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`tar: ${kind} metadata entry of ${size} bytes exceeds the ${maxMetadataEntryBytes}-byte cap`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (;;) {
|
|
88
|
+
const block = await reader.readExact(BLOCK);
|
|
89
|
+
if (block === null) {
|
|
90
|
+
// EOF at a block boundary without the two-zero-block trailer.
|
|
91
|
+
// Tolerated: everything read so far was structurally complete.
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (isZeroBlock(block)) {
|
|
95
|
+
// End-of-archive marker. The second zero block and any trailing
|
|
96
|
+
// padding are irrelevant.
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (++entryCount > maxEntryCount) {
|
|
101
|
+
throw new Error(`tar: archive exceeds the maximum of ${maxEntryCount} entries`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const header = parseHeader(block);
|
|
105
|
+
const typeflag = header.typeflag;
|
|
106
|
+
|
|
107
|
+
// --- Metadata entries that modify the NEXT real entry ---------------
|
|
108
|
+
if (typeflag === "x" || typeflag === "g") {
|
|
109
|
+
guardMetadata(header.size, "PAX");
|
|
110
|
+
const data = await readAll(reader, header.size);
|
|
111
|
+
await reader.skip(padOf(header.size));
|
|
112
|
+
const records = parsePaxRecords(data);
|
|
113
|
+
if (typeflag === "x") {
|
|
114
|
+
pendingPax = records;
|
|
115
|
+
} else {
|
|
116
|
+
globalPax = mergePax(globalPax, records);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (typeflag === "L") {
|
|
121
|
+
guardMetadata(header.size, "GNU long-name");
|
|
122
|
+
const data = await readAll(reader, header.size);
|
|
123
|
+
await reader.skip(padOf(header.size));
|
|
124
|
+
pendingLongName = decodeString(stripTrailingNuls(data));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (typeflag === "K") {
|
|
128
|
+
// GNU long linkname. Links are not bundle entries; discard.
|
|
129
|
+
await reader.skip(header.size + padOf(header.size));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Real entry ------------------------------------------------------
|
|
134
|
+
let path = header.name;
|
|
135
|
+
if (globalPax !== null && globalPax.has("path")) path = globalPax.get("path") as string;
|
|
136
|
+
if (pendingLongName !== null) path = pendingLongName;
|
|
137
|
+
if (pendingPax !== null && pendingPax.has("path")) path = pendingPax.get("path") as string;
|
|
138
|
+
|
|
139
|
+
let size = header.size;
|
|
140
|
+
const paxSize = pendingPax?.get("size") ?? globalPax?.get("size");
|
|
141
|
+
if (paxSize !== undefined) {
|
|
142
|
+
const parsed = Number(paxSize);
|
|
143
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
144
|
+
throw new Error("tar: invalid PAX size value");
|
|
145
|
+
}
|
|
146
|
+
size = parsed;
|
|
147
|
+
}
|
|
148
|
+
pendingPax = null;
|
|
149
|
+
pendingLongName = null;
|
|
150
|
+
|
|
151
|
+
const isFile = typeflag === "0" || typeflag === "\0";
|
|
152
|
+
const state = { remaining: size };
|
|
153
|
+
const body = bodyGenerator(reader, state);
|
|
154
|
+
|
|
155
|
+
yield {
|
|
156
|
+
path,
|
|
157
|
+
size,
|
|
158
|
+
kind: isFile ? "file" : "other",
|
|
159
|
+
typeflag,
|
|
160
|
+
body,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Skip whatever the consumer left unread, plus block padding.
|
|
164
|
+
if (state.remaining > 0) {
|
|
165
|
+
await reader.skip(state.remaining);
|
|
166
|
+
state.remaining = 0;
|
|
167
|
+
}
|
|
168
|
+
await reader.skip(padOf(size));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Body streaming
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
async function* bodyGenerator(
|
|
177
|
+
reader: ChunkReader,
|
|
178
|
+
state: { remaining: number }
|
|
179
|
+
): AsyncGenerator<Uint8Array, void, void> {
|
|
180
|
+
while (state.remaining > 0) {
|
|
181
|
+
const chunk = await reader.readSome(state.remaining);
|
|
182
|
+
if (chunk === null) {
|
|
183
|
+
throw new Error("tar: truncated archive (unexpected end of stream inside entry body)");
|
|
184
|
+
}
|
|
185
|
+
state.remaining -= chunk.length;
|
|
186
|
+
yield chunk;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function readAll(reader: ChunkReader, size: number): Promise<Uint8Array> {
|
|
191
|
+
if (size === 0) return new Uint8Array(0);
|
|
192
|
+
const data = await reader.readExact(size);
|
|
193
|
+
if (data === null) {
|
|
194
|
+
throw new Error("tar: truncated archive (unexpected end of stream)");
|
|
195
|
+
}
|
|
196
|
+
return data;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Header parsing
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
interface RawHeader {
|
|
204
|
+
name: string;
|
|
205
|
+
size: number;
|
|
206
|
+
typeflag: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseHeader(block: Uint8Array): RawHeader {
|
|
210
|
+
verifyChecksum(block);
|
|
211
|
+
|
|
212
|
+
const rawType = block[156] as number;
|
|
213
|
+
const typeflag = rawType === 0 ? "\0" : String.fromCharCode(rawType);
|
|
214
|
+
|
|
215
|
+
let name = decodeString(fieldBytes(block, 0, 100));
|
|
216
|
+
const magic = decodeString(fieldBytes(block, 257, 6));
|
|
217
|
+
if (magic === "ustar") {
|
|
218
|
+
const prefix = decodeString(fieldBytes(block, 345, 155));
|
|
219
|
+
if (prefix.length > 0) {
|
|
220
|
+
name = `${prefix}/${name}`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const size = parseNumeric(block.subarray(124, 136));
|
|
225
|
+
|
|
226
|
+
return { name, size, typeflag };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Extract a NUL-terminated field. */
|
|
230
|
+
function fieldBytes(block: Uint8Array, offset: number, length: number): Uint8Array {
|
|
231
|
+
const raw = block.subarray(offset, offset + length);
|
|
232
|
+
let end = raw.indexOf(0);
|
|
233
|
+
if (end === -1) end = raw.length;
|
|
234
|
+
return raw.subarray(0, end);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Parse a tar numeric field: ASCII octal (NUL/space padded), or GNU
|
|
239
|
+
* base-256 when the high bit of the first byte is set.
|
|
240
|
+
*/
|
|
241
|
+
function parseNumeric(field: Uint8Array): number {
|
|
242
|
+
if (field.length > 0 && ((field[0] as number) & 0x80) !== 0) {
|
|
243
|
+
// GNU base-256: big-endian binary, first byte's low 7 bits included.
|
|
244
|
+
let value = 0n;
|
|
245
|
+
for (let i = 0; i < field.length; i++) {
|
|
246
|
+
const byte = i === 0 ? (field[0] as number) & 0x7f : (field[i] as number);
|
|
247
|
+
value = (value << 8n) | BigInt(byte);
|
|
248
|
+
}
|
|
249
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
250
|
+
throw new Error("tar: numeric field exceeds the supported range");
|
|
251
|
+
}
|
|
252
|
+
return Number(value);
|
|
253
|
+
}
|
|
254
|
+
const text = decodeString(field).trim().replace(/\0+$/, "").trim();
|
|
255
|
+
if (text.length === 0) return 0;
|
|
256
|
+
const value = Number.parseInt(text, 8);
|
|
257
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
258
|
+
throw new Error(`tar: invalid octal numeric field: "${text}"`);
|
|
259
|
+
}
|
|
260
|
+
return value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function verifyChecksum(block: Uint8Array): void {
|
|
264
|
+
const stored = parseNumeric(block.subarray(148, 156));
|
|
265
|
+
let sum = 0;
|
|
266
|
+
for (let i = 0; i < BLOCK; i++) {
|
|
267
|
+
sum += i >= 148 && i < 156 ? 0x20 : (block[i] as number);
|
|
268
|
+
}
|
|
269
|
+
if (sum !== stored) {
|
|
270
|
+
throw new Error("tar: header checksum mismatch (corrupt or not a tar archive)");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isZeroBlock(block: Uint8Array): boolean {
|
|
275
|
+
for (let i = 0; i < block.length; i++) {
|
|
276
|
+
if ((block[i] as number) !== 0) return false;
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// PAX records
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parse PAX extended-header data: a sequence of "len key=value\n" records
|
|
287
|
+
* where len is the decimal byte length of the whole record including the
|
|
288
|
+
* digits, the space, and the trailing newline.
|
|
289
|
+
*/
|
|
290
|
+
function parsePaxRecords(data: Uint8Array): Map<string, string> {
|
|
291
|
+
const records = new Map<string, string>();
|
|
292
|
+
let offset = 0;
|
|
293
|
+
while (offset < data.length) {
|
|
294
|
+
let cursor = offset;
|
|
295
|
+
while (cursor < data.length && (data[cursor] as number) !== 0x20) cursor++;
|
|
296
|
+
if (cursor >= data.length) {
|
|
297
|
+
throw new Error("tar: malformed PAX record (no length delimiter)");
|
|
298
|
+
}
|
|
299
|
+
const length = Number(decodeString(data.subarray(offset, cursor)));
|
|
300
|
+
if (!Number.isInteger(length) || length <= 0 || offset + length > data.length) {
|
|
301
|
+
throw new Error("tar: malformed PAX record (bad length)");
|
|
302
|
+
}
|
|
303
|
+
const record = data.subarray(cursor + 1, offset + length);
|
|
304
|
+
if (record.length === 0 || (record[record.length - 1] as number) !== 0x0a) {
|
|
305
|
+
throw new Error("tar: malformed PAX record (missing newline)");
|
|
306
|
+
}
|
|
307
|
+
const text = decodeString(record.subarray(0, record.length - 1));
|
|
308
|
+
const eq = text.indexOf("=");
|
|
309
|
+
if (eq === -1) {
|
|
310
|
+
throw new Error("tar: malformed PAX record (missing '=')");
|
|
311
|
+
}
|
|
312
|
+
records.set(text.slice(0, eq), text.slice(eq + 1));
|
|
313
|
+
offset += length;
|
|
314
|
+
}
|
|
315
|
+
return records;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function mergePax(
|
|
319
|
+
base: Map<string, string> | null,
|
|
320
|
+
overlay: Map<string, string>
|
|
321
|
+
): Map<string, string> {
|
|
322
|
+
const merged = new Map(base ?? []);
|
|
323
|
+
for (const [key, value] of overlay) merged.set(key, value);
|
|
324
|
+
return merged;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Byte helpers
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function decodeString(bytes: Uint8Array): string {
|
|
332
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function stripTrailingNuls(bytes: Uint8Array): Uint8Array {
|
|
336
|
+
let end = bytes.length;
|
|
337
|
+
while (end > 0 && (bytes[end - 1] as number) === 0) end--;
|
|
338
|
+
return bytes.subarray(0, end);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Pull-based reader over an async chunk stream.
|
|
343
|
+
*/
|
|
344
|
+
class ChunkReader {
|
|
345
|
+
private readonly iter: AsyncIterator<Uint8Array>;
|
|
346
|
+
private chunks: Uint8Array[] = [];
|
|
347
|
+
private head = 0;
|
|
348
|
+
private available = 0;
|
|
349
|
+
private ended = false;
|
|
350
|
+
private totalPulled = 0;
|
|
351
|
+
private readonly maxTotalBytes: number;
|
|
352
|
+
|
|
353
|
+
constructor(source: AsyncIterable<Uint8Array>, maxTotalBytes?: number) {
|
|
354
|
+
this.iter = source[Symbol.asyncIterator]();
|
|
355
|
+
this.maxTotalBytes = maxTotalBytes ?? Number.POSITIVE_INFINITY;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private async fill(): Promise<boolean> {
|
|
359
|
+
if (this.ended) return false;
|
|
360
|
+
const result = await this.iter.next();
|
|
361
|
+
if (result.done === true) {
|
|
362
|
+
this.ended = true;
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
const chunk = result.value;
|
|
366
|
+
if (chunk.length > 0) {
|
|
367
|
+
this.totalPulled += chunk.length;
|
|
368
|
+
if (this.totalPulled > this.maxTotalBytes) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`tar: archive exceeds the maximum decompressed size of ${this.maxTotalBytes} bytes`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
this.chunks.push(chunk);
|
|
374
|
+
this.available += chunk.length;
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Read exactly n bytes. Returns null on a clean EOF with nothing
|
|
381
|
+
* buffered; throws if the stream ends mid-read.
|
|
382
|
+
*/
|
|
383
|
+
async readExact(n: number): Promise<Uint8Array | null> {
|
|
384
|
+
while (this.available < n) {
|
|
385
|
+
const more = await this.fill();
|
|
386
|
+
if (!more) {
|
|
387
|
+
if (this.available === 0) return null;
|
|
388
|
+
throw new Error("tar: truncated archive (unexpected end of stream)");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return this.take(n);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Read between 1 and max bytes. Returns null at EOF. */
|
|
395
|
+
async readSome(max: number): Promise<Uint8Array | null> {
|
|
396
|
+
while (this.available === 0) {
|
|
397
|
+
const more = await this.fill();
|
|
398
|
+
if (!more) return null;
|
|
399
|
+
}
|
|
400
|
+
const first = this.chunks[0] as Uint8Array;
|
|
401
|
+
const take = Math.min(max, first.length - this.head);
|
|
402
|
+
const out = first.subarray(this.head, this.head + take);
|
|
403
|
+
this.advance(take, first);
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async skip(n: number): Promise<void> {
|
|
408
|
+
let remaining = n;
|
|
409
|
+
while (remaining > 0) {
|
|
410
|
+
const chunk = await this.readSome(remaining);
|
|
411
|
+
if (chunk === null) {
|
|
412
|
+
throw new Error("tar: truncated archive (unexpected end of stream)");
|
|
413
|
+
}
|
|
414
|
+
remaining -= chunk.length;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private take(n: number): Uint8Array {
|
|
419
|
+
const out = new Uint8Array(n);
|
|
420
|
+
let copied = 0;
|
|
421
|
+
while (copied < n) {
|
|
422
|
+
const first = this.chunks[0] as Uint8Array;
|
|
423
|
+
const take = Math.min(n - copied, first.length - this.head);
|
|
424
|
+
out.set(first.subarray(this.head, this.head + take), copied);
|
|
425
|
+
copied += take;
|
|
426
|
+
this.advance(take, first);
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private advance(consumed: number, first: Uint8Array): void {
|
|
432
|
+
this.head += consumed;
|
|
433
|
+
this.available -= consumed;
|
|
434
|
+
if (this.head === first.length) {
|
|
435
|
+
this.chunks.shift();
|
|
436
|
+
this.head = 0;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function padOf(size: number): number {
|
|
442
|
+
return (BLOCK - (size % BLOCK)) % BLOCK;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Minimal deterministic tar writer
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
//
|
|
449
|
+
// The producer counterpart of the reader above, used by the bundle archive
|
|
450
|
+
// builder (export.ts). Deliberately minimal and deterministic:
|
|
451
|
+
//
|
|
452
|
+
// - Regular-file entries only. No directories, links, or special files.
|
|
453
|
+
// - Fixed header metadata: mode 0644, uid/gid 0, mtime 0, empty
|
|
454
|
+
// uname/gname. The same entry list always produces the same bytes.
|
|
455
|
+
// - Long paths use the ustar prefix field when the path splits cleanly
|
|
456
|
+
// (name <= 100 bytes, prefix <= 155 bytes), and a PAX 'x' path record
|
|
457
|
+
// otherwise. Both forms are read back by the reader above and required
|
|
458
|
+
// of consumers by docs/BUNDLE-FORMAT.md section 4.
|
|
459
|
+
// - Entries are written in the order given; ordering policy (the bundle
|
|
460
|
+
// builder sorts by raw UTF-8 path bytes) belongs to the caller.
|
|
461
|
+
|
|
462
|
+
/** A regular-file entry to be written. */
|
|
463
|
+
export interface TarWriteFile {
|
|
464
|
+
/** Bundle-root-relative path, "/" separators. Validated by the caller. */
|
|
465
|
+
path: string;
|
|
466
|
+
content: Uint8Array;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Build a complete tar archive (ustar + PAX long paths) in memory. */
|
|
470
|
+
export function writeTarArchive(files: TarWriteFile[]): Uint8Array {
|
|
471
|
+
const parts: Uint8Array[] = [];
|
|
472
|
+
for (const file of files) {
|
|
473
|
+
const pathBytes = new TextEncoder().encode(file.path);
|
|
474
|
+
if (pathBytes.length <= 100) {
|
|
475
|
+
parts.push(writeHeader(file.path, "", file.content.length, "0"));
|
|
476
|
+
} else {
|
|
477
|
+
const split = splitUstarPath(file.path, pathBytes);
|
|
478
|
+
if (split !== null) {
|
|
479
|
+
parts.push(writeHeader(split.name, split.prefix, file.content.length, "0"));
|
|
480
|
+
} else {
|
|
481
|
+
// PAX extended header carrying the full path, then a header whose
|
|
482
|
+
// name field holds a deterministic truncation.
|
|
483
|
+
const record = paxPathRecord(file.path);
|
|
484
|
+
const paxName = truncateBytes(`PaxHeaders.0/${file.path}`, 100);
|
|
485
|
+
parts.push(writeHeader(paxName, "", record.length, "x"), padBlock(record));
|
|
486
|
+
parts.push(writeHeader(truncateBytes(file.path, 100), "", file.content.length, "0"));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
parts.push(padBlock(file.content));
|
|
490
|
+
}
|
|
491
|
+
parts.push(new Uint8Array(BLOCK), new Uint8Array(BLOCK));
|
|
492
|
+
return concatParts(parts);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Split a long path for the ustar prefix field: the rightmost "/" such
|
|
497
|
+
* that the name part fits 100 bytes and the prefix part fits 155 bytes.
|
|
498
|
+
* Returns null when no split works (PAX fallback).
|
|
499
|
+
*/
|
|
500
|
+
function splitUstarPath(
|
|
501
|
+
path: string,
|
|
502
|
+
pathBytes: Uint8Array
|
|
503
|
+
): { prefix: string; name: string } | null {
|
|
504
|
+
for (let i = path.length - 1; i > 0; i--) {
|
|
505
|
+
if (path.charCodeAt(i) !== 0x2f) continue;
|
|
506
|
+
const prefix = path.slice(0, i);
|
|
507
|
+
const name = path.slice(i + 1);
|
|
508
|
+
const prefixLen = new TextEncoder().encode(prefix).length;
|
|
509
|
+
const nameLen = pathBytes.length - prefixLen - 1;
|
|
510
|
+
if (name.length > 0 && nameLen <= 100 && prefixLen <= 155) {
|
|
511
|
+
return { prefix, name };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function writeHeader(name: string, prefix: string, size: number, typeflag: string): Uint8Array {
|
|
518
|
+
const block = new Uint8Array(BLOCK);
|
|
519
|
+
const ascii = (text: string, offset: number, length: number): void => {
|
|
520
|
+
const bytes = new TextEncoder().encode(text);
|
|
521
|
+
block.set(bytes.subarray(0, Math.min(bytes.length, length)), offset);
|
|
522
|
+
};
|
|
523
|
+
ascii(name, 0, 100);
|
|
524
|
+
block.set(octal(0o644, 8), 100); // mode
|
|
525
|
+
block.set(octal(0, 8), 108); // uid
|
|
526
|
+
block.set(octal(0, 8), 116); // gid
|
|
527
|
+
block.set(octal(size, 12), 124); // size
|
|
528
|
+
block.set(octal(0, 12), 136); // mtime, fixed 0 for determinism
|
|
529
|
+
for (let i = 148; i < 156; i++) block[i] = 0x20; // checksum spaces
|
|
530
|
+
block[156] = typeflag.charCodeAt(0);
|
|
531
|
+
ascii("ustar", 257, 6); // magic, NUL-terminated by the zeroed block
|
|
532
|
+
ascii("00", 263, 2); // version
|
|
533
|
+
if (prefix.length > 0) ascii(prefix, 345, 155);
|
|
534
|
+
let sum = 0;
|
|
535
|
+
for (let i = 0; i < BLOCK; i++) sum += block[i] as number;
|
|
536
|
+
ascii(sum.toString(8).padStart(6, "0"), 148, 6);
|
|
537
|
+
block[154] = 0;
|
|
538
|
+
block[155] = 0x20;
|
|
539
|
+
return block;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function octal(value: number, length: number): Uint8Array {
|
|
543
|
+
const out = new Uint8Array(length);
|
|
544
|
+
const text = value.toString(8).padStart(length - 1, "0");
|
|
545
|
+
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i);
|
|
546
|
+
out[length - 1] = 0;
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** PAX record "<len> path=<value>\n" where len counts the whole record. */
|
|
551
|
+
function paxPathRecord(path: string): Uint8Array {
|
|
552
|
+
const baseBytes = new TextEncoder().encode(` path=${path}\n`);
|
|
553
|
+
let length = baseBytes.length + 1;
|
|
554
|
+
while (String(length).length + baseBytes.length !== length) {
|
|
555
|
+
length = String(length).length + baseBytes.length;
|
|
556
|
+
}
|
|
557
|
+
return concatParts([new TextEncoder().encode(String(length)), baseBytes]);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Truncate a string to at most n UTF-8 bytes (paths here are ASCII in practice). */
|
|
561
|
+
function truncateBytes(text: string, n: number): string {
|
|
562
|
+
const bytes = new TextEncoder().encode(text);
|
|
563
|
+
if (bytes.length <= n) return text;
|
|
564
|
+
let end = n;
|
|
565
|
+
// Do not cut inside a multi-byte sequence.
|
|
566
|
+
while (end > 0 && ((bytes[end] as number) & 0xc0) === 0x80) end--;
|
|
567
|
+
return new TextDecoder("utf-8").decode(bytes.subarray(0, end));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function padBlock(content: Uint8Array): Uint8Array {
|
|
571
|
+
const padding = padOf(content.length);
|
|
572
|
+
if (padding === 0) return content;
|
|
573
|
+
const out = new Uint8Array(content.length + padding);
|
|
574
|
+
out.set(content, 0);
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function concatParts(parts: Uint8Array[]): Uint8Array {
|
|
579
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
580
|
+
const out = new Uint8Array(total);
|
|
581
|
+
let offset = 0;
|
|
582
|
+
for (const part of parts) {
|
|
583
|
+
out.set(part, offset);
|
|
584
|
+
offset += part.length;
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
}
|