feat: release 2.7.0-rc2
This commit is contained in:
+263
-42
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user