feat: release 2.7.0-rc2

This commit is contained in:
2026-10-06 15:31:51 +00:00 Unverified
parent cd9fb512cd
commit c8de9ddcba
42 changed files with 6127 additions and 593 deletions
+65 -23
View File
@@ -9,14 +9,7 @@ import {
rm,
symlink,
} from "node:fs/promises";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import { basename, dirname, isAbsolute, join, relative } from "node:path";
import {
BUILD_PROTOCOL_VERSION,
assertSha256Digest,
@@ -129,16 +122,50 @@ export function parseWorkspaceManifest(data: Uint8Array): WorkspaceManifest {
return manifest as WorkspaceManifest;
}
function safeSymlinkTarget(filePath: string, target: string): boolean {
if (
!target ||
isAbsolute(target) ||
target.includes("\\") ||
target.includes("\0")
)
return false;
const resolved = resolve("/workspace", dirname(filePath), target);
return resolved === "/workspace" || resolved.startsWith("/workspace/");
function validateSymlinkTargets(targets: Map<string, string>): void {
for (const [path, target] of targets) {
if (
!target ||
isAbsolute(target) ||
target.includes("\\") ||
target.includes("\0")
) {
throw new Error(`Unsafe symlink target for ${path}`);
}
// Resolve components in filesystem order: a symlink is expanded before
// processing the following `..`, unlike node:path.resolve's lexical result.
const pending: (string | { end: string })[] = [
...dirname(path).split("/"),
...target.split("/"),
];
const resolved: string[] = [];
const visited = new Set<string>();
while (pending.length > 0) {
const component = pending.shift()!;
if (typeof component !== "string") {
visited.delete(component.end);
continue;
}
if (!component || component === ".") continue;
if (component === "..") {
if (resolved.length === 0)
throw new Error(`Unsafe symlink target for ${path}`);
resolved.pop();
continue;
}
const candidate = [...resolved, component].join("/");
const link = targets.get(candidate);
if (link === undefined) {
resolved.push(component);
continue;
}
if (visited.has(candidate))
throw new Error(`Unsafe symlink cycle for ${path}`);
visited.add(candidate);
pending.unshift(...link.split("/"), { end: candidate });
}
}
}
export async function materializeWorkspace(
@@ -171,6 +198,21 @@ export async function materializeWorkspace(
const manifestData = await read(manifestDigest);
if (timing) timing.manifestBytes = manifestData.byteLength;
const manifest = parseWorkspaceManifest(manifestData);
const symlinkData = new Map<string, Uint8Array>();
const symlinkTargets = new Map<string, string>();
await mapConcurrent(
manifest.files.filter((file) => file.type === "symlink"),
async (file) => {
const data = await read(file.digest);
if (data.byteLength !== file.size) {
throw new Error(`Workspace blob size mismatch for ${file.path}`);
}
const link = new TextDecoder("utf-8", { fatal: true }).decode(data);
symlinkData.set(file.path, data);
symlinkTargets.set(file.path, link);
},
);
validateSymlinkTargets(symlinkTargets);
if (timing) {
timing.fileCount = manifest.files.length;
timing.fileBytes = manifest.files.reduce(
@@ -199,15 +241,15 @@ export async function materializeWorkspace(
await write(() =>
mkdir(dirname(target), { recursive: true, mode: 0o755 }),
);
const data = await read(file.digest);
const data =
file.type === "symlink"
? symlinkData.get(file.path)!
: await read(file.digest);
if (data.byteLength !== file.size) {
throw new Error(`Workspace blob size mismatch for ${file.path}`);
}
if (file.type === "symlink") {
const link = new TextDecoder("utf-8", { fatal: true }).decode(data);
if (!safeSymlinkTarget(file.path, link))
throw new Error(`Unsafe symlink target for ${file.path}`);
await write(() => symlink(link, target));
await write(() => symlink(symlinkTargets.get(file.path)!, target));
} else {
const handle = await write(() =>
open(