@kammcs/buildit-mcp 0.8.0 → 0.10.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where a file a session shares may come from: inside the session's
3
+ * working folder or the OS temp folder (by its real path, symlinks
4
+ * resolved), and never from a well-known secret folder, whatever the roots.
5
+ */
6
+ import { realpath } from 'node:fs/promises';
7
+ import { homedir, tmpdir } from 'node:os';
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
+ import { defaultTokenFile } from '../secrets/keychain.js';
10
+ /** Whether paths on this platform compare without case (Windows and macOS). */
11
+ const FOLD = process.platform === 'win32' || process.platform === 'darwin';
12
+ const norm = (p) => (FOLD ? p.toLowerCase() : p);
13
+ /** Whether `child` is `parent` or inside it (both resolved). */
14
+ export function isInside(parent, child) {
15
+ const rel = relative(norm(parent), norm(child));
16
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.startsWith(sep + sep));
17
+ }
18
+ /** The real path of a folder, or the folder itself when it can't be resolved. */
19
+ async function real(folder) {
20
+ return realpath(folder).catch(() => resolve(folder));
21
+ }
22
+ /**
23
+ * The secret folders and files a session never shares from, under the home
24
+ * folder: SSH, cloud and cluster credentials, GnuPG, the Docker login,
25
+ * buildit-mcp's own token file, and the browsers' profiles (passwords and
26
+ * cookies).
27
+ */
28
+ export function secretLocations(home = homedir()) {
29
+ const h = (...parts) => join(home, ...parts);
30
+ return [
31
+ h('.ssh'),
32
+ h('.aws'),
33
+ h('.azure'),
34
+ h('.gnupg'),
35
+ h('.config', 'gcloud'),
36
+ h('.kube'),
37
+ h('.docker', 'config.json'),
38
+ h('.git-credentials'),
39
+ dirname(defaultTokenFile()),
40
+ // Browser profiles.
41
+ h('AppData', 'Local', 'Google', 'Chrome', 'User Data'),
42
+ h('AppData', 'Local', 'Microsoft', 'Edge', 'User Data'),
43
+ h('AppData', 'Local', 'BraveSoftware'),
44
+ h('AppData', 'Roaming', 'Mozilla', 'Firefox'),
45
+ h('AppData', 'Roaming', 'Opera Software'),
46
+ h('Library', 'Application Support', 'Google', 'Chrome'),
47
+ h('Library', 'Application Support', 'Microsoft Edge'),
48
+ h('Library', 'Application Support', 'Firefox'),
49
+ h('Library', 'Application Support', 'BraveSoftware'),
50
+ h('Library', 'Safari'),
51
+ h('Library', 'Cookies'),
52
+ h('Library', 'Keychains'),
53
+ h('.mozilla'),
54
+ h('.config', 'google-chrome'),
55
+ h('.config', 'chromium'),
56
+ h('.config', 'BraveSoftware'),
57
+ h('.local', 'share', 'keyrings'),
58
+ ];
59
+ }
60
+ /** Why a resolved path may not be shared from where it is, or undefined. */
61
+ export async function locationProblem(path, roots) {
62
+ const home = roots.home ?? homedir();
63
+ for (const secret of secretLocations(home)) {
64
+ if (isInside(await real(secret), path) || isInside(secret, path))
65
+ return 'secret_folder';
66
+ }
67
+ const allowed = [await real(roots.folder), await real(tmpdir())];
68
+ return allowed.some((root) => isInside(root, path)) ? undefined : 'outside_roots';
69
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * A video's length and poster frame, for `reply`'s `files`, made with
3
+ * ffprobe and ffmpeg when both are on the PATH. Without them (or when
4
+ * either fails, is slow, or makes a poster over the contract's limits),
5
+ * there is no poster and no length, and the file is shared as before:
6
+ * nothing here ever refuses a file.
7
+ *
8
+ * The programs run safely:
9
+ * - **No shell.** Each is spawned by its absolute path, found in the PATH's
10
+ * absolute folders only (never the current folder, never a `.cmd` or
11
+ * `.bat`, which would need a shell), with an argument array.
12
+ * - **Paths as `file:` URLs** of absolute paths, so a name can't be read as
13
+ * an option or another protocol.
14
+ * - **A timeout** for each run (and the tool call's own signal), after
15
+ * which the program is killed.
16
+ * - **A temporary output**, in a folder the caller removes, written by
17
+ * ffmpeg with no metadata (`-map_metadata -1`), so the poster carries
18
+ * nothing but the frame.
19
+ *
20
+ * The poster is a JPEG of at most 320 px a side (never enlarged) and
21
+ * 64 KiB, as the contract's `thumbnail` asks; a frame that is still over
22
+ * 64 KiB at the lowest quality is dropped.
23
+ */
24
+ import { spawn as nodeSpawn } from 'node:child_process';
25
+ import { constants } from 'node:fs';
26
+ import { access, readFile, rm, stat } from 'node:fs/promises';
27
+ import { delimiter, isAbsolute, join } from 'node:path';
28
+ /** The contract's limits for a video's poster. */
29
+ export const POSTER_MAX_BYTES = 64 * 1024;
30
+ export const POSTER_MAX_SIDE = 320;
31
+ /** The contract's limits for duration_ms. */
32
+ const DURATION_MAX_MS = 86_400_000;
33
+ /** How long ffprobe and ffmpeg may each take. */
34
+ export const PROBE_TIMEOUT_MS = 10_000;
35
+ export const FRAME_TIMEOUT_MS = 20_000;
36
+ /** The JPEG qualities tried in turn (ffmpeg's -q:v, 2 best to 31 worst). */
37
+ const QUALITIES = [4, 8, 14, 22, 31];
38
+ /** At most this much of a program's output is kept. */
39
+ const MAX_STDOUT = 16 * 1024;
40
+ /**
41
+ * The absolute path of `name` in the PATH's absolute folders, or undefined.
42
+ * On Windows only `.exe` and `.com` count (a `.cmd` or `.bat` needs a shell).
43
+ */
44
+ export async function findProgram(name, env = process.env, platform = process.platform) {
45
+ const win = platform === 'win32';
46
+ const raw = (win ? (env.Path ?? env.PATH) : env.PATH) ?? '';
47
+ const dirs = raw
48
+ .split(win ? ';' : delimiter)
49
+ .map((d) => d.trim().replace(/^"(.*)"$/, '$1'))
50
+ .filter((d) => d !== '' && isAbsolute(d));
51
+ const names = win ? [`${name}.exe`, `${name}.com`] : [name];
52
+ for (const dir of dirs) {
53
+ for (const n of names) {
54
+ const candidate = join(dir, n);
55
+ try {
56
+ const info = await stat(candidate);
57
+ if (!info.isFile())
58
+ continue;
59
+ if (!win)
60
+ await access(candidate, constants.X_OK);
61
+ return candidate;
62
+ }
63
+ catch {
64
+ // Not here.
65
+ }
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+ /** Runs a program (no shell) and resolves with its exit code and first output; rejects on a spawn error or timeout. */
71
+ function run(spawn, program, args, timeoutMs, signal) {
72
+ return new Promise((resolve, reject) => {
73
+ const limit = AbortSignal.timeout(timeoutMs);
74
+ const both = signal === undefined ? limit : AbortSignal.any([signal, limit]);
75
+ let child;
76
+ try {
77
+ child = spawn(program, args, {
78
+ shell: false,
79
+ windowsHide: true,
80
+ stdio: ['ignore', 'pipe', 'pipe'],
81
+ signal: both,
82
+ killSignal: 'SIGKILL',
83
+ });
84
+ }
85
+ catch (err) {
86
+ reject(err instanceof Error ? err : new Error('spawn failed'));
87
+ return;
88
+ }
89
+ let out = '';
90
+ child.stdout?.setEncoding('utf8');
91
+ child.stdout?.on('data', (chunk) => {
92
+ if (out.length < MAX_STDOUT)
93
+ out += chunk.slice(0, MAX_STDOUT - out.length);
94
+ });
95
+ // Drained and dropped: its text can name the file.
96
+ child.stderr?.resume();
97
+ let settled = false;
98
+ child.on('error', (err) => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ reject(err);
103
+ });
104
+ child.on('close', (code) => {
105
+ if (settled)
106
+ return;
107
+ settled = true;
108
+ if (both.aborted)
109
+ reject(new Error('timed out'));
110
+ else
111
+ resolve({ code, stdout: out });
112
+ });
113
+ });
114
+ }
115
+ /** A file's path as ffmpeg's `file:` URL protocol takes it (no option or protocol tricks). */
116
+ export function fileArg(path) {
117
+ return `file:${path}`;
118
+ }
119
+ /** The length in ms that ffprobe's `format=duration` gives, within the contract's range. */
120
+ export function parseDuration(stdout) {
121
+ const seconds = Number.parseFloat(stdout.trim().split(/\s+/)[0] ?? '');
122
+ if (!Number.isFinite(seconds) || seconds <= 0)
123
+ return undefined;
124
+ const ms = Math.round(seconds * 1000);
125
+ if (ms < 1 || ms > DURATION_MAX_MS)
126
+ return undefined;
127
+ return ms;
128
+ }
129
+ /** A JPEG's width and height, from its first start-of-frame marker. */
130
+ export function jpegSize(b) {
131
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8)
132
+ return undefined;
133
+ const at = (n) => b[n] ?? 0;
134
+ const u16 = (n) => (at(n) << 8) | at(n + 1);
135
+ let i = 2;
136
+ while (i + 4 <= b.length) {
137
+ if (b[i] !== 0xff)
138
+ return undefined;
139
+ const marker = at(i + 1);
140
+ if (marker === 0xff) {
141
+ i++;
142
+ continue;
143
+ }
144
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
145
+ i += 2;
146
+ continue;
147
+ }
148
+ const len = u16(i + 2);
149
+ if (len < 2)
150
+ return undefined;
151
+ const sof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
152
+ if (sof) {
153
+ if (i + 9 > b.length)
154
+ return undefined;
155
+ const height = u16(i + 5);
156
+ const width = u16(i + 7);
157
+ return width > 0 && height > 0 ? { width, height } : undefined;
158
+ }
159
+ if (marker === 0xda || marker === 0xd9)
160
+ return undefined;
161
+ i += 2 + len;
162
+ }
163
+ return undefined;
164
+ }
165
+ /** ffprobe's arguments for a file's length. */
166
+ export function probeArgs(source) {
167
+ return [
168
+ '-v',
169
+ 'error',
170
+ '-show_entries',
171
+ 'format=duration',
172
+ '-of',
173
+ 'default=noprint_wrappers=1:nokey=1',
174
+ fileArg(source),
175
+ ];
176
+ }
177
+ /** ffmpeg's arguments for one poster frame at `atSeconds`, at most 320 px a side, no metadata. */
178
+ export function frameArgs(source, output, atSeconds, quality) {
179
+ const side = POSTER_MAX_SIDE;
180
+ return [
181
+ '-nostdin',
182
+ '-hide_banner',
183
+ '-loglevel',
184
+ 'error',
185
+ '-ss',
186
+ atSeconds.toFixed(3),
187
+ '-i',
188
+ fileArg(source),
189
+ '-map',
190
+ '0:v:0',
191
+ '-frames:v',
192
+ '1',
193
+ '-an',
194
+ '-sn',
195
+ '-dn',
196
+ '-map_metadata',
197
+ '-1',
198
+ '-vf',
199
+ `scale=w='min(${side},iw)':h='min(${side},ih)':force_original_aspect_ratio=decrease,format=yuvj420p`,
200
+ '-c:v',
201
+ 'mjpeg',
202
+ '-q:v',
203
+ String(quality),
204
+ '-f',
205
+ 'image2',
206
+ '-update',
207
+ '1',
208
+ '-y',
209
+ fileArg(output),
210
+ ];
211
+ }
212
+ /** Where in the video the poster is taken: 1 s in, or a third of a shorter one. */
213
+ export function posterTime(durationMs) {
214
+ if (durationMs === undefined)
215
+ return 0;
216
+ return durationMs > 3000 ? 1 : durationMs / 3000;
217
+ }
218
+ /**
219
+ * The video's length and a poster, written as `poster-<index>.jpg` in the
220
+ * folder `dir` gives (a temporary folder the caller removes; asked for only
221
+ * when both programs are found). Each is undefined when it can't be made;
222
+ * this never throws.
223
+ */
224
+ export async function videoFacts(source, dir, index, options = {}) {
225
+ const spawn = options.spawn ?? nodeSpawn;
226
+ const which = options.which ?? ((name) => findProgram(name));
227
+ const none = { durationMs: undefined, poster: undefined };
228
+ try {
229
+ const [ffprobe, ffmpeg] = await Promise.all([which('ffprobe'), which('ffmpeg')]);
230
+ if (ffprobe === undefined || ffmpeg === undefined)
231
+ return none;
232
+ let durationMs;
233
+ try {
234
+ const probe = await run(spawn, ffprobe, probeArgs(source), PROBE_TIMEOUT_MS, options.signal);
235
+ if (probe.code === 0)
236
+ durationMs = parseDuration(probe.stdout);
237
+ }
238
+ catch {
239
+ // No length; the poster may still be made.
240
+ }
241
+ if (options.signal?.aborted === true)
242
+ return none;
243
+ const output = join(await dir(), `poster-${index}.jpg`);
244
+ const at = posterTime(durationMs);
245
+ const poster = await makePoster(spawn, ffmpeg, source, output, at, options.signal).catch(() => undefined);
246
+ return { durationMs, poster };
247
+ }
248
+ catch {
249
+ return none;
250
+ }
251
+ }
252
+ async function makePoster(spawn, ffmpeg, source, output, at, signal) {
253
+ for (const quality of QUALITIES) {
254
+ await rm(output, { force: true });
255
+ let r = await run(spawn, ffmpeg, frameArgs(source, output, at, quality), FRAME_TIMEOUT_MS, signal);
256
+ let bytes = r.code === 0 ? await readFile(output).catch(() => undefined) : undefined;
257
+ if ((bytes === undefined || bytes.length === 0) && at > 0) {
258
+ // No frame there (a short or odd file): the first frame instead.
259
+ await rm(output, { force: true });
260
+ r = await run(spawn, ffmpeg, frameArgs(source, output, 0, quality), FRAME_TIMEOUT_MS, signal);
261
+ bytes = r.code === 0 ? await readFile(output).catch(() => undefined) : undefined;
262
+ at = 0;
263
+ }
264
+ if (bytes === undefined || bytes.length === 0)
265
+ break;
266
+ if (bytes.length > POSTER_MAX_BYTES)
267
+ continue;
268
+ const size = jpegSize(bytes);
269
+ if (size === undefined || size.width > POSTER_MAX_SIDE || size.height > POSTER_MAX_SIDE)
270
+ break;
271
+ return {
272
+ path: output,
273
+ contentType: 'image/jpeg',
274
+ size: bytes.length,
275
+ width: size.width,
276
+ height: size.height,
277
+ };
278
+ }
279
+ await rm(output, { force: true }).catch(() => undefined);
280
+ return undefined;
281
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The credential scan of a text file a session is about to share: the whole
3
+ * file, streamed in pieces of whole lines, through the tool approvals'
4
+ * credential rules (mask.ts, R1 to R8), before anything is uploaded. The
5
+ * agent API checks the first MiB again with the same rules.
6
+ *
7
+ * It names the lines and the rules' kinds, never a value.
8
+ */
9
+ import { createReadStream } from 'node:fs';
10
+ import { StringDecoder } from 'node:string_decoder';
11
+ import { findCredentials } from '../channel/mask.js';
12
+ /** The most lines a finding names. */
13
+ export const FINDING_MAX_LINES = 20;
14
+ /** How much text is scanned at once (whole lines; one longer line is scanned on its own). */
15
+ const PIECE_CHARS = 1024 * 1024;
16
+ /** A line longer than this is scanned in pieces (a minified file). */
17
+ const LINE_MAX_CHARS = 8 * 1024 * 1024;
18
+ /** What a piece of text adds to the finding, and how many lines it held. */
19
+ function scanPiece(text, firstLine, into) {
20
+ const found = findCredentials(text, firstLine);
21
+ if (found !== undefined) {
22
+ for (const line of found.lines)
23
+ into.lines.add(line);
24
+ for (const kind of found.kinds)
25
+ into.kinds.add(kind);
26
+ }
27
+ // Lines as the rules count them: CR LF, CR and the Unicode separators end a line.
28
+ return lineBreaks(text);
29
+ }
30
+ /** How many line breaks a text holds: LF, CR LF and CR, and U+2028 and U+2029. */
31
+ export function lineBreaks(text) {
32
+ let n = 0;
33
+ for (let i = 0; i < text.length; i++) {
34
+ const c = text.charCodeAt(i);
35
+ if (c === 10 || c === 0x2028 || c === 0x2029)
36
+ n++;
37
+ else if (c === 13 && text.charCodeAt(i + 1) !== 10)
38
+ n++;
39
+ }
40
+ return n;
41
+ }
42
+ /** Scans the file at `path` whole; undefined when nothing looks like a credential. */
43
+ export async function scanFileForCredentials(path, signal) {
44
+ const decoder = new StringDecoder('utf8');
45
+ const found = { lines: new Set(), kinds: new Set() };
46
+ let pending = '';
47
+ let line = 1;
48
+ /** Scans the whole lines waiting (all of it at the end), keeping a partial last line. */
49
+ const flush = (end) => {
50
+ let cut = end ? pending.length : pending.lastIndexOf('\n') + 1;
51
+ if (!end && cut === 0) {
52
+ // One very long line: scanned in pieces, never cutting a CR LF in two.
53
+ if (pending.length <= LINE_MAX_CHARS)
54
+ return;
55
+ cut = pending.charCodeAt(LINE_MAX_CHARS - 1) === 13 ? LINE_MAX_CHARS - 1 : LINE_MAX_CHARS;
56
+ }
57
+ if (cut === 0)
58
+ return;
59
+ line += scanPiece(pending.slice(0, cut), line, found);
60
+ pending = pending.slice(cut);
61
+ };
62
+ const stream = createReadStream(path, {
63
+ highWaterMark: 256 * 1024,
64
+ ...(signal ? { signal } : {}),
65
+ });
66
+ for await (const chunk of stream) {
67
+ pending += decoder.write(chunk);
68
+ if (pending.length >= PIECE_CHARS)
69
+ flush(false);
70
+ }
71
+ pending += decoder.end();
72
+ flush(true);
73
+ if (found.kinds.size === 0)
74
+ return undefined;
75
+ const order = [
76
+ 'private_key',
77
+ 'authorization_header',
78
+ 'bearer_token',
79
+ 'url_password',
80
+ 'credential_option',
81
+ 'credential_assignment',
82
+ 'api_key',
83
+ 'jwt',
84
+ ];
85
+ return {
86
+ lines: [...found.lines].sort((a, b) => a - b).slice(0, FINDING_MAX_LINES),
87
+ kinds: order.filter((k) => found.kinds.has(k)),
88
+ };
89
+ }
90
+ /** A finding in plain words: "line 12 looks like an API key". */
91
+ export function describeFinding(f) {
92
+ const what = f.kinds.map((k) => KIND_WORDS[k] ?? k.replace(/_/g, ' ')).join(', ');
93
+ if (f.lines.length === 0)
94
+ return `it looks like it holds ${what}`;
95
+ if (f.lines.length === 1)
96
+ return `line ${f.lines[0]} looks like ${what}`;
97
+ return `lines ${f.lines.join(', ')} look like ${what}`;
98
+ }
99
+ const KIND_WORDS = {
100
+ private_key: 'a private key',
101
+ authorization_header: 'an Authorization header',
102
+ bearer_token: 'a bearer token',
103
+ url_password: 'a password in a URL',
104
+ credential_option: 'a password or token option',
105
+ credential_assignment: 'a password, secret or token assignment',
106
+ api_key: 'an API key',
107
+ jwt: 'a JSON Web Token',
108
+ };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The two requests that don't go to the agent API: the GET of a presigned
3
+ * download URL and the PUT of a presigned upload URL. They go straight to
4
+ * storage, so they never carry the token (no Authorization header), and
5
+ * the PUT sends exactly the signed headers with the file streamed from disk.
6
+ *
7
+ * A presigned URL is a bearer URL for its few minutes: it is never logged,
8
+ * written to disk or shown to Claude, and an error never repeats it.
9
+ */
10
+ import { createReadStream, createWriteStream } from 'node:fs';
11
+ import { rm } from 'node:fs/promises';
12
+ import { request as httpRequest } from 'node:http';
13
+ import { request as httpsRequest } from 'node:https';
14
+ import { Transform } from 'node:stream';
15
+ import { pipeline } from 'node:stream/promises';
16
+ /** A failed transfer: an HTTP status from storage, or a local reason. Never the URL. */
17
+ export class TransferError extends Error {
18
+ reason;
19
+ status;
20
+ constructor(reason, status) {
21
+ super(reason === 'status'
22
+ ? `storage answered HTTP ${status ?? 0}`
23
+ : reason === 'too_large'
24
+ ? 'the download is larger than the file'
25
+ : reason === 'short'
26
+ ? 'the download ended early'
27
+ : reason === 'timeout'
28
+ ? 'the transfer timed out'
29
+ : reason === 'url'
30
+ ? 'the storage URL is not one this server uses'
31
+ : 'the network failed');
32
+ this.reason = reason;
33
+ this.status = status;
34
+ this.name = 'TransferError';
35
+ }
36
+ }
37
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
38
+ /** The URL, if storage may be reached there: https, or http on this computer (a local stack). */
39
+ function storageUrl(raw) {
40
+ let url;
41
+ try {
42
+ url = new URL(raw);
43
+ }
44
+ catch {
45
+ throw new TransferError('url');
46
+ }
47
+ if (url.protocol === 'https:')
48
+ return url;
49
+ if (url.protocol === 'http:' && LOOPBACK.has(url.hostname))
50
+ return url;
51
+ throw new TransferError('url');
52
+ }
53
+ function send(url, method, headers, signal) {
54
+ const request = url.protocol === 'https:' ? httpsRequest : httpRequest;
55
+ const req = request(url, { method, headers, ...(signal ? { signal } : {}) });
56
+ const response = new Promise((resolve, reject) => {
57
+ req.on('response', resolve);
58
+ req.on('error', (err) => {
59
+ reject(signal?.aborted === true || err.name === 'AbortError'
60
+ ? new TransferError('timeout')
61
+ : new TransferError('network'));
62
+ });
63
+ });
64
+ return { req, response };
65
+ }
66
+ /** Reads and drops a response's body (at most 64 KiB kept, as text). */
67
+ async function readSmall(res) {
68
+ const chunks = [];
69
+ let size = 0;
70
+ for await (const chunk of res) {
71
+ const b = chunk;
72
+ if (size < 65_536)
73
+ chunks.push(b);
74
+ size += b.length;
75
+ }
76
+ return Buffer.concat(chunks).toString('utf8').slice(0, 65_536);
77
+ }
78
+ /**
79
+ * GETs `url` into a new file at `dest` (created 0600, never over an
80
+ * existing file), stopping when more than `maxBytes` arrive. Returns the
81
+ * bytes written. With `exactBytes`, a shorter or longer body fails. The
82
+ * partial file is removed on any failure.
83
+ */
84
+ export async function downloadToFile(rawUrl, dest, options) {
85
+ const url = storageUrl(rawUrl);
86
+ const { req, response } = send(url, 'GET', { Accept: '*/*' }, options.signal);
87
+ req.end();
88
+ let written = 0;
89
+ try {
90
+ const res = await response;
91
+ if ((res.statusCode ?? 0) < 200 || (res.statusCode ?? 0) >= 300) {
92
+ await readSmall(res).catch(() => '');
93
+ throw new TransferError('status', res.statusCode);
94
+ }
95
+ const counter = new Transform({
96
+ transform(chunk, _enc, done) {
97
+ written += chunk.length;
98
+ if (written > options.maxBytes)
99
+ done(new TransferError('too_large'));
100
+ else
101
+ done(null, chunk);
102
+ },
103
+ });
104
+ await pipeline(res, counter, createWriteStream(dest, { flags: 'wx', mode: 0o600 }), {
105
+ ...(options.signal ? { signal: options.signal } : {}),
106
+ }).catch((err) => {
107
+ if (err instanceof TransferError)
108
+ throw err;
109
+ throw new TransferError(options.signal?.aborted === true ? 'timeout' : 'network');
110
+ });
111
+ if (options.exactBytes !== undefined && written !== options.exactBytes) {
112
+ throw new TransferError(written < options.exactBytes ? 'short' : 'too_large');
113
+ }
114
+ return written;
115
+ }
116
+ catch (err) {
117
+ req.destroy();
118
+ await rm(dest, { force: true }).catch(() => undefined);
119
+ throw err;
120
+ }
121
+ }
122
+ /**
123
+ * PUTs the file at `source` (`size` bytes) to `url` with exactly `headers`
124
+ * (the signed Content-Type and Content-Length), streamed, and no
125
+ * Authorization header. `onBytes` hears the bytes sent so far.
126
+ */
127
+ export async function uploadFile(rawUrl, headers, source, size, options = {}) {
128
+ const url = storageUrl(rawUrl);
129
+ const sent = {};
130
+ for (const [name, value] of Object.entries(headers)) {
131
+ // Never a credential of ours: storage's signature is the only one.
132
+ if (name.toLowerCase() === 'authorization')
133
+ continue;
134
+ sent[name] = value;
135
+ }
136
+ if (!Object.keys(sent).some((k) => k.toLowerCase() === 'content-length')) {
137
+ sent['Content-Length'] = String(size);
138
+ }
139
+ const { req, response } = send(url, 'PUT', sent, options.signal);
140
+ let count = 0;
141
+ const counter = new Transform({
142
+ transform(chunk, _enc, done) {
143
+ count += chunk.length;
144
+ options.onBytes?.(count);
145
+ done(null, chunk);
146
+ },
147
+ });
148
+ const body = pipeline(createReadStream(source), counter, req).catch(() => {
149
+ // The response (or its error) says what happened.
150
+ });
151
+ try {
152
+ const res = await response;
153
+ await readSmall(res).catch(() => '');
154
+ await body;
155
+ if ((res.statusCode ?? 0) < 200 || (res.statusCode ?? 0) >= 300) {
156
+ throw new TransferError('status', res.statusCode);
157
+ }
158
+ }
159
+ catch (err) {
160
+ req.destroy();
161
+ throw err;
162
+ }
163
+ }