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
+263 -42
View File
@@ -14,7 +14,14 @@ import {
readFile,
} from "node:fs/promises";
import type { Stats } from "node:fs";
import { delimiter, dirname, isAbsolute, relative, resolve, sep } from "node:path";
import {
delimiter,
dirname,
isAbsolute,
relative,
resolve,
sep,
} from "node:path";
import { promisify } from "node:util";
import {
BUILD_PROTOCOL_VERSION,
@@ -30,9 +37,14 @@ export async function gitAvailable(): Promise<boolean> {
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
if (!directory) continue;
try {
await access(resolve(directory, process.platform === "win32" ? "git.exe" : "git"), constants.X_OK);
await access(
resolve(directory, process.platform === "win32" ? "git.exe" : "git"),
constants.X_OK,
);
return true;
} catch { /* Continue searching PATH. */ }
} catch {
/* Continue searching PATH. */
}
}
return false;
}
@@ -76,6 +88,36 @@ function isWithin(root: string, candidate: string): boolean {
);
}
function assertSafeSymlinkTarget(
root: string,
path: string,
target: string,
symlinks: Set<string>,
): void {
const source = resolve(root, path);
if (
target.includes("\0") ||
isAbsolute(target) ||
!isWithin(root, resolve(dirname(source), target))
)
throw new Error(`Symlink escapes workspace: ${path} -> ${target}`);
// Inspect raw components before normalization: `sub/..` still traverses
// `sub` when it is a symlink, even though it resolves lexically to `.`.
const components = path.split("/").slice(0, -1);
for (const part of target.split(sep === "\\" ? /[\\/]/ : "/")) {
if (!part || part === ".") continue;
if (part === "..") components.pop();
else {
components.push(part);
if (symlinks.has(components.join("/")))
throw new Error(
`Symlink traverses snapshot symlink: ${path} -> ${target}`,
);
}
}
}
async function gitFiles(root: string, args: string[]): Promise<string[]> {
const { stdout } = await execFileAsync(
"git",
@@ -116,16 +158,127 @@ async function selectedFiles(root: string): Promise<string[]> {
);
}
async function selectedAutoFiles(root: string): Promise<string[]> {
// ls-files includes tracked files even when they now match .gitignore.
// --no-index makes check-ignore apply the same rules to those paths too.
const candidates = await gitFiles(root, [
"--cached",
"--others",
"--exclude-standard",
]);
if (candidates.length === 0) return [];
const child = spawn("git", [
"-C",
root,
"check-ignore",
"--no-index",
"--stdin",
"-z",
]);
const output: Buffer[] = [];
const errors: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => output.push(chunk));
child.stderr.on("data", (chunk: Buffer) => errors.push(chunk));
const completed = new Promise<void>((resolveExit, rejectExit) => {
child.once("error", rejectExit);
child.once("close", (code) => {
if (code === 0 || code === 1) resolveExit();
else
rejectExit(
new Error(
Buffer.concat(errors).toString("utf8") ||
`git check-ignore exited with ${code}`,
),
);
});
});
child.stdin.end(Buffer.from(`${candidates.join("\0")}\0`));
await completed;
const decoder = new TextDecoder("utf-8", { fatal: true });
// Decode strictly, like ls-files, so an invalid path cannot silently alias another.
const ignored = new Set<string>();
const bytes = Buffer.concat(output);
let start = 0;
for (let end = bytes.indexOf(0); end !== -1; end = bytes.indexOf(0, start)) {
if (end > start) ignored.add(decoder.decode(bytes.subarray(start, end)));
start = end + 1;
}
const selected = candidates
.filter((path) => !ignored.has(path))
.sort((a, b) => Buffer.from(a).compare(Buffer.from(b)));
rejectSelectedAutoSecrets(selected);
return selected;
}
function rejectSelectedAutoSecrets(paths: string[]): void {
const unsafe = paths.find((path) => {
const parts = path.split("/");
const name = parts.at(-1)!.toLowerCase();
const directories = parts.slice(0, -1).map((part) => part.toLowerCase());
const dotenv =
/^\.env(?:$|[._-]|rc$)/.test(name) &&
!/^\.env[._-](?:example|sample|template)(?:$|[._-])/.test(name);
const credentialDirectory = directories.some((part) =>
[
".aws",
".azure",
".gnupg",
".ssh",
".kube",
".docker",
"credentials",
"secrets",
].includes(part),
);
const conventionalCredential =
[".npmrc", ".pypirc", ".netrc"].includes(name) ||
/^(?:id_(?:rsa|dsa|ecdsa|ed25519)|identity)(?:$|\.)/.test(name) ||
/^(?:credentials?|.*_credentials?)(?:\.|$)/.test(name) ||
/\.(?:pem|key|p12|pfx|keystore)$/.test(name);
return dotenv || credentialDirectory || conventionalCredential;
});
if (unsafe) {
throw new Error(
`build: auto refuses to snapshot potential credentials at ${unsafe}; add it to .gitignore and retry`,
);
}
}
async function filesystemFiles(root: string): Promise<string[]> {
const files: string[] = [];
const ignoredDirectories = new Set([
".git", ".hg", ".svn", "node_modules", "vendor", "bower_components",
".venv", "venv", "__pycache__", ".tox", ".mypy_cache", ".pytest_cache",
".next", ".nuxt", ".svelte-kit", ".cache", ".turbo", "dist", "build", "coverage",
"target", "out", "tmp", "temp",
".git",
".hg",
".svn",
"node_modules",
"vendor",
"bower_components",
".venv",
"venv",
"__pycache__",
".tox",
".mypy_cache",
".pytest_cache",
".next",
".nuxt",
".svelte-kit",
".cache",
".turbo",
"dist",
"build",
"coverage",
"target",
"out",
"tmp",
"temp",
]);
const sensitiveDirectory = /(?:^|[-_.])(?:secrets?|credentials?|configs?)(?:$|[-_.])/i;
const ignoreRules: Array<{ base: string; pattern: string; directory: boolean }> = [];
const sensitiveDirectory =
/(?:^|[-_.])(?:secrets?|credentials?|configs?)(?:$|[-_.])/i;
const ignoreRules: Array<{
base: string;
pattern: string;
directory: boolean;
}> = [];
const loadIgnore = async (directory: string): Promise<void> => {
try {
const content = await readFile(resolve(directory, ".gitignore"), "utf8");
@@ -136,7 +289,12 @@ async function filesystemFiles(root: string): Promise<string[]> {
// Git's parent-directory semantics, so fallback stays fail-closed.
if (line.startsWith("!")) continue;
const rule = line.replace(/^\//, "");
if (rule) ignoreRules.push({ base: relative(root, directory).split(sep).join("/"), pattern: rule.replace(/\/$/, ""), directory: line.endsWith("/") });
if (rule)
ignoreRules.push({
base: relative(root, directory).split(sep).join("/"),
pattern: rule.replace(/\/$/, ""),
directory: line.endsWith("/"),
});
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
@@ -147,11 +305,22 @@ async function filesystemFiles(root: string): Promise<string[]> {
for (const rule of ignoreRules) {
const prefix = rule.base ? `${rule.base}/` : "";
if (rule.base && path !== rule.base && !path.startsWith(prefix)) continue;
const local = rule.base && path.startsWith(prefix) ? path.slice(prefix.length) : path;
const glob = rule.pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "__DOUBLESTAR__").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/__DOUBLESTAR__/g, ".*");
const local =
rule.base && path.startsWith(prefix) ? path.slice(prefix.length) : path;
const glob = rule.pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "__DOUBLESTAR__")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, "[^/]")
.replace(/__DOUBLESTAR__/g, ".*");
const matcher = new RegExp(`^(?:${glob})(?:/.*)?$`);
const basenameMatcher = new RegExp(`^(?:${glob})$`);
if ((matcher.test(local) || local.split("/").some((part) => basenameMatcher.test(part))) && (!rule.directory || isDirectory || local.includes("/"))) ignored = true;
if (
(matcher.test(local) ||
local.split("/").some((part) => basenameMatcher.test(part))) &&
(!rule.directory || isDirectory || local.includes("/"))
)
ignored = true;
}
return ignored;
};
@@ -161,15 +330,30 @@ async function filesystemFiles(root: string): Promise<string[]> {
const source = resolve(directory, entry.name);
const path = relative(root, source).split(sep).join("/");
if (entry.isDirectory()) {
if (ignoredDirectories.has(entry.name) || sensitiveDirectory.test(entry.name) || ignoredByRules(path, true)) continue;
if (
ignoredDirectories.has(entry.name) ||
sensitiveDirectory.test(entry.name) ||
ignoredByRules(path, true)
)
continue;
await visit(source);
}
else if (entry.isFile() || entry.isSymbolicLink()) {
} else if (entry.isFile() || entry.isSymbolicLink()) {
if (ignoredByRules(path, false)) continue;
if (/^\.env/i.test(entry.name) || /(?:secret|credential|password|token|private[-_.]?key)/i.test(entry.name) || /^(?:id_rsa|id_ed25519|known_hosts|config\.json|\.npmrc|\.pypirc|\.netrc)$/i.test(entry.name)) continue;
if (
/^\.env/i.test(entry.name) ||
/(?:secret|credential|password|token|private[-_.]?key)/i.test(
entry.name,
) ||
/^(?:id_rsa|id_ed25519|known_hosts|config\.json|\.npmrc|\.pypirc|\.netrc)$/i.test(
entry.name,
)
)
continue;
files.push(path);
} else {
throw new Error(`Special files are not allowed in workspaces: ${relative(root, source).split(sep).join("/")}`);
throw new Error(
`Special files are not allowed in workspaces: ${relative(root, source).split(sep).join("/")}`,
);
}
}
};
@@ -187,7 +371,12 @@ async function isGitRepository(root: string): Promise<boolean> {
const exitCode = (error as { exitCode?: number }).exitCode;
const message = error instanceof Error ? error.message : String(error);
if (code === "ENOENT") return false;
if (exitCode === 128 || code === 128 || /not a git repository/i.test(message)) return false;
if (
exitCode === 128 ||
code === 128 ||
/not a git repository/i.test(message)
)
return false;
throw error;
}
}
@@ -347,15 +536,23 @@ export function validateWorkspaceManifest(manifest: WorkspaceManifest): void {
export async function enumerateWorkspace(
root: string,
mode: "default" | "auto" = "default",
): Promise<WorkspaceSnapshot> {
const repository = await realpath(root);
const gitBacked = await isGitRepository(repository);
const paths = gitBacked
? await (async () => {
await rejectSelectedSpecialFiles(repository);
return selectedFiles(repository);
})()
: await filesystemFiles(repository);
if (mode === "auto" && !gitBacked)
throw new Error(
"build: auto requires Git and a Git repository to apply .gitignore rules safely",
);
const paths =
mode === "auto"
? await selectedAutoFiles(repository)
: gitBacked
? await (async () => {
await rejectSelectedSpecialFiles(repository);
return selectedFiles(repository);
})()
: await filesystemFiles(repository);
const files: WorkspaceFile[] = [];
const blobs = new Map<Sha256Digest, Uint8Array>();
@@ -364,6 +561,19 @@ export async function enumerateWorkspace(
const source = resolve(repository, path);
if (!isWithin(repository, source))
throw new Error(`Workspace path escapes root: ${path}`);
// Git's index can retain files beneath a directory replaced by a symlink.
// A leaf O_NOFOLLOW does not protect against following that parent.
let parent = repository;
for (const part of path.split("/").slice(0, -1)) {
parent = resolve(parent, part);
try {
if (!(await lstat(parent)).isDirectory())
throw new Error(`Unsafe workspace parent: ${path}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
throw error;
}
}
let stat: Stats;
try {
@@ -377,11 +587,6 @@ export async function enumerateWorkspace(
let entry: WorkspaceFile;
if (stat.isSymbolicLink()) {
const target = await readlink(source);
if (
isAbsolute(target) ||
!isWithin(repository, resolve(dirname(source), target))
)
throw new Error(`Symlink escapes workspace: ${path} -> ${target}`);
data = Buffer.from(target);
entry = {
path,
@@ -422,6 +627,18 @@ export async function enumerateWorkspace(
version: BUILD_PROTOCOL_VERSION,
files,
} satisfies WorkspaceManifest;
const symlinks = new Set(
files.filter((file) => file.type === "symlink").map((file) => file.path),
);
for (const file of files) {
if (file.type === "symlink")
assertSafeSymlinkTarget(
repository,
file.path,
Buffer.from(blobs.get(file.digest)!).toString("utf8"),
symlinks,
);
}
return {
manifest,
digest: workspaceManifestDigest(manifest),
@@ -465,6 +682,21 @@ export async function materializeWorkspace(
reader: BlobReader,
): Promise<void> {
validateWorkspaceManifest(manifest);
const symlinks = new Set(
manifest.files
.filter((file) => file.type === "symlink")
.map((file) => file.path),
);
const linkTargets = new Map<string, string>();
for (const file of manifest.files) {
if (file.type !== "symlink") continue;
const data = await readBlob(reader, file.digest);
if (data.byteLength !== file.size || digest(data) !== file.digest)
throw new Error(`Blob verification failed for ${file.path}`);
const linkTarget = Buffer.from(data).toString("utf8");
assertSafeSymlinkTarget(destination, file.path, linkTarget, symlinks);
linkTargets.set(file.path, linkTarget);
}
await mkdir(destination, { recursive: false, mode: 0o755 });
const root = await realpath(destination);
@@ -492,19 +724,8 @@ export async function materializeWorkspace(
(entry) => entry.type === "symlink",
)) {
await ensureParentDirectories(root, file.path);
const data = await readBlob(reader, file.digest);
if (data.byteLength !== file.size || digest(data) !== file.digest)
throw new Error(`Blob verification failed for ${file.path}`);
const linkTarget = Buffer.from(data).toString("utf8");
const linkTarget = linkTargets.get(file.path)!;
const target = resolve(root, file.path);
if (
linkTarget.includes("\0") ||
isAbsolute(linkTarget) ||
!isWithin(root, resolve(dirname(target), linkTarget))
)
throw new Error(
`Symlink escapes workspace: ${file.path} -> ${linkTarget}`,
);
await symlink(linkTarget, target);
}
}