139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { FsSafeError } from "../src/errors.js";
|
|
import {
|
|
PRIVATE_SECRET_DIR_MODE,
|
|
PRIVATE_SECRET_FILE_MODE,
|
|
readSecretFileSync,
|
|
tryReadSecretFileSync,
|
|
writeSecretFileAtomic,
|
|
} from "../src/secret-file.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
async function tempRoot(prefix: string): Promise<string> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })));
|
|
});
|
|
|
|
function expectSecretReadCode(run: () => string, code: FsSafeError["code"]): void {
|
|
try {
|
|
run();
|
|
throw new Error("Expected readSecretFileSync to throw.");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(FsSafeError);
|
|
expect((err as FsSafeError).code).toBe(code);
|
|
}
|
|
}
|
|
|
|
describe("secret file helpers", () => {
|
|
it("reads trimmed secrets and exposes nullable try-read semantics", async () => {
|
|
const root = await tempRoot("fs-safe-secret-");
|
|
const filePath = path.join(root, "token.txt");
|
|
await fs.writeFile(filePath, " secret \n", "utf8");
|
|
|
|
expect(readSecretFileSync(filePath, "API token")).toBe("secret");
|
|
expect(tryReadSecretFileSync(filePath, "API token")).toBe("secret");
|
|
expect(tryReadSecretFileSync(undefined, "API token")).toBeUndefined();
|
|
});
|
|
|
|
it("throws structured errors for strict secret reads", async () => {
|
|
const root = await tempRoot("fs-safe-secret-errors-");
|
|
const empty = path.join(root, "empty.txt");
|
|
const big = path.join(root, "big.txt");
|
|
await fs.writeFile(empty, "\n", "utf8");
|
|
await fs.writeFile(big, "abcdef", "utf8");
|
|
|
|
expectSecretReadCode(() => readSecretFileSync("", "API token"), "invalid-path");
|
|
expectSecretReadCode(
|
|
() => readSecretFileSync(path.join(root, "missing.txt"), "API token"),
|
|
"not-found",
|
|
);
|
|
expectSecretReadCode(() => readSecretFileSync(root, "API token"), "not-file");
|
|
expectSecretReadCode(
|
|
() => readSecretFileSync(big, "API token", { maxBytes: 2 }),
|
|
"too-large",
|
|
);
|
|
expectSecretReadCode(() => readSecretFileSync(empty, "API token"), "invalid-path");
|
|
});
|
|
|
|
it("can reject symlinked secret paths", async () => {
|
|
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.`,
|
|
);
|
|
expect(tryReadSecretFileSync(link, "API token", { rejectSymlink: true })).toBeUndefined();
|
|
});
|
|
|
|
it("writes private secret files under a non-symlink root", async () => {
|
|
const root = await tempRoot("fs-safe-secret-");
|
|
const filePath = path.join(root, "nested", "token.txt");
|
|
|
|
await writeSecretFileAtomic({
|
|
rootDir: root,
|
|
filePath,
|
|
content: "secret\n",
|
|
});
|
|
|
|
expect(readSecretFileSync(filePath, "API token")).toBe("secret");
|
|
if (process.platform !== "win32") {
|
|
const dirStat = await fs.stat(path.dirname(filePath));
|
|
const fileStat = await fs.stat(filePath);
|
|
expect(dirStat.mode & 0o777).toBe(PRIVATE_SECRET_DIR_MODE);
|
|
expect(fileStat.mode & 0o777).toBe(PRIVATE_SECRET_FILE_MODE);
|
|
}
|
|
});
|
|
|
|
it("accepts stricter private secret file and directory modes", async () => {
|
|
const root = await tempRoot("fs-safe-secret-");
|
|
const filePath = path.join(root, "nested", "token.txt");
|
|
|
|
await writeSecretFileAtomic({
|
|
rootDir: root,
|
|
filePath,
|
|
content: "secret\n",
|
|
mode: 0o400,
|
|
dirMode: 0o700,
|
|
});
|
|
|
|
expect(readSecretFileSync(filePath, "API token")).toBe("secret");
|
|
if (process.platform !== "win32") {
|
|
const dirStat = await fs.stat(path.dirname(filePath));
|
|
const fileStat = await fs.stat(filePath);
|
|
expect(dirStat.mode & 0o777).toBe(0o700);
|
|
expect(fileStat.mode & 0o777).toBe(0o400);
|
|
}
|
|
});
|
|
|
|
it("rejects writes outside the private secret root", async () => {
|
|
const root = await tempRoot("fs-safe-secret-");
|
|
const outside = await tempRoot("fs-safe-secret-outside-");
|
|
|
|
await expect(
|
|
writeSecretFileAtomic({
|
|
rootDir: root,
|
|
filePath: path.join(outside, "token.txt"),
|
|
content: "secret\n",
|
|
}),
|
|
).rejects.toThrow(`Private secret path must stay under ${root}.`);
|
|
});
|
|
});
|