fix: isolate prepack types and secret symlink reads

This commit is contained in:
Peter Steinberger 2026-05-08 08:41:57 +01:00
parent 638999ca7c
commit 66a4dfba0e
No known key found for this signature in database
5 changed files with 79 additions and 6 deletions

View File

@ -5,6 +5,8 @@
### Fixes
- Align POSIX and Windows handling for literal `..`-prefixed write targets, preserve whitespace in direct home-relative path inputs, and run the check suite on Windows CI. (#14; thanks @sjf)
- Keep source prepack builds isolated from parent monorepo ambient type packages such as Bun typings. (#13; thanks @Kaspre)
- Let secret-file reads follow symlink paths through the pinned real target unless callers opt into `rejectSymlink: true`.
## 0.2.0 - 2026-05-07

View File

@ -55,12 +55,26 @@ function readSecretFileOutcomeSync(
};
}
if (options.rejectSymlink && previewStat.isSymbolicLink()) {
return {
ok: false,
code: "symlink",
message: `${label} file at ${resolvedPath} must not be a symlink.`,
};
if (previewStat.isSymbolicLink()) {
if (!options.rejectSymlink) {
try {
previewStat = fs.statSync(resolvedPath);
} catch (error) {
const normalized = normalizeSecretReadError(error);
return {
ok: false,
code: (error as NodeJS.ErrnoException).code === "ENOENT" ? "not-found" : "invalid-path",
error: normalized,
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
};
}
} else {
return {
ok: false,
code: "symlink",
message: `${label} file at ${resolvedPath} must not be a symlink.`,
};
}
}
if (!previewStat.isFile()) {
return {

View File

@ -2,7 +2,17 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createBoundedReadStream, createMaxBytesTransform } from "../src/bounded-read-stream.js";
import {
assertAsyncDirectoryGuard,
assertSyncDirectoryGuard,
createAsyncDirectoryGuard,
createNearestExistingDirectoryGuard,
createNearestExistingSyncDirectoryGuard,
createSyncDirectoryGuard,
} from "../src/directory-guard.js";
import { drainFileLockManagerForTest, resetFileLockManagerForTest } from "../src/file-lock.js";
import { sameFileIdentity } from "../src/file-identity.js";
import { readLocalFileFromRoots, resolveLocalPathFromRootsSync } from "../src/local-roots.js";
@ -138,6 +148,46 @@ describe("small identity and lock wrappers", () => {
});
});
describe("bounded streams and directory guard coverage", () => {
it("returns raw streams without limits and rejects oversized limited streams", async () => {
const raw = Readable.from(["ok"]);
const returned = createBoundedReadStream({ handle: { createReadStream: () => raw } }, undefined);
expect(returned).toBe(raw);
await expect(async () => {
for await (const _chunk of Readable.from(["ab", "cd"]).pipe(createMaxBytesTransform(3))) {
// Drain the stream so transform errors surface.
}
}).rejects.toMatchObject({ code: "too-large" });
});
it("detects changed or invalid directory guards", async () => {
const root = await tempRoot("fs-safe-dir-guard-more-");
const nested = path.join(root, "nested");
const filePath = path.join(root, "file.txt");
await fs.mkdir(nested);
await fs.writeFile(filePath, "not a dir", "utf8");
await expect(createAsyncDirectoryGuard(filePath)).rejects.toMatchObject({ code: "not-file" });
expect(() => createSyncDirectoryGuard(filePath)).toThrow("directory component");
const asyncGuard = await createAsyncDirectoryGuard(nested);
const syncGuard = createSyncDirectoryGuard(nested);
await fs.rm(nested, { recursive: true });
await fs.mkdir(nested);
await expect(assertAsyncDirectoryGuard(asyncGuard)).rejects.toMatchObject({
code: "path-mismatch",
});
expect(() => assertSyncDirectoryGuard(syncGuard)).toThrow("directory changed");
const nearest = await createNearestExistingDirectoryGuard(root, path.join(root, "missing", "x"));
expect(nearest.dir).toBe(root);
expect(createNearestExistingSyncDirectoryGuard(root, path.join(root, "missing", "x")).dir)
.toBe(root);
});
});
describe("sibling temp coverage", () => {
it("syncs temp files and parent dirs when requested", async () => {
const root = await tempRoot("fs-safe-sibling-more-");

View File

@ -68,9 +68,15 @@ describe("secret file helpers", () => {
const root = await tempRoot("fs-safe-secret-");
const target = path.join(root, "target.txt");
const link = path.join(root, "link.txt");
const broken = path.join(root, "broken.txt");
await fs.writeFile(target, "secret", "utf8");
await fs.symlink(target, link);
await fs.symlink(path.join(root, "missing.txt"), broken);
expect(readSecretFileSync(link, "API token")).toBe("secret");
expect(tryReadSecretFileSync(link, "API token")).toBe("secret");
expectSecretReadCode(() => readSecretFileSync(broken, "API token"), "not-found");
expect(tryReadSecretFileSync(broken, "API token")).toBeUndefined();
expect(() => readSecretFileSync(link, "API token", { rejectSymlink: true })).toThrow(
`API token file at ${link} must not be a symlink.`,
);

View File

@ -11,6 +11,7 @@
"rootDir": "src",
"strict": true,
"target": "ES2022",
"types": ["node"],
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]