Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ if (
process.argv.includes(PACKAGED_ARCHIVE_PROOF_ARGUMENT)
? async () => {
const stateRoot = app.getPath("userData");
const exports = await runPackagedExportProof(stateRoot);
const exports = await runPackagedExportProof(
stateRoot,
process.argv.includes(PACKAGED_EXPORT_PROOF_ARGUMENT),
);
const report = process.argv.includes(PACKAGED_ARCHIVE_PROOF_ARGUMENT)
? await runPackagedArchiveProof(stateRoot)
: exports;
Expand Down
185 changes: 185 additions & 0 deletions apps/desktop/src/main/packaged-export-disk-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { syncBuiltinESMExports } from "node:module";
import { basename, dirname, join } from "node:path";

import { canonicalSerialize, captureJsonExport, serializeJsonExport } from "@open-chords/domain";

import { proofTreeHashes } from "./packaged-proof-tree.ts";
import { openProjectExports } from "./project-exports.ts";
import { openProjectLibrary, type ProjectLibrary } from "./project-library.ts";

// Inject ENOSPC at the filesystem boundary of the installed production service.
// This proves error handling, not actual volume exhaustion or native Save selection.
export async function runPackagedExportDiskProof(source: ProjectLibrary, stateRoot: string) {
const started = performance.now();
const stage = (name: string) =>
process.stderr.write(
`Export disk proof stage: ${name} duration_ms=${Math.round(performance.now() - started)}\n`,
);
try {
return await runDiskProof(source, stateRoot, stage);
} catch (error) {
const known = new Set([
"export_disk_proof_fixture_invalid",
"export_disk_proof_content_invalid",
"export_disk_proof_preservation_failed",
"export_disk_proof_retry_failed",
"export_disk_proof_retry_not_durable",
"export_disk_proof_retry_not_recovered",
]);
const osCodes = new Set(["ENOSPC", "ENOENT", "EACCES", "EPERM", "EIO", "EBUSY"]);
const code =
error instanceof Error && known.has(error.message)
? error.message
: error instanceof Error &&
"code" in error &&
typeof error.code === "string" &&
osCodes.has(error.code)
? error.code
: "unknown";
process.stderr.write(
`Export disk proof failure: code=${code} duration_ms=${Math.round(performance.now() - started)}\n`,
);
throw error;
}
}

async function runDiskProof(
source: ProjectLibrary,
stateRoot: string,
stage: (name: string) => void,
) {
stage("started");
const projectId = "project_golden";
const sourceBaseline = canonicalSerialize(await proofTreeHashes(stateRoot));
const fixture = await source.readProject(projectId);
const root = join(dirname(stateRoot), "packaged-export-disk-state");
const output = join(dirname(stateRoot), "packaged-export-disk-output");
const target = join(output, "existing.json");
await mkdir(output);
await writeFile(target, "existing external bytes\n");
const library = await openProjectLibrary({ stateRoot: root });
await library.createProject({
envelope: fixture.envelope,
records: { ...fixture.records, exportReceipts: [] },
});
await mkdir(join(root, "export-pending"));
const baseline = canonicalSerialize(await proofTreeHashes(root));
const targets = canonicalSerialize(await proofTreeHashes(output));
const snapshot = await library.getSnapshot(projectId);
if (!snapshot) throw new Error("export_disk_proof_fixture_invalid");
const request = {
projectId,
expectedProjectRevisionId: snapshot.projectRevisionId,
presentation: "current",
};
for (const phase of ["journal_write", "staging_write", "staging_sync"] as const) {
const service = await openProjectExports({
library,
stateRoot: root,
pickTarget: async () => target,
});
const originalOpen = fs.promises.open;
let injected = false;
let rejected = false;
try {
fs.promises.open = async (...args) => {
const file = await originalOpen(...args);
const path = String(args[0]);
const selected =
phase === "journal_write"
? dirname(path) === join(root, "export-pending") && path.endsWith(".json")
: dirname(path) === output && /^\.export_[a-f0-9]{32}\.tmp$/.test(basename(path));
if (selected && args[1] === "wx") {
const fail = () => {
injected = true;
throw Object.assign(new Error("fixture_disk_full"), { code: "ENOSPC" });
};
if (phase === "staging_sync") file.sync = async () => fail();
else {
const write = file.writeFile.bind(file);
file.writeFile = async (content) => {
// Leave real partial bytes so cleanup assertions cannot pass vacuously.
if (typeof content !== "string" && !(content instanceof Uint8Array))
throw new Error("export_disk_proof_content_invalid");
const bytes =
typeof content === "string" ? Buffer.from(content) : Buffer.from(content);
await write(bytes.subarray(0, 16));
fail();
};
}
}
return file;
};
syncBuiltinESMExports();
await service.saveJson(request);
} catch (error) {
rejected = error instanceof Error && "code" in error && error.code === "ENOSPC";
} finally {
fs.promises.open = originalOpen;
syncBuiltinESMExports();
}
const reopened = await openProjectLibrary({ stateRoot: root });
const recovered = await openProjectExports({
library: reopened,
stateRoot: root,
pickTarget: async () => target,
});
if (
!injected ||
!rejected ||
service.busy ||
service.pendingRecovery !== 0 ||
recovered.busy ||
recovered.pendingRecovery !== 0 ||
reopened.listExportReceipts(projectId).length !== 0 ||
(await reopened.getSnapshot(projectId))?.projectRevisionId !== snapshot.projectRevisionId ||
canonicalSerialize(await proofTreeHashes(root)) !== baseline ||
canonicalSerialize(await proofTreeHashes(output)) !== targets
)
throw new Error("export_disk_proof_preservation_failed");
stage(`preserved_${phase}`);
}
stage("retry_service_opening");
const retry = await openProjectExports({
library,
stateRoot: root,
pickTarget: async () => {
stage("retry_target_selected");
return target;
},
});
stage("retry_saving");
if (
(await retry.saveJson(request)).state !== "saved" ||
retry.busy ||
retry.pendingRecovery !== 0
)
throw new Error("export_disk_proof_retry_failed");
stage("retry_saved");
const reopened = await openProjectLibrary({ stateRoot: root });
stage("retry_library_reopened");
const receipts = reopened.listExportReceipts(projectId);
const bytes = await readFile(target);
if (
receipts.length !== 1 ||
receipts[0]?.outputHash !== `sha256:${createHash("sha256").update(bytes).digest("hex")}` ||
bytes.toString() !==
serializeJsonExport(captureJsonExport(snapshot.project, { presentation: "current" })) ||
(await reopened.getSnapshot(projectId))?.projectRevisionId === snapshot.projectRevisionId ||
canonicalSerialize(await proofTreeHashes(stateRoot)) !== sourceBaseline
)
throw new Error("export_disk_proof_retry_not_durable");
stage("retry_output_verified");
const recovered = await openProjectExports({
library: reopened,
stateRoot: root,
pickTarget: async () => target,
});
if (recovered.pendingRecovery !== 0 || recovered.busy)
throw new Error("export_disk_proof_retry_not_recovered");
stage("retry_recovery_verified");
return { diskFailureRefusals: 3, diskFailureRetryDurable: true };
}
7 changes: 6 additions & 1 deletion apps/desktop/src/main/packaged-export-proof.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";

import { runPackagedExportDiskProof } from "./packaged-export-disk-proof.ts";
import { runPackagedExportFailureProof } from "./packaged-export-failure-proof.ts";
import { exportTarget, openProjectExports } from "./project-exports.ts";
import { openProjectLibrary } from "./project-library.ts";
Expand All @@ -9,7 +10,7 @@ export const PACKAGED_EXPORT_PROOF_ARGUMENT = "--open-chords-export-proof";

// CI seeds only synthetic Project data in an isolated user-data directory.
// Fixed targets exercise bundled projections, not native Save dialog selection.
export async function runPackagedExportProof(stateRoot: string) {
export async function runPackagedExportProof(stateRoot: string, includeDiskFailures = true) {
const library = await openProjectLibrary({ stateRoot });
const projectId = "project_golden";
const initial = await library.getSnapshot(projectId);
Expand Down Expand Up @@ -64,6 +65,9 @@ export async function runPackagedExportProof(stateRoot: string) {
outputRoot,
initialRevisionId: initial.projectRevisionId,
});
const diskFailures = includeDiskFailures
? await runPackagedExportDiskProof(library, stateRoot)
: {};
const reopened = await openProjectLibrary({ stateRoot });
if (reopened.listExportReceipts(projectId).length !== 5)
throw new Error("export_proof_reopen_failed");
Expand All @@ -72,5 +76,6 @@ export async function runPackagedExportProof(stateRoot: string) {
cancelledWithoutRevision: true,
durableReceipts: 5,
...failures,
...diskFailures,
};
}
4 changes: 2 additions & 2 deletions tests/archive-proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ it("imports the generated archive and leaves durable Library bytes unchanged on
try {
const stateRoot = join(root, "state");
await prepareArchiveProofFixture(stateRoot);
await runPackagedExportProof(stateRoot);
await runPackagedExportProof(stateRoot, false);
expect(await runPackagedArchiveProof(stateRoot)).toEqual({
proof: "installed-archives",
roundtrip: true,
Expand All @@ -25,4 +25,4 @@ it("imports the generated archive and leaves durable Library bytes unchanged on
} finally {
await rm(root, { recursive: true, force: true });
}
});
}, 30000);
4 changes: 3 additions & 1 deletion tests/export-proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ it("exports the synthetic fixture through the production service with cancelled
durableReceipts: 5,
rejectedUnchanged: 4,
cancelledTargetUnchanged: true,
diskFailureRefusals: 3,
diskFailureRetryDurable: true,
});
expect(await readFile(join(root, "packaged-export-output/score.cho"), "utf8")).toBe(
await readFile("tests/fixtures/chordpro-golden.cho", "utf8"),
Expand All @@ -48,4 +50,4 @@ it("exports the synthetic fixture through the production service with cancelled
} finally {
await rm(root, { recursive: true, force: true });
}
});
}, 30000);
65 changes: 65 additions & 0 deletions tests/installed-export-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, it } from "vitest";

import {
installedExportExitDiagnostic,
runInstalledExportProcess,
} from "./support/installed-export-process.ts";

it("reports only bounded process status without captured private details", () => {
const error = {
code: 1,
killed: false,
signal: null,
message: "/private/path",
stderr: "private token",
stdout: "private content",
};
expect(installedExportExitDiagnostic(error, 123.4)).toBe(
"Installed export process failure: duration_ms=123 killed=false exit_code=1 exit_signal=none",
);
expect(
installedExportExitDiagnostic({ code: "private", signal: "private", killed: true }, 120001),
).toBe(
"Installed export process failure: duration_ms=120001 killed=true exit_code=none exit_signal=none",
);
expect(installedExportExitDiagnostic({ signal: "SIGTERM", killed: true }, 120100)).toContain(
"killed=true exit_code=none exit_signal=SIGTERM",
);
});

it("redacts the actual child-process rejection while retaining fixed proof stages", async () => {
let failure: unknown;
try {
await runInstalledExportProcess(
process.execPath,
[
"-e",
`
process.stderr.write("/private/provider-token\\n");
process.stderr.write("Export disk proof stage: retry_saving duration_ms=123\\n");
process.stderr.write("Export disk proof stage: private-content duration_ms=123\\n");
process.stdout.write("private stdout");
process.exit(7);
`,
],
{},
);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(Error);
if (!(failure instanceof Error)) throw new Error("fixture_child_did_not_fail");
expect(failure.message).toContain("exit_code=7");
expect(failure.message).toContain("retry_saving duration_ms=123");
for (const forbidden of [
process.execPath,
"/private",
"provider-token",
"private-content",
"private stdout",
]) {
expect(failure.message).not.toContain(forbidden);
}
expect(Object.hasOwn(failure, "cause")).toBe(false);
expect(Object.hasOwn(failure, "stderr")).toBe(false);
});
7 changes: 3 additions & 4 deletions tests/packaged/archives.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, realpath, rm, writeFile, lstat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";

import { expect, test } from "@playwright/test";
import extractZip from "extract-zip";

import { PACKAGED_ARCHIVE_PROOF_ARGUMENT } from "../../apps/desktop/src/main/packaged-archive-proof-constants.ts";
import { openProjectLibrary } from "../../apps/desktop/src/main/project-library.ts";
import { prepareArchiveProofFixture } from "../support/archive-proof-fixture.ts";
import { runInstalledExportProcess } from "../support/installed-export-process.ts";

test.skip(
process.platform !== "darwin" && process.platform !== "win32",
Expand Down Expand Up @@ -59,10 +58,10 @@ test("installed archive round-trip, cancellation and hostile corpus preserve dur
windows,
].join(";");
}
const { stdout, stderr } = await promisify(execFile)(
const { stdout, stderr } = await runInstalledExportProcess(
executable,
[PACKAGED_ARCHIVE_PROOF_ARGUMENT, `--user-data-dir=${stateRoot}`],
{ env, timeout: 120000, maxBuffer: 16384, windowsHide: true },
env,
);
expect(JSON.parse(stdout.trim())).toEqual({
proof: "installed-archives",
Expand Down
Loading
Loading