diff --git a/CHANGELOG.md b/CHANGELOG.md index a35edaf..d2b5a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/secret-file.ts b/src/secret-file.ts index 89cda2e..a4935b9 100644 --- a/src/secret-file.ts +++ b/src/secret-file.ts @@ -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 { diff --git a/test/coverage-more.test.ts b/test/coverage-more.test.ts index a9fdd30..0a2dd70 100644 --- a/test/coverage-more.test.ts +++ b/test/coverage-more.test.ts @@ -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-"); diff --git a/test/secret-file.test.ts b/test/secret-file.test.ts index 9874aa2..dc1ee04 100644 --- a/test/secret-file.test.ts +++ b/test/secret-file.test.ts @@ -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.`, ); diff --git a/tsconfig.json b/tsconfig.json index 94836c3..d91fb74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "rootDir": "src", "strict": true, "target": "ES2022", + "types": ["node"], "verbatimModuleSyntax": true }, "include": ["src/**/*.ts"]