Skip to content

Commit fb2b786

Browse files
committed
fix(cli): snapshot staging folders no longer fail past 260 characters on Windows
1 parent bb3c828 commit fb2b786

5 files changed

Lines changed: 82 additions & 14 deletions

File tree

‎packages/cli/src/capture/captureFile.ts‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
1-
import {
2-
mkdirSync,
3-
mkdtempSync,
4-
realpathSync,
5-
renameSync,
6-
rmSync,
7-
statSync,
8-
writeFileSync,
9-
} from "node:fs";
1+
import { mkdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
102
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
113
import { CaptureDirRefusedError } from "./captureErrors.js";
4+
import { createStagingDir } from "../utils/stagingDir.js";
125

136
type WriteOptions = Parameters<typeof writeFileSync>[2];
147

@@ -25,7 +18,7 @@ function publishCaptureFileSync(
2518
data: string | NodeJS.ArrayBufferView,
2619
options: Exclude<WriteOptions, string>,
2720
): void {
28-
const stagingDir = mkdtempSync(join(dirname(path), ".capture-"));
21+
const stagingDir = createStagingDir(dirname(path), ".capture-");
2922
try {
3023
const staged = join(stagingDir, basename(path));
3124
writeCaptureFileSync(staged, data, { ...options, flag: "wx" });

‎packages/cli/src/cloud/download.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
* existing destination is replaced only after the download succeeds.
1414
*/
1515

16-
import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync } from "node:fs";
16+
import { chmodSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
1717
import { open } from "node:fs/promises";
1818
import { dirname, join } from "node:path";
1919
import { setTimeout as sleep } from "node:timers/promises";
2020
import { resolveWritePath } from "@hyperframes/core/atomic-file";
21+
import { createStagingDir } from "../utils/stagingDir.js";
2122

2223
// The first request asks for PART_BYTES; a larger file is split into up to
2324
// CONNECTIONS ranged GETs. A server that ignores Range answers 200 and is streamed.
@@ -78,7 +79,7 @@ export async function downloadToFile(
7879
// Writes through a caller-selected symlink, as before.
7980
const destination = resolveWritePath(destPath);
8081
const previous = statSync(destination, { throwIfNoEntry: false });
81-
const stage = mkdtempSync(join(dirname(destination), ".hf-download-"));
82+
const stage = createStagingDir(dirname(destination), ".hf-download-");
8283
const stagedFile = join(stage, "download");
8384
let bytes = 0;
8485
let firstError: unknown;

‎packages/cli/src/registry/publication.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { lstatSync, chmodSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
1+
import { lstatSync, chmodSync, renameSync, rmSync, writeFileSync } from "node:fs";
22
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
33
import { mkdirWithinProject, realpath } from "@hyperframes/core";
4+
import { createStagingDir } from "../utils/stagingDir.js";
45

56
export { realProjectRoot as registryRoot } from "@hyperframes/core";
67

@@ -46,7 +47,7 @@ export function publishRegistryFile(
4647
const parent = realpath(dirname(path));
4748
assertContained(root, parent);
4849
const destination = resolve(parent, basename(path));
49-
const stage = mkdtempSync(join(parent, ".hf-install-"));
50+
const stage = createStagingDir(parent, ".hf-install-");
5051
try {
5152
const stagedFile = join(stage, "file");
5253
writeFileSync(stagedFile, bytes, { flag: "wx" });
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { rmSync, statSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { basename, join } from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import * as fs from "node:fs";
6+
import { createStagingDir } from "./stagingDir.js";
7+
8+
vi.mock("node:fs", async (importOriginal) => {
9+
const original = await importOriginal<typeof fs>();
10+
return {
11+
...original,
12+
mkdtempSync: vi.fn(() => {
13+
throw Object.assign(new Error("path too long"), { code: "ENOENT" });
14+
}),
15+
};
16+
});
17+
18+
const roots: string[] = [];
19+
const root = () => {
20+
const made = join(tmpdir(), `hf-staging-${process.pid}-${roots.length}`);
21+
fs.mkdirSync(made, { recursive: true });
22+
roots.push(made);
23+
return made;
24+
};
25+
afterEach(() => {
26+
for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true });
27+
});
28+
29+
describe("createStagingDir", () => {
30+
it("makes a fresh private directory without mkdtemp, which fails on long Windows paths", () => {
31+
const parent = root();
32+
const first = createStagingDir(parent, ".stage-");
33+
const second = createStagingDir(parent, ".stage-");
34+
expect(first).not.toBe(second);
35+
expect(basename(first)).toMatch(/^\.stage-[0-9a-f]{8}$/);
36+
expect(statSync(first).isDirectory()).toBe(true);
37+
if (process.platform !== "win32") expect(statSync(first).mode & 0o777).toBe(0o700);
38+
expect(fs.mkdtempSync).not.toHaveBeenCalled();
39+
});
40+
41+
it("tries another name when one is taken, and gives up after five", () => {
42+
const parent = root();
43+
const taken = createStagingDir(parent, ".s-", () => "same");
44+
expect(taken).toBe(join(parent, ".s-same"));
45+
const names = ["same", "same", "next"];
46+
expect(createStagingDir(parent, ".s-", () => names.shift() ?? "x")).toBe(
47+
join(parent, ".s-next"),
48+
);
49+
expect(() => createStagingDir(parent, ".s-", () => "same")).toThrow(/EEXIST/);
50+
});
51+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { randomBytes } from "node:crypto";
2+
import { mkdirSync } from "node:fs";
3+
import { join } from "node:path";
4+
5+
const TRIES = 5;
6+
7+
/** A fresh private directory in `parent`. Not mkdtemp: on Windows that call fails once its path passes 260 characters. */
8+
export function createStagingDir(
9+
parent: string,
10+
prefix: string,
11+
suffix = () => randomBytes(4).toString("hex"),
12+
): string {
13+
for (let attempt = 1; ; attempt++) {
14+
const dir = join(parent, `${prefix}${suffix()}`);
15+
try {
16+
mkdirSync(dir, { mode: 0o700 });
17+
return dir;
18+
} catch (error) {
19+
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || attempt >= TRIES) throw error;
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)