Skip to content

Commit 1963461

Browse files
fix(test): atomic write for code get --out, no truncate before fetch (#7)
test code get --out <path> opened (truncated) the destination file before the network request. If the GET then failed, or hit the "no code generated yet" branch which writes nothing, the user's pre-existing --out file was left emptied with no way to recover it. Write to a sibling temp file instead and rename it onto the real path only after a successful, complete write. Mirrors the atomic rename contract bundle.ts already uses for multi-file bundles. Add regression tests for both failure modes: a failing fetch and the no-code-yet branch. Both reproduce the truncation on the old code and pass on the fix.
1 parent 5ebcba8 commit 1963461

2 files changed

Lines changed: 111 additions & 35 deletions

File tree

src/commands/test.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
mkdtempSync,
5+
readdirSync,
6+
readFileSync,
7+
writeFileSync,
8+
} from 'node:fs';
29
import { tmpdir } from 'node:os';
310
import { join } from 'node:path';
411
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -1556,6 +1563,46 @@ describe('runCodeGet', () => {
15561563
),
15571564
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
15581565
});
1566+
1567+
// Regression: --out used to open (truncate) the destination file
1568+
// before the network request. A failed fetch left a pre-existing
1569+
// file emptied. The fix writes to a sibling temp file and renames it
1570+
// into place only on success, so a failure must never touch the
1571+
// operator's existing --out file.
1572+
it('--out: a failed fetch leaves a pre-existing file untouched, not truncated', async () => {
1573+
const { credentialsPath } = makeCreds();
1574+
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-out-fail-'));
1575+
const target = join(dir, 'existing.ts');
1576+
writeFileSync(target, 'PRE-EXISTING CONTENT', 'utf8');
1577+
const fetchImpl = (() => Promise.reject(new Error('ENETUNREACH'))) as typeof globalThis.fetch;
1578+
await expect(
1579+
runCodeGet(
1580+
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', out: target },
1581+
{ credentialsPath, fetchImpl },
1582+
),
1583+
).rejects.toBeDefined();
1584+
expect(readFileSync(target, 'utf-8')).toBe('PRE-EXISTING CONTENT');
1585+
// No leftover temp file in the directory.
1586+
const leftovers = readdirSync(dir).filter(f => f !== 'existing.ts');
1587+
expect(leftovers).toEqual([]);
1588+
});
1589+
1590+
// Regression: the "no code generated yet" branch writes nothing but
1591+
// previously still closed (and thus truncated) the opened file.
1592+
it('--out: "no code generated yet" leaves a pre-existing file untouched', async () => {
1593+
const { credentialsPath } = makeCreds();
1594+
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-out-empty-'));
1595+
const target = join(dir, 'existing.ts');
1596+
writeFileSync(target, 'PRE-EXISTING CONTENT', 'utf8');
1597+
const fetchImpl = makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: '' } }));
1598+
await runCodeGet(
1599+
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', out: target },
1600+
{ credentialsPath, fetchImpl, stderr: () => undefined },
1601+
);
1602+
expect(readFileSync(target, 'utf-8')).toBe('PRE-EXISTING CONTENT');
1603+
const leftovers = readdirSync(dir).filter(f => f !== 'existing.ts');
1604+
expect(leftovers).toEqual([]);
1605+
});
15591606
});
15601607

15611608
describe('runCodePut', () => {

src/commands/test.ts

Lines changed: 63 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createWriteStream, readFileSync, readdirSync, statSync, type WriteStream } from 'node:fs';
2-
import { stat } from 'node:fs/promises';
3-
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
2+
import { rename, stat, unlink } from 'node:fs/promises';
3+
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
44
import { randomUUID } from 'node:crypto';
55
import { Command } from 'commander';
66
import {
@@ -3169,11 +3169,14 @@ interface CodeGetOptions extends CommonOptions {
31693169
* directly; presigned URLs are dereferenced via the same fetch impl
31703170
* (without API-key headers — the URL is the bearer of authority).
31713171
*
3172-
* `--out <path>` redirects the same bytes into a file. We open the file
3173-
* before issuing the network request so a permission/dir error fails
3174-
* fast (exit 5 / VALIDATION_ERROR) without spending an API call. On
3175-
* any error after open we tear down the sink so we don't leak a
3176-
* half-written artifact.
3172+
* `--out <path>` redirects the same bytes into a file. We validate the
3173+
* path and open a sibling temp file before issuing the network request
3174+
* so a permission/dir error fails fast (exit 5 / VALIDATION_ERROR)
3175+
* without spending an API call. The temp file is renamed onto the real
3176+
* `--out` path only after a successful, complete write; on any error
3177+
* (or the "no code generated yet" branch, which writes nothing) the
3178+
* temp file is discarded and the user's pre-existing `--out` file, if
3179+
* any, is left untouched.
31773180
*/
31783181
export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Promise<CliTestCode> {
31793182
// Dry-run: no fetch, no fs. Print the canned shape to stdout and, if
@@ -3204,9 +3207,11 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
32043207

32053208
try {
32063209
const code = await client.get<CliTestCode>(`/tests/${encodeURIComponent(opts.testId)}/code`);
3210+
let wroteContent = false;
32073211

32083212
if (opts.output === 'json') {
32093213
out.print(code);
3214+
wroteContent = true;
32103215
} else if (isPresignedCodeUrl(code.code)) {
32113216
// Text mode: dump the source body. JSON consumers want the wire
32123217
// shape; humans (and agents shelling out via `> file.ts`) want
@@ -3217,20 +3222,24 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
32173222
// or a piped `gzip`) pauses the upstream reader rather than
32183223
// letting chunks accumulate in V8's heap.
32193224
await streamPresignedBody(code.code, out, deps);
3225+
wroteContent = true;
32203226
} else if (code.code === '' || code.code === null) {
32213227
// P2-10: draft test with no code yet — empty body would produce
32223228
// silent empty stdout. Print a friendly hint to stderr instead so
3223-
// the operator knows what happened, and keep exit 0.
3229+
// the operator knows what happened, and keep exit 0. Nothing was
3230+
// written, so the temp file is discarded below without touching
3231+
// a pre-existing `--out` file.
32243232
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
32253233
stderrFn('(no code generated yet — run the test first)');
32263234
} else {
32273235
await out.writeChunk(code.code);
3236+
wroteContent = true;
32283237
}
32293238

3230-
if (fileSink) await closeOutputFile(fileSink);
3239+
if (fileSink) await closeOutputFile(fileSink, wroteContent);
32313240
return code;
32323241
} catch (err) {
3233-
if (fileSink) await closeOutputFile(fileSink).catch(() => undefined);
3242+
if (fileSink) await closeOutputFile(fileSink, false).catch(() => undefined);
32343243
throw err;
32353244
}
32363245
}
@@ -7733,22 +7742,29 @@ function makeOutput(mode: OutputMode, deps: TestDeps): Output {
77337742
* Internal handle for `--out <path>` writes. Wraps a Node WriteStream
77347743
* with a tracked `error` field so `closeOutputFile` can re-raise an
77357744
* async stream error (EACCES on a write, ENOSPC mid-stream, etc.) that
7736-
* was emitted between writes.
7745+
* was emitted between writes. The stream writes to `tmpPath`, a sibling
7746+
* of the real `path`; `closeOutputFile` renames it into place only on
7747+
* a successful, complete write, so a forged or failed response never
7748+
* modifies (or empties) the operator's pre-existing `--out` file.
77377749
*/
77387750
interface FileSink {
77397751
readonly stream: WriteStream;
77407752
readonly path: string;
7753+
readonly tmpPath: string;
77417754
error: Error | null;
77427755
}
77437756

77447757
/**
7745-
* Open the `--out` target before any network I/O so a permission/dir
7746-
* error fails fast. Synchronous open via `createWriteStream` doesn't
7747-
* actually open the descriptor until first write, so we don't surface
7748-
* EACCES/ENOENT here — instead the stream emits `'error'`, which we
7749-
* remember on the sink and re-throw at close time. The benefit of
7750-
* opening early is still real: invalid path strings (empty, `/dev/null`
7751-
* on a sandboxed fs, etc.) are caught before the API request goes out.
7758+
* Open a temp file next to the `--out` target before any network I/O so
7759+
* a permission/dir error fails fast. Synchronous open via
7760+
* `createWriteStream` doesn't actually open the descriptor until first
7761+
* write, so we don't surface EACCES/ENOENT here, instead the stream
7762+
* emits `'error'`, which we remember on the sink and re-throw at close
7763+
* time. The benefit of opening early is still real: invalid path
7764+
* strings (empty, `/dev/null` on a sandboxed fs, etc.) are caught
7765+
* before the API request goes out. Writing to a temp path rather than
7766+
* `resolved` directly means the real `--out` file is never truncated
7767+
* up front, see `closeOutputFile` for the commit step.
77527768
*/
77537769
function openOutputFile(rawPath: string): FileSink {
77547770
if (typeof rawPath !== 'string' || rawPath.length === 0) {
@@ -7777,8 +7793,9 @@ function openOutputFile(rawPath: string): FileSink {
77777793
if (!parentStat.isDirectory()) {
77787794
throw localValidationError('out', `parent path is not a directory: ${parent}`);
77797795
}
7780-
const stream = createWriteStream(resolved, { encoding: 'utf8' });
7781-
const sink: FileSink = { stream, path: resolved, error: null };
7796+
const tmpPath = join(parent, `.${basename(resolved)}.tmp-${randomUUID()}`);
7797+
const stream = createWriteStream(tmpPath, { encoding: 'utf8' });
7798+
const sink: FileSink = { stream, path: resolved, tmpPath, error: null };
77827799
stream.on('error', err => {
77837800
sink.error = err instanceof Error ? err : new Error(String(err));
77847801
});
@@ -7808,25 +7825,37 @@ function makeFileOutput(mode: OutputMode, sink: FileSink): Output {
78087825
}
78097826

78107827
/**
7811-
* Flush + close the file sink. Called on the success path after the
7812-
* last write and on the error path inside a `.catch(() => undefined)`
7813-
* so the original error isn't masked by a teardown failure.
7828+
* Flush + close the file sink, then either commit or discard the temp
7829+
* file. Called on the success path after the last write (`commit:
7830+
* true` when content was actually written) and on the error / "no code
7831+
* yet" paths (`commit: false`) inside a `.catch(() => undefined)` so a
7832+
* teardown failure doesn't mask the original error.
7833+
*
7834+
* `commit: true` renames `tmpPath` onto the real `--out` path, the
7835+
* only point at which the operator's file is touched. `commit: false`
7836+
* discards the temp file and leaves any pre-existing `--out` file
7837+
* exactly as it was, this is what prevents a failed/empty response
7838+
* from silently truncating the operator's filesystem (mirrors the
7839+
* atomic-rename contract `bundle.ts` uses for multi-file bundles).
78147840
*
78157841
* Re-raises any async stream error captured by the `'error'` listener.
78167842
* Without this re-raise, an EACCES on first write would leave a
7817-
* zero-byte file behind and exit 0a false-success surface that is
7818-
* exactly the failure mode `--out` exists to avoid.
7843+
* zero-byte temp file behind and exit 0, a false-success surface that
7844+
* is exactly the failure mode `--out` exists to avoid.
78197845
*/
7820-
function closeOutputFile(sink: FileSink): Promise<void> {
7821-
return new Promise((resolve, reject) => {
7822-
sink.stream.end(() => {
7823-
if (sink.error) {
7824-
reject(new TransportError(`Failed to write --out ${sink.path}: ${sink.error.message}`));
7825-
return;
7826-
}
7827-
resolve();
7828-
});
7846+
async function closeOutputFile(sink: FileSink, commit: boolean): Promise<void> {
7847+
await new Promise<void>(resolveStream => {
7848+
sink.stream.end(() => resolveStream());
78297849
});
7850+
if (sink.error) {
7851+
await unlink(sink.tmpPath).catch(() => undefined);
7852+
throw new TransportError(`Failed to write --out ${sink.path}: ${sink.error.message}`);
7853+
}
7854+
if (!commit) {
7855+
await unlink(sink.tmpPath).catch(() => undefined);
7856+
return;
7857+
}
7858+
await rename(sink.tmpPath, sink.path);
78307859
}
78317860

78327861
/** A presigned `code` body is any `https://` URL — never anything else. */

0 commit comments

Comments
 (0)