pi-cursor-sdk 0.1.56 → 0.1.57
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/CHANGELOG.md +45 -1
- package/README.md +100 -28
- package/docs/cursor-live-smoke-checklist.md +7 -7
- package/docs/cursor-model-ux-spec.md +45 -38
- package/docs/cursor-native-tool-replay.md +2 -2
- package/docs/cursor-native-tool-visual-audit.md +1 -1
- package/docs/cursor-testing-lessons.md +20 -7
- package/docs/cursor-tool-surfaces.md +13 -2
- package/docs/platform-smoke-implementation.md +220 -0
- package/docs/platform-smoke.md +183 -247
- package/package.json +38 -6
- package/platform-smoke.config.mjs +5 -1
- package/scripts/cloud-runtime-smoke.d.mts +3 -0
- package/scripts/cloud-runtime-smoke.mjs +502 -0
- package/scripts/debug-provider-events.mjs +7 -2
- package/scripts/isolated-cursor-smoke.sh +4 -6
- package/scripts/lib/cursor-child-process.d.mts +1 -0
- package/scripts/lib/cursor-child-process.mjs +137 -7
- package/scripts/lib/local-resume-smoke-harness.mjs +543 -0
- package/scripts/local-resume-cleanup-smoke.mjs +108 -0
- package/scripts/local-resume-smoke.d.mts +1 -0
- package/scripts/local-resume-smoke.mjs +642 -0
- package/scripts/platform-smoke/artifact-anchored-extract.d.mts +10 -0
- package/scripts/platform-smoke/artifact-anchored-extract.mjs +111 -0
- package/scripts/platform-smoke/artifact-bundle-chunk.mjs +57 -0
- package/scripts/platform-smoke/artifact-bundle-contract.mjs +34 -0
- package/scripts/platform-smoke/artifact-fs-safety.mjs +311 -0
- package/scripts/platform-smoke/artifact-openat-extract.c +335 -0
- package/scripts/platform-smoke/artifact-secrets.mjs +155 -0
- package/scripts/platform-smoke/artifacts.mjs +293 -65
- package/scripts/platform-smoke/card-detect.mjs +16 -4
- package/scripts/platform-smoke/crabbox-runner.mjs +45 -3
- package/scripts/platform-smoke/doctor.mjs +20 -10
- package/scripts/platform-smoke/live-suite-runner.mjs +18 -57
- package/scripts/platform-smoke/local-resume-runner.mjs +252 -0
- package/scripts/platform-smoke/local-resume-suites.d.mts +15 -0
- package/scripts/platform-smoke/local-resume-suites.mjs +104 -0
- package/scripts/platform-smoke/scenarios.mjs +16 -2
- package/scripts/platform-smoke/target-runtime.mjs +206 -0
- package/scripts/platform-smoke/targets.mjs +33 -141
- package/scripts/platform-smoke/visual-evidence.mjs +6 -7
- package/scripts/platform-smoke/wrapped-line-match.mjs +9 -0
- package/scripts/platform-smoke.mjs +40 -27
- package/scripts/refresh-cursor-model-snapshots.mjs +18 -6
- package/scripts/steering-rpc-smoke.mjs +12 -2
- package/scripts/tmux-live-smoke.sh +3 -5
- package/shared/cursor-cloud-lifecycle-constants.d.mts +3 -0
- package/shared/cursor-cloud-lifecycle-constants.mjs +7 -0
- package/shared/cursor-sensitive-text.mjs +7 -1
- package/src/context.ts +5 -2
- package/src/cursor-agents-context-registration.ts +7 -0
- package/src/cursor-agents-context.ts +3 -1
- package/src/cursor-api-key.ts +15 -1
- package/src/cursor-bridge-contract.ts +3 -0
- package/src/cursor-cloud-lifecycle.ts +733 -0
- package/src/cursor-cloud-options.ts +206 -0
- package/src/cursor-cloud-reporting.ts +246 -0
- package/src/cursor-config.ts +659 -0
- package/src/cursor-display-only-trace.ts +14 -0
- package/src/cursor-display-text.ts +8 -2
- package/src/cursor-durable-fs.ts +49 -0
- package/src/cursor-fallback-models.generated.ts +2045 -485
- package/src/cursor-live-run-accounting.ts +7 -1
- package/src/cursor-live-run-coordinator.ts +1 -0
- package/src/cursor-pi-tool-bridge-run.ts +14 -4
- package/src/cursor-provider-live-run-drain.ts +5 -0
- package/src/cursor-provider-run-finalizer.ts +36 -28
- package/src/cursor-provider-turn-finalize.ts +72 -6
- package/src/cursor-provider-turn-prepare.ts +228 -12
- package/src/cursor-provider-turn-runner.ts +41 -13
- package/src/cursor-provider-turn-send.ts +59 -16
- package/src/cursor-provider-turn-types.ts +44 -10
- package/src/cursor-runtime-state.ts +478 -0
- package/src/cursor-sdk-event-debug.ts +46 -6
- package/src/cursor-sdk-process-error-guard.ts +101 -30
- package/src/cursor-session-agent-cleanup.ts +328 -0
- package/src/cursor-session-agent-resume.ts +439 -0
- package/src/cursor-session-agent.ts +109 -13
- package/src/cursor-session-scope.ts +35 -2
- package/src/cursor-session-send-policy.ts +1 -1
- package/src/cursor-skill-tool.ts +30 -11
- package/src/cursor-state.ts +112 -69
- package/src/cursor-usage-accounting.ts +14 -4
- package/src/index.ts +11 -2
- package/src/model-discovery.ts +10 -56
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import {
|
|
8
|
+
MAX_BUNDLE_AGGREGATE_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_BUNDLE_FILE_COUNT,
|
|
9
|
+
MAX_BUNDLE_PATH_BYTES, MAX_BUNDLE_PATH_COMPONENTS, MAX_INFLATED_BUNDLE_JSON_BYTES,
|
|
10
|
+
} from "./artifact-bundle-contract.mjs";
|
|
11
|
+
|
|
12
|
+
const MAGIC = Buffer.from([80, 73, 65, 82, 84, 48, 49, 0]);
|
|
13
|
+
let helperPath;
|
|
14
|
+
let helperDirectory;
|
|
15
|
+
let helperUnavailable = false;
|
|
16
|
+
|
|
17
|
+
function compileHelper() {
|
|
18
|
+
if (helperPath) return helperPath;
|
|
19
|
+
if (helperUnavailable) return undefined;
|
|
20
|
+
const source = fileURLToPath(new URL("./artifact-openat-extract.c", import.meta.url));
|
|
21
|
+
const content = readFileSync(source);
|
|
22
|
+
const nativeLimits = [
|
|
23
|
+
MAX_INFLATED_BUNDLE_JSON_BYTES, MAX_BUNDLE_FILE_COUNT, MAX_BUNDLE_FILE_BYTES,
|
|
24
|
+
MAX_BUNDLE_AGGREGATE_BYTES, MAX_BUNDLE_PATH_BYTES, MAX_BUNDLE_PATH_COMPONENTS,
|
|
25
|
+
];
|
|
26
|
+
const key = createHash("sha256").update(content).update(process.platform).update(process.arch)
|
|
27
|
+
.update(nativeLimits.join(":"))
|
|
28
|
+
.digest("hex").slice(0, 16);
|
|
29
|
+
helperDirectory = mkdtempSync(join(tmpdir(), `pi-cursor-artifact-openat-${key}-`));
|
|
30
|
+
chmodSync(helperDirectory, 0o700);
|
|
31
|
+
const output = join(helperDirectory, "extract");
|
|
32
|
+
const compiled = spawnSync("cc", [
|
|
33
|
+
"-std=c11", "-O2", "-Wall", "-Wextra", "-Werror",
|
|
34
|
+
`-DMAX_INPUT=${MAX_INFLATED_BUNDLE_JSON_BYTES}`,
|
|
35
|
+
`-DMAX_FILES=${MAX_BUNDLE_FILE_COUNT}`,
|
|
36
|
+
`-DMAX_FILE_BYTES=${MAX_BUNDLE_FILE_BYTES}`,
|
|
37
|
+
`-DMAX_TOTAL_BYTES=${MAX_BUNDLE_AGGREGATE_BYTES}`,
|
|
38
|
+
`-DMAX_PATH_BYTES=${MAX_BUNDLE_PATH_BYTES}`,
|
|
39
|
+
`-DMAX_PATH_COMPONENTS=${MAX_BUNDLE_PATH_COMPONENTS}`,
|
|
40
|
+
"-o", output, source,
|
|
41
|
+
], {
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
timeout: 30_000,
|
|
44
|
+
maxBuffer: 1024 * 1024,
|
|
45
|
+
});
|
|
46
|
+
if (compiled.status !== 0 || compiled.error || !existsSync(output)) {
|
|
47
|
+
rmSync(helperDirectory, { recursive: true, force: true });
|
|
48
|
+
helperDirectory = undefined;
|
|
49
|
+
helperUnavailable = true;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
chmodSync(output, 0o700);
|
|
53
|
+
helperPath = output;
|
|
54
|
+
process.once("exit", () => {
|
|
55
|
+
if (helperDirectory) try { rmSync(helperDirectory, { recursive: true, force: true }); } catch {}
|
|
56
|
+
});
|
|
57
|
+
return helperPath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function encodeFiles(files) {
|
|
61
|
+
const count = Buffer.alloc(4);
|
|
62
|
+
count.writeUInt32LE(files.length);
|
|
63
|
+
const chunks = [MAGIC, count];
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const path = Buffer.from(file.path, "utf8");
|
|
66
|
+
const lengths = Buffer.alloc(8);
|
|
67
|
+
lengths.writeUInt32LE(path.length, 0);
|
|
68
|
+
lengths.writeUInt32LE(file.content.length, 4);
|
|
69
|
+
chunks.push(lengths, path, file.content);
|
|
70
|
+
}
|
|
71
|
+
return Buffer.concat(chunks);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writePosix(outputDir, files, expectedRoot) {
|
|
75
|
+
const helper = compileHelper();
|
|
76
|
+
if (!helper) return false;
|
|
77
|
+
let inputDirectory;
|
|
78
|
+
let inputFd;
|
|
79
|
+
try {
|
|
80
|
+
inputDirectory = mkdtempSync(join(tmpdir(), "pi-cursor-artifact-input-"));
|
|
81
|
+
chmodSync(inputDirectory, 0o700);
|
|
82
|
+
const inputPath = join(inputDirectory, "frame");
|
|
83
|
+
writeFileSync(inputPath, encodeFiles(files), { flag: "wx", mode: 0o600 });
|
|
84
|
+
inputFd = openSync(inputPath, "r");
|
|
85
|
+
unlinkSync(inputPath);
|
|
86
|
+
const result = spawnSync(helper, [outputDir, String(expectedRoot.dev), String(expectedRoot.ino)], {
|
|
87
|
+
stdio: [inputFd, "pipe", "pipe"],
|
|
88
|
+
encoding: "buffer",
|
|
89
|
+
maxBuffer: 1024 * 1024,
|
|
90
|
+
});
|
|
91
|
+
return result.status === 0 && !result.error;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
} finally {
|
|
95
|
+
try {
|
|
96
|
+
if (inputFd !== undefined) closeSync(inputFd);
|
|
97
|
+
} catch {
|
|
98
|
+
// Continue to remove the private input directory.
|
|
99
|
+
} finally {
|
|
100
|
+
if (inputDirectory) try { rmSync(inputDirectory, { recursive: true, force: true }); } catch {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Extracts prevalidated files without permitting a nested host-path swap to redirect writes.
|
|
106
|
+
* Node has no handle-relative Windows creation API, so Windows controllers fail closed before
|
|
107
|
+
* mutating nonempty bundles. Windows remains a supported target of POSIX-hosted release matrices. */
|
|
108
|
+
export function writeExtractedFilesAnchored(outputDir, files, expectedRoot) {
|
|
109
|
+
if (process.platform === "win32") return files.length === 0;
|
|
110
|
+
return writePosix(outputDir, files, expectedRoot);
|
|
111
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { closeSync, fstatSync, readSync } from "node:fs";
|
|
3
|
+
import { PLATFORM_ARTIFACT_BUNDLE_PATH } from "./artifact-bundle-contract.mjs";
|
|
4
|
+
import { openRegularFileNoFollow } from "./artifact-fs-safety.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_CHUNK_BYTES = 32 * 1024;
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
|
|
9
|
+
function value(name) {
|
|
10
|
+
const index = args.indexOf(name);
|
|
11
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fail(message) {
|
|
15
|
+
console.error(`artifact bundle chunk error: ${message}`);
|
|
16
|
+
process.exitCode = 2;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
20
|
+
console.log("Usage: node scripts/platform-smoke/artifact-bundle-chunk.mjs --path .platform-artifact-bundle.gz --offset <bytes> --length <1..32768>");
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const pathValue = value("--path");
|
|
25
|
+
const offset = Number(value("--offset"));
|
|
26
|
+
const length = Number(value("--length"));
|
|
27
|
+
if (args.length !== 6 || !pathValue || !Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 1 || length > MAX_CHUNK_BYTES) {
|
|
28
|
+
fail("invalid arguments");
|
|
29
|
+
} else if (pathValue !== PLATFORM_ARTIFACT_BUNDLE_PATH) {
|
|
30
|
+
fail(`path must be exactly ${PLATFORM_ARTIFACT_BUNDLE_PATH}`);
|
|
31
|
+
} else {
|
|
32
|
+
let descriptor;
|
|
33
|
+
let output;
|
|
34
|
+
try {
|
|
35
|
+
descriptor = openRegularFileNoFollow(PLATFORM_ARTIFACT_BUNDLE_PATH);
|
|
36
|
+
const total = fstatSync(descriptor).size;
|
|
37
|
+
if (offset >= total) throw new Error("offset is outside the bundle");
|
|
38
|
+
const buffer = Buffer.alloc(Math.min(length, total - offset));
|
|
39
|
+
const bytes = readSync(descriptor, buffer, 0, buffer.length, offset);
|
|
40
|
+
output = `PLATFORM_BUNDLE_CHUNK_JSON=${JSON.stringify({
|
|
41
|
+
offset,
|
|
42
|
+
total,
|
|
43
|
+
bytes,
|
|
44
|
+
contentBase64: buffer.subarray(0, bytes).toString("base64"),
|
|
45
|
+
})}`;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
48
|
+
} finally {
|
|
49
|
+
if (descriptor !== undefined) {
|
|
50
|
+
try { closeSync(descriptor); } catch {
|
|
51
|
+
output = undefined;
|
|
52
|
+
fail("failed to close bundle");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (output) console.log(output);
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform artifact bundle contract — transport markers, path rules, size caps, and the
|
|
3
|
+
* canonical exact-size base64 decoder shared by the writer, extractor, and remote chunk transport.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const PLATFORM_ARTIFACT_BUNDLE_START = "PLATFORM_LIVE_BUNDLE_JSON_START";
|
|
7
|
+
export const PLATFORM_ARTIFACT_BUNDLE_END = "PLATFORM_LIVE_BUNDLE_JSON_END";
|
|
8
|
+
export const PLATFORM_ARTIFACT_BUNDLE_FILE_MARKER = "PLATFORM_LIVE_BUNDLE_FILE_JSON=";
|
|
9
|
+
export const PLATFORM_ARTIFACT_BUNDLE_PATH = ".platform-artifact-bundle.gz";
|
|
10
|
+
|
|
11
|
+
export const MAX_BUNDLE_FILE_BYTES = 5 * 1024 * 1024;
|
|
12
|
+
export const MAX_COMPRESSED_BUNDLE_BYTES = 20 * 1024 * 1024;
|
|
13
|
+
export const MAX_INFLATED_BUNDLE_JSON_BYTES = 64 * 1024 * 1024;
|
|
14
|
+
export const MAX_BUNDLE_FILE_COUNT = 512;
|
|
15
|
+
export const MAX_BUNDLE_AGGREGATE_BYTES = 40 * 1024 * 1024;
|
|
16
|
+
export const MAX_BUNDLE_PATH_BYTES = 4096;
|
|
17
|
+
export const MAX_BUNDLE_PATH_COMPONENTS = 4096;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decode base64 only when it is the canonical (shortest, correctly padded) encoding of
|
|
21
|
+
* exactly `exactDecodedSize` bytes — rejects re-encodings that round-trip to a different string.
|
|
22
|
+
*/
|
|
23
|
+
export function decodeCanonicalBase64(value, exactDecodedSize) {
|
|
24
|
+
if (typeof value !== "string" || value.length !== 4 * Math.ceil(exactDecodedSize / 3)) return undefined;
|
|
25
|
+
const decoded = Buffer.from(value, "base64");
|
|
26
|
+
return decoded.toString("base64") === value ? decoded : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A relative, forward-slash, dot-segment-free path — the only path shape a bundle may declare. */
|
|
30
|
+
export function isCanonicalPlatformBundlePath(path) {
|
|
31
|
+
if (typeof path !== "string" || !path || Buffer.byteLength(path) > MAX_BUNDLE_PATH_BYTES || path.includes("\\") || path.includes("\0")) return false;
|
|
32
|
+
if (path.startsWith("/") || /^[A-Za-z]:/.test(path)) return false;
|
|
33
|
+
return path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
34
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No-follow filesystem safety primitives shared by artifact scanning, bundling, and extraction:
|
|
3
|
+
* symlink-proof identity tracking, trusted-root traversal, bounded reads, safe extraction
|
|
4
|
+
* writes/cleanup, and the exclusive spill-file writer used when a bundle is too big to inline.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
closeSync, constants, fstatSync, lstatSync, openSync, readSync,
|
|
9
|
+
readdirSync, realpathSync, unlinkSync, writeSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
13
|
+
import { MAX_BUNDLE_FILE_BYTES } from "./artifact-bundle-contract.mjs";
|
|
14
|
+
import { writeExtractedFilesAnchored } from "./artifact-anchored-extract.mjs";
|
|
15
|
+
|
|
16
|
+
function sameFileIdentity(left, right) {
|
|
17
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameStableFileSnapshot(left, right) {
|
|
21
|
+
return sameFileIdentity(left, right) && left.size === right.size && left.mode === right.mode &&
|
|
22
|
+
left.nlink === right.nlink && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sameDirectorySnapshot(left, right, immutable) {
|
|
26
|
+
return sameFileIdentity(left, right) && (!immutable || (
|
|
27
|
+
left.ctimeMs === right.ctimeMs && left.mtimeMs === right.mtimeMs && left.size === right.size &&
|
|
28
|
+
left.mode === right.mode && left.nlink === right.nlink
|
|
29
|
+
));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function reverifyDirectory(directory) {
|
|
33
|
+
try {
|
|
34
|
+
const current = lstatSync(directory.path);
|
|
35
|
+
return current.isDirectory() && !current.isSymbolicLink() &&
|
|
36
|
+
sameDirectorySnapshot(directory.stat, current, directory.immutable) &&
|
|
37
|
+
(directory.descriptor === undefined || sameDirectorySnapshot(directory.stat, fstatSync(directory.descriptor), directory.immutable));
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function reverifyDirectories(directories) {
|
|
44
|
+
return directories.every(reverifyDirectory);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function openRegularFileNoFollow(path, parentDirectories = []) {
|
|
48
|
+
if (!reverifyDirectories(parentDirectories)) throw new Error("parent directory changed before file open");
|
|
49
|
+
const before = lstatSync(path);
|
|
50
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error("path is not a regular file");
|
|
51
|
+
if (!reverifyDirectories(parentDirectories)) throw new Error("parent directory changed before file open");
|
|
52
|
+
let descriptor;
|
|
53
|
+
try {
|
|
54
|
+
const noFollow = process.platform !== "win32" && typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
55
|
+
descriptor = openSync(path, constants.O_RDONLY | noFollow);
|
|
56
|
+
const opened = fstatSync(descriptor);
|
|
57
|
+
const after = lstatSync(path);
|
|
58
|
+
if (!opened.isFile() || !after.isFile() || after.isSymbolicLink() || !sameFileIdentity(before, opened) ||
|
|
59
|
+
!sameFileIdentity(opened, after) || !reverifyDirectories(parentDirectories)) {
|
|
60
|
+
throw new Error("path changed while opening regular file");
|
|
61
|
+
}
|
|
62
|
+
return descriptor;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (descriptor !== undefined) {
|
|
65
|
+
try { closeSync(descriptor); } catch {}
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function boundedFileSnapshot(path, maxBytes = MAX_BUNDLE_FILE_BYTES, parentDirectories = []) {
|
|
72
|
+
let descriptor;
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
descriptor = openRegularFileNoFollow(path, parentDirectories);
|
|
76
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
77
|
+
if (!before.isFile()) {
|
|
78
|
+
result = { ok: false, reason: "non-regular-entry" };
|
|
79
|
+
} else if (before.size > BigInt(maxBytes)) {
|
|
80
|
+
result = { ok: false, reason: "file-size", size: Number(before.size) };
|
|
81
|
+
} else if (!reverifyDirectories(parentDirectories)) {
|
|
82
|
+
result = { ok: false, reason: "file-changed" };
|
|
83
|
+
} else {
|
|
84
|
+
const content = Buffer.alloc(Number(before.size));
|
|
85
|
+
let offset = 0;
|
|
86
|
+
while (offset < content.length) {
|
|
87
|
+
const bytesRead = readSync(descriptor, content, offset, content.length - offset, null);
|
|
88
|
+
if (bytesRead === 0) break;
|
|
89
|
+
offset += bytesRead;
|
|
90
|
+
}
|
|
91
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
92
|
+
result = offset === Number(before.size) && sameStableFileSnapshot(before, after) && reverifyDirectories(parentDirectories)
|
|
93
|
+
? { ok: true, content }
|
|
94
|
+
: { ok: false, reason: "file-changed" };
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
result = { ok: false, reason: "file-read" };
|
|
98
|
+
}
|
|
99
|
+
if (descriptor !== undefined) {
|
|
100
|
+
try { closeSync(descriptor); } catch { return { ok: false, reason: "file-close" }; }
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Open a directory descriptor whose identity is pinned against symlink/rename swaps.
|
|
107
|
+
*
|
|
108
|
+
* `immutable: true` marks a traversal root/ancestor: its full stat snapshot (ctime/mtime/size/
|
|
109
|
+
* mode/nlink) must stay frozen for the life of the walk, so `reverifyDirectory` compares the
|
|
110
|
+
* whole snapshot — used for read-only scanning and bundling.
|
|
111
|
+
* `immutable: false` (default) marks an extraction destination, where only device+inode identity
|
|
112
|
+
* is pinned, since mtime/size legitimately change as files are written into it.
|
|
113
|
+
*/
|
|
114
|
+
export function openTrustedDirectory(path, { immutable = false, created = false } = {}) {
|
|
115
|
+
const before = lstatSync(path);
|
|
116
|
+
if (!before.isDirectory() || before.isSymbolicLink()) throw new Error("trusted directory target is not a real directory");
|
|
117
|
+
if (process.platform === "win32") {
|
|
118
|
+
const after = lstatSync(path);
|
|
119
|
+
if (!after.isDirectory() || after.isSymbolicLink() || !sameFileIdentity(before, after)) throw new Error("trusted directory target changed");
|
|
120
|
+
return { path, stat: after, immutable, created };
|
|
121
|
+
}
|
|
122
|
+
let descriptor;
|
|
123
|
+
try {
|
|
124
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
125
|
+
const opened = fstatSync(descriptor);
|
|
126
|
+
const after = lstatSync(path);
|
|
127
|
+
if (!opened.isDirectory() || !after.isDirectory() || after.isSymbolicLink() ||
|
|
128
|
+
!sameFileIdentity(before, opened) || !sameFileIdentity(opened, after)) throw new Error("trusted directory target changed");
|
|
129
|
+
return { path, stat: opened, descriptor, immutable, created };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (descriptor !== undefined) try { closeSync(descriptor); } catch {}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function closeDirectory(directory) {
|
|
137
|
+
if (directory?.descriptor !== undefined) closeSync(directory.descriptor);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function samePath(left, right) {
|
|
141
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function canonicalTraversalRoot(root) {
|
|
145
|
+
const lexicalRoot = resolve(root);
|
|
146
|
+
const final = lstatSync(lexicalRoot);
|
|
147
|
+
if (!final.isDirectory() || final.isSymbolicLink()) throw new Error("artifact root is not a real directory");
|
|
148
|
+
const actualRoot = realpathSync(lexicalRoot);
|
|
149
|
+
for (const lexicalBase of [resolve(process.cwd()), resolve(tmpdir())]) {
|
|
150
|
+
const rel = relative(lexicalBase, lexicalRoot);
|
|
151
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) continue;
|
|
152
|
+
if (samePath(actualRoot, resolve(realpathSync(lexicalBase), rel))) return actualRoot;
|
|
153
|
+
}
|
|
154
|
+
throw new Error("artifact root is outside a trusted canonical base");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isNonArtifactInfrastructureDirectory(name) {
|
|
158
|
+
return /^(?:node_modules|\.git)$/i.test(name);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Binary-safe, no-follow, symlink-race-proof walk of every file under a trusted root. */
|
|
162
|
+
export function walkArtifactTree(root, handlers) {
|
|
163
|
+
let rootDirectory;
|
|
164
|
+
try {
|
|
165
|
+
rootDirectory = openTrustedDirectory(canonicalTraversalRoot(root), { immutable: true });
|
|
166
|
+
} catch {
|
|
167
|
+
handlers.directoryRead(resolve(root), undefined, resolve(root));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
function report(handler, path) {
|
|
171
|
+
handler(path, undefined, rootDirectory.path);
|
|
172
|
+
}
|
|
173
|
+
function walk(directory, ancestors) {
|
|
174
|
+
const guards = [...ancestors, directory];
|
|
175
|
+
let entries;
|
|
176
|
+
try {
|
|
177
|
+
if (!reverifyDirectories(guards)) throw new Error("artifact directory changed before read");
|
|
178
|
+
entries = readdirSync(directory.path, { withFileTypes: true });
|
|
179
|
+
if (!reverifyDirectories(guards)) throw new Error("artifact directory changed after read");
|
|
180
|
+
} catch {
|
|
181
|
+
report(handlers.directoryRead, directory.path);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (!reverifyDirectories(guards)) {
|
|
186
|
+
report(handlers.directoryRead, directory.path);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const path = resolve(directory.path, entry.name);
|
|
190
|
+
let stat;
|
|
191
|
+
try { stat = lstatSync(path); } catch {
|
|
192
|
+
report(handlers.fileRead, path);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
196
|
+
if (isNonArtifactInfrastructureDirectory(entry.name)) continue;
|
|
197
|
+
let child;
|
|
198
|
+
try {
|
|
199
|
+
child = openTrustedDirectory(path, { immutable: true });
|
|
200
|
+
if (!reverifyDirectories(guards)) throw new Error("artifact ancestor changed");
|
|
201
|
+
walk(child, guards);
|
|
202
|
+
} catch {
|
|
203
|
+
report(handlers.directoryRead, path);
|
|
204
|
+
} finally {
|
|
205
|
+
if (child) try { closeDirectory(child); } catch { report(handlers.directoryRead, path); }
|
|
206
|
+
}
|
|
207
|
+
if (!reverifyDirectories(guards)) {
|
|
208
|
+
report(handlers.directoryRead, directory.path);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
} else if (stat.isFile() && !stat.isSymbolicLink()) {
|
|
212
|
+
handlers.file(path, guards, rootDirectory.path);
|
|
213
|
+
} else {
|
|
214
|
+
report(handlers.nonRegular, path);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
try { walk(rootDirectory, []); } finally {
|
|
219
|
+
try { closeDirectory(rootDirectory); } catch { report(handlers.directoryRead, rootDirectory.path); }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function missingPath(path) {
|
|
224
|
+
try { return { stat: lstatSync(path) }; } catch (error) {
|
|
225
|
+
if (error?.code === "ENOENT") return {};
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function preflightExtraction(outputDir, files) {
|
|
231
|
+
const root = openTrustedDirectory(resolve(outputDir));
|
|
232
|
+
try {
|
|
233
|
+
const directoryPaths = new Set([root.path]);
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
let path = dirname(resolve(outputDir, file.path));
|
|
236
|
+
while (path !== root.path) {
|
|
237
|
+
directoryPaths.add(path);
|
|
238
|
+
path = dirname(path);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const paths = [...directoryPaths].sort((left, right) => left.split(/[/\\]/).length - right.split(/[/\\]/).length);
|
|
242
|
+
for (const path of paths) {
|
|
243
|
+
const { stat } = missingPath(path);
|
|
244
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error("artifact destination parent is not a real directory");
|
|
245
|
+
}
|
|
246
|
+
for (const file of files) {
|
|
247
|
+
const path = resolve(outputDir, file.path);
|
|
248
|
+
if (missingPath(path).stat) throw new Error("artifact destination already exists");
|
|
249
|
+
}
|
|
250
|
+
return { root };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (root.descriptor !== undefined) try { closeSync(root.descriptor); } catch {}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Write prevalidated `{path, content}` files through descriptor-relative POSIX extraction.
|
|
258
|
+
* Windows controllers reject nonempty extraction because Node lacks handle-relative creation. */
|
|
259
|
+
export function writeExtractedFiles(outputDir, files) {
|
|
260
|
+
let preflight;
|
|
261
|
+
let succeeded = false;
|
|
262
|
+
try {
|
|
263
|
+
preflight = preflightExtraction(outputDir, files);
|
|
264
|
+
succeeded = writeExtractedFilesAnchored(preflight.root.path, files, preflight.root.stat);
|
|
265
|
+
} catch {}
|
|
266
|
+
if (preflight?.root.descriptor !== undefined) {
|
|
267
|
+
try { closeSync(preflight.root.descriptor); } catch {}
|
|
268
|
+
}
|
|
269
|
+
return succeeded;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Exclusively create `path` (no-follow, fails if it already exists) and write `content` to it,
|
|
273
|
+
* verifying identity before and after the write and unlinking on any failure. */
|
|
274
|
+
export function writeBundleSpillFile(path, content) {
|
|
275
|
+
let descriptor;
|
|
276
|
+
let stat;
|
|
277
|
+
let failure;
|
|
278
|
+
try {
|
|
279
|
+
const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
|
|
280
|
+
descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
|
|
281
|
+
stat = fstatSync(descriptor);
|
|
282
|
+
const afterOpen = lstatSync(path);
|
|
283
|
+
if (!stat.isFile() || !afterOpen.isFile() || afterOpen.isSymbolicLink() || !sameFileIdentity(stat, afterOpen)) {
|
|
284
|
+
throw new Error("platform artifact bundle changed while opening");
|
|
285
|
+
}
|
|
286
|
+
let offset = 0;
|
|
287
|
+
while (offset < content.length) {
|
|
288
|
+
const written = writeSync(descriptor, content, offset, content.length - offset);
|
|
289
|
+
if (written < 1) throw new Error("platform artifact bundle write made no progress");
|
|
290
|
+
offset += written;
|
|
291
|
+
}
|
|
292
|
+
const afterWrite = lstatSync(path);
|
|
293
|
+
if (!afterWrite.isFile() || afterWrite.isSymbolicLink() || !sameFileIdentity(stat, fstatSync(descriptor)) || !sameFileIdentity(stat, afterWrite)) {
|
|
294
|
+
throw new Error("platform artifact bundle changed while writing");
|
|
295
|
+
}
|
|
296
|
+
} catch (error) {
|
|
297
|
+
failure = error;
|
|
298
|
+
}
|
|
299
|
+
if (descriptor !== undefined) {
|
|
300
|
+
try { closeSync(descriptor); } catch (error) { failure ??= error; }
|
|
301
|
+
}
|
|
302
|
+
if (failure) {
|
|
303
|
+
if (stat) {
|
|
304
|
+
try {
|
|
305
|
+
const current = lstatSync(path);
|
|
306
|
+
if (current.isFile() && !current.isSymbolicLink() && sameFileIdentity(stat, current)) unlinkSync(path);
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
throw failure;
|
|
310
|
+
}
|
|
311
|
+
}
|