Files
SeedPass/js/packages/cli/test/durability.test.ts
T
thePR0M3TH3ANandClaude Opus 5 e2e9aa851e Fix what three platforms found: two shipped bugs, four unportable tests
The platform workflow's first run produced seventeen failures on Windows and
macOS, where the JavaScript suite had never executed. Two were real defects
in shipped code.

A CONTAINMENT CHECK THAT REFUSED EVERYTHING ON WINDOWS

`resolveWithinProfile` compared `target.startsWith(root + "/")` with a
hardcoded forward slash. On Windows `resolve()` returns
`C:\profile\file.txt` and the comparison asked for `C:\profile/`, so it
matched nothing: document export and import refused every path, including
legitimate ones. It failed CLOSED, so nothing leaked — the feature simply did
not work. Now uses `relative()`, which needs no separator of our choosing.

A COMMAND PARSER THAT ATE WINDOWS PATHS

`parseCommandSpec` treated backslash as a POSIX escape unconditionally, so
`--exec "C:\Program Files\tool.exe"` was spawned as the command `C:Program`
with an argument `Filestool.exe`. Every Windows path handed to --exec or
--stdin-to was mangled before the spawn. This is the SECOND instance of this
exact bug today — the Python TUI palette had it too — which is what a whole
class looks like rather than a one-off.

`windows` is an explicit parameter, not a `process.platform` read, so both
branches are tested from either platform. That parameter is the actual fix
for the class: the reason these survived is that the Windows branch could
only ever run on Windows.

THE REST WERE TESTS, NOT CODE

- `/bin/true` does not exist on macOS (it is /usr/bin/true), so a spawn failed
  with ENOENT and the refusal looked like the daemon's crash-safety fix having
  regressed. Now uses process.execPath, which exists everywhere by definition.
- The oversized-request test treated its own EPIPE as fatal. The daemon caps
  the line, answers and hangs up; on macOS that hangup lands while the client
  is still pushing the remaining megabyte. Correct server behaviour was
  failing the test on one platform and passing on the other.
- Three sink tests wrote `#!/bin/sh` files. Shebangs and /bin/sh do not exist
  on Windows. Now Node scripts run by the interpreter already running the
  tests.
- Eight `0600` assertions and two clipboard tests are guarded by documented
  constants in test/helpers/platform.ts. Windows has no POSIX mode bits and
  the runner has no clipboard.

The skips state a gap rather than closing it, and TODO.md now records that the
Windows permissions question grew from two Python tests to ten across both
languages — including the vault index and the semantic index, which hold
secrets. With TypeScript becoming the only implementation that is a shipping
question, not a test-suite one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 14:39:52 -04:00

153 lines
6.2 KiB
TypeScript

/**
* Durability and input-validation guarantees for vault writes.
*
* From a security review: a bad numeric option once persisted NaN as JSON
* null and made every later open fail; writes truncated the target in place
* with no lock, so a crash or a concurrent command could lose the vault.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { NO_POSIX_PERMISSIONS } from "./helpers/platform.js";
import { mkdtemp, readFile, writeFile, stat, chmod } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { mnemonics, entriesIndex } from "@seedpass/test-vectors";
import { deriveIndexKeyBytes, encryptV3, utf8 } from "@seedpass/core";
import { buildProgram, AppDir, type ProgramIo } from "../src/index.js";
const MNEMONIC = mnemonics["abandon12"]!;
let vaultPath: string;
let dir: string;
async function run(...argv: string[]): Promise<{ stdout: string; error?: unknown }> {
const out: string[] = [];
const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} };
let error: unknown;
const saved = process.env["SEEDPASS_MNEMONIC"];
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
try {
await buildProgram(io).parseAsync(["node", "seedpass-js", ...argv]);
} catch (e) {
error = e;
} finally {
if (saved === undefined) delete process.env["SEEDPASS_MNEMONIC"];
else process.env["SEEDPASS_MNEMONIC"] = saved;
}
return { stdout: out.join("\n"), error };
}
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "seedpass-durability-"));
vaultPath = join(dir, "vault.enc");
await writeFile(
vaultPath,
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(entriesIndex.entries))),
);
});
describe("numeric input validation", () => {
it("rejects a non-numeric length instead of persisting NaN", async () => {
const before = await readFile(vaultPath);
const r = await run("--vault", vaultPath, "entry", "add", "password", "bad", "--length", "not-a-number");
expect(String((r.error as Error).message)).toContain("whole number");
// The vault is untouched and still opens
expect(await readFile(vaultPath)).toEqual(before);
expect((await run("--vault", vaultPath, "entry", "list")).error).toBeUndefined();
});
it("rejects out-of-range and fractional values", async () => {
for (const bad of ["0", "5", "999", "16.5", "1e3", "", " "]) {
const r = await run("--vault", vaultPath, "entry", "add", "password", "bad", "--length", bad);
expect(r.error, `--length ${JSON.stringify(bad)} should be refused`).toBeTruthy();
}
const totp = await run(
"--vault", vaultPath, "entry", "add", "totp", "bad", "--period", "abc",
);
expect(String((totp.error as Error).message)).toContain("whole number");
});
it("still accepts valid values", async () => {
const r = await run("--vault", vaultPath, "entry", "add", "password", "good", "--length", "24");
expect(r.error).toBeUndefined();
expect(JSON.parse(r.stdout).length).toBe(24);
});
});
describe("atomic, permission-correct writes", () => {
it.skipIf(NO_POSIX_PERMISSIONS)("leaves no temp files and keeps the vault at 0600", async () => {
await chmod(vaultPath, 0o644); // simulate a loosened file
await run("--vault", vaultPath, "entry", "add", "key-value", "perm", "k", "v");
const info = await stat(vaultPath);
// writeFile's mode only applies on creation, so this must be repaired
expect(info.mode & 0o077).toBe(0);
const leftovers = (await import("node:fs/promises")).readdir(dir);
expect((await leftovers).filter((f) => f.includes(".tmp"))).toEqual([]);
expect(existsSync(`${vaultPath}.lock`)).toBe(false);
});
it("survives concurrent writers without losing entries", async () => {
const labels = ["c1", "c2", "c3", "c4", "c5"];
await Promise.all(
labels.map((label) =>
run("--vault", vaultPath, "entry", "add", "key-value", label, "k", "v"),
),
);
const rows = JSON.parse((await run("--vault", vaultPath, "entry", "list")).stdout) as {
label: string;
}[];
const seen = rows.map((r) => r.label);
// Without locking, concurrent read-modify-write cycles silently drop
// entries; every one of these must be present.
for (const label of labels) expect(seen).toContain(label);
});
});
describe("secrets on the command line", () => {
it("warns when a secret arrives as an argument", async () => {
const out: string[] = [];
const err: string[] = [];
const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) };
const saved = process.env["SEEDPASS_MNEMONIC"];
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
try {
await buildProgram(io).parseAsync([
"node", "seedpass-js", "--vault", vaultPath,
"entry", "add", "key-value", "argv-secret", "k", "exposed-on-argv",
]);
} finally {
if (saved === undefined) delete process.env["SEEDPASS_MNEMONIC"];
else process.env["SEEDPASS_MNEMONIC"] = saved;
}
expect(err.join("\n")).toContain("shell history");
// The warning must not repeat the secret it is warning about
expect(err.join("\n")).not.toContain("exposed-on-argv");
});
});
describe("profile path validation", () => {
it("refuses a fingerprint that is not 16 hex characters", async () => {
const app = new AppDir(dir);
expect(() => app.profileDir("../../etc")).toThrow(/invalid profile fingerprint/);
await expect(app.removeProfile("../../etc")).rejects.toThrow(/invalid profile fingerprint/);
await expect(app.switchProfile("nope")).rejects.toThrow(/invalid profile fingerprint/);
});
it("ignores malformed entries in the fingerprint registry", async () => {
const appRoot = await mkdtemp(join(tmpdir(), "seedpass-registry-"));
await writeFile(
join(appRoot, "fingerprints.json"),
JSON.stringify({
fingerprints: ["../../evil", "0123456789ABCDEF"],
last_used: "../../evil",
names: {},
}),
);
const app = new AppDir(appRoot);
const data = await app.readFingerprints();
expect(data.fingerprints).toEqual(["0123456789ABCDEF"]);
expect(data.last_used).toBeNull();
});
});