Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6380ee11f | ||
|
|
3140f8c002 | ||
|
|
011082201d | ||
|
|
d4b12d8b93 |
No files matched your search
@@ -4,6 +4,7 @@ import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
|
||||
|
||||
import {
|
||||
EvidenceFileError,
|
||||
type OverwritePolicy,
|
||||
type PublishedEvidenceFile,
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -63,7 +64,11 @@ export const exportEvidenceBundleCommand = async (
|
||||
if (!loaded.ok) return loaded;
|
||||
return projectWrite(
|
||||
loaded.value,
|
||||
await persistEvidenceBundleCommand(loaded.value, outputPath, overwrite),
|
||||
await persistEvidenceBundleCommand(
|
||||
loaded.value,
|
||||
outputPath,
|
||||
overwrite ? "replace" : "refuse",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -130,10 +135,11 @@ export const publishEvidenceDelivery = async (input: {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}): Promise<Result<PublishedEvidenceDelivery, AnalysisError>> => {
|
||||
const { signal, operation } = input;
|
||||
// Delivery outputs have no overwrite option; refusals must not offer one.
|
||||
const written = await persistEvidenceBundleCommand(
|
||||
input.bundle,
|
||||
input.outputPath,
|
||||
false,
|
||||
"never",
|
||||
signal,
|
||||
operation,
|
||||
);
|
||||
@@ -204,7 +210,7 @@ export const inspectAnalysisViewFromBundle = async (
|
||||
const persistEvidenceBundleCommand = async (
|
||||
bundle: EvidenceBundle,
|
||||
outputPath: string,
|
||||
overwrite: boolean,
|
||||
overwrite: OverwritePolicy,
|
||||
signal?: AbortSignal,
|
||||
operation?: string,
|
||||
): Promise<Result<PublishedEvidenceFile, AnalysisError>> => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
EvidenceFileError,
|
||||
EvidenceIntegrityError,
|
||||
type OverwritePolicy,
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
@@ -121,7 +122,7 @@ const readBundle = async (
|
||||
export const writeEvidenceBundle = async (
|
||||
bundle: EvidenceBundle,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
overwrite: OverwritePolicy,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
operation = "export_evidence_bundle",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { basename, dirname, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
EvidenceFileError,
|
||||
type OverwritePolicy,
|
||||
type PublishedEvidenceFile,
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import {
|
||||
@@ -208,7 +209,7 @@ const isJsonReadCancellation = (
|
||||
export const writeTextFile = async (
|
||||
encoded: string,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
overwrite: OverwritePolicy,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<TextWriteResult<EvidenceFileError>> =>
|
||||
writeTextParts([encoded], path, overwrite, resources);
|
||||
@@ -217,20 +218,20 @@ export const writeTextFile = async (
|
||||
export function writeTextParts(
|
||||
parts: Iterable<string>,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
overwrite: OverwritePolicy,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<TextWriteResult<EvidenceFileError>>;
|
||||
export function writeTextParts(
|
||||
parts: Iterable<string>,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
overwrite: OverwritePolicy,
|
||||
resources: ArtifactResourceScope,
|
||||
cancellation: TextWriteCancellation | undefined,
|
||||
): Promise<TextWriteResult>;
|
||||
export function writeTextParts(
|
||||
parts: Iterable<string>,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
overwrite: OverwritePolicy,
|
||||
resources: ArtifactResourceScope,
|
||||
cancellation?: TextWriteCancellation,
|
||||
): Promise<TextWriteResult> {
|
||||
@@ -257,7 +258,7 @@ export function writeTextParts(
|
||||
const writeTextPartsOwned = async (
|
||||
parts: Iterable<string>,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
overwrite: OverwritePolicy,
|
||||
resources: ArtifactResourceScope,
|
||||
cancellation?: TextWriteCancellation,
|
||||
): Promise<TextWriteResult> => {
|
||||
@@ -275,9 +276,12 @@ const writeTextPartsOwned = async (
|
||||
throw cause;
|
||||
});
|
||||
if (existing !== undefined) {
|
||||
if (!overwrite)
|
||||
if (overwrite !== "replace")
|
||||
return err(
|
||||
new EvidenceFileError("write", "exists", { path: requestedPath }),
|
||||
new EvidenceFileError("write", "exists", {
|
||||
path: requestedPath,
|
||||
overwrite,
|
||||
}),
|
||||
);
|
||||
if (!existing.isFile() || existing.isSymbolicLink())
|
||||
return err(
|
||||
@@ -288,7 +292,7 @@ const writeTextPartsOwned = async (
|
||||
const bytes = await publishFile(
|
||||
destination,
|
||||
parts,
|
||||
overwrite,
|
||||
overwrite === "replace",
|
||||
resources,
|
||||
cancellation,
|
||||
);
|
||||
@@ -333,11 +337,12 @@ const writeTextPartsOwned = async (
|
||||
cause.operation === cancellation.operation
|
||||
)
|
||||
return err(cause);
|
||||
if (!overwrite && fileErrorCode(cause) === "EEXIST")
|
||||
if (overwrite !== "replace" && fileErrorCode(cause) === "EEXIST")
|
||||
return err(
|
||||
new EvidenceFileError("write", "exists", {
|
||||
cause,
|
||||
path: requestedPath,
|
||||
overwrite,
|
||||
}),
|
||||
);
|
||||
return err(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
linuxPackageManagerCommands,
|
||||
linuxSharedLibrariesAvailable,
|
||||
parseLinuxDistribution,
|
||||
readLinuxDistribution,
|
||||
supportsReaHost,
|
||||
LinuxHopperTemporaryCleanupOwner,
|
||||
systemLinuxHopperInstallHost,
|
||||
@@ -379,11 +380,14 @@ describe("Linux Hopper cleanup and failure details", () => {
|
||||
const directory = await createTestTempDirectory("rea-hopper-status-");
|
||||
const launcher = join(directory, "Hopper");
|
||||
await writeFile(launcher, "fixture launcher");
|
||||
const host = systemLinuxHopperInstallHost({
|
||||
...process.env,
|
||||
HOME: directory,
|
||||
XDG_CACHE_HOME: join(directory, "cache"),
|
||||
});
|
||||
const host = systemLinuxHopperInstallHost(
|
||||
{
|
||||
...process.env,
|
||||
HOME: directory,
|
||||
XDG_CACHE_HOME: join(directory, "cache"),
|
||||
},
|
||||
() => readLinuxDistribution(process.arch),
|
||||
);
|
||||
const command = vi
|
||||
.spyOn(nativeCommands, "execFileOutput")
|
||||
.mockResolvedValue({
|
||||
|
||||
@@ -6,9 +6,12 @@ import { err, ok } from "../domain/result.js";
|
||||
import * as pythonRuntime from "./LinuxHopperPython.js";
|
||||
import {
|
||||
linuxHopperInstallDisclosure,
|
||||
readLinuxDistribution,
|
||||
systemLinuxHopperInstallHost,
|
||||
} from "./LinuxHopper.js";
|
||||
|
||||
const hostDistribution = () => readLinuxDistribution(process.arch);
|
||||
|
||||
const missingPython = {
|
||||
interpreter: "/usr/bin/python3",
|
||||
version: [3, 14],
|
||||
@@ -80,7 +83,10 @@ describe("Linux Hopper package runtime", () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-linux-python-install-",
|
||||
);
|
||||
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
|
||||
const host = systemLinuxHopperInstallHost(
|
||||
{ XDG_CACHE_HOME: directory },
|
||||
hostDistribution,
|
||||
);
|
||||
const command = vi
|
||||
.spyOn(boundedCommands, "execFileBoundedOutput")
|
||||
.mockResolvedValue(commandSuccess);
|
||||
@@ -115,7 +121,10 @@ describe("Linux Hopper package runtime", () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-linux-python-failure-",
|
||||
);
|
||||
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
|
||||
const host = systemLinuxHopperInstallHost(
|
||||
{ XDG_CACHE_HOME: directory },
|
||||
hostDistribution,
|
||||
);
|
||||
const failed = {
|
||||
exitCode: 67,
|
||||
stdout: "original package output",
|
||||
@@ -153,7 +162,10 @@ describe("Linux Hopper package mapping", () => {
|
||||
"/usr/lib/x86_64-linux-gnu/libpython3.13.so.1.0",
|
||||
])("keeps an unproven APT mapping unknown: %s", async (path) => {
|
||||
const directory = await createTestTempDirectory("rea-python-package-map-");
|
||||
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
|
||||
const host = systemLinuxHopperInstallHost(
|
||||
{ XDG_CACHE_HOME: directory },
|
||||
hostDistribution,
|
||||
);
|
||||
const command = vi
|
||||
.spyOn(boundedCommands, "execFileBoundedOutput")
|
||||
.mockResolvedValue(commandSuccess);
|
||||
@@ -201,7 +213,10 @@ describe("Linux Hopper package cancellation and disclosure", () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-python-install-cancel-",
|
||||
);
|
||||
const host = systemLinuxHopperInstallHost({ XDG_CACHE_HOME: directory });
|
||||
const host = systemLinuxHopperInstallHost(
|
||||
{ XDG_CACHE_HOME: directory },
|
||||
hostDistribution,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const cause = new Error("caller cancelled at " + phase);
|
||||
let commandCount = 0;
|
||||
|
||||
@@ -444,10 +444,10 @@ const addCandidate = (
|
||||
...current.authority_references,
|
||||
...candidate.authority_references,
|
||||
]),
|
||||
residual_unknown_ids: uniqueBy(
|
||||
[...current.residual_unknown_ids, ...candidate.residual_unknown_ids],
|
||||
(item) => item,
|
||||
),
|
||||
residual_unknown_ids: uniqueSorted([
|
||||
...current.residual_unknown_ids,
|
||||
...candidate.residual_unknown_ids,
|
||||
]),
|
||||
// Each observation keeps its own unresolved authority and next steps.
|
||||
unavailable_authority: uniqueSorted([
|
||||
...current.unavailable_authority,
|
||||
|
||||
@@ -135,7 +135,12 @@ export const writeAnalysisSnapshot = async (
|
||||
new EvidenceIntegrityError(snapshotValidationMessage(cause), { cause }),
|
||||
);
|
||||
}
|
||||
return writeTextFile(encoded, path, overwrite, resources);
|
||||
return writeTextFile(
|
||||
encoded,
|
||||
path,
|
||||
overwrite ? "replace" : "refuse",
|
||||
resources,
|
||||
);
|
||||
};
|
||||
|
||||
const snapshotValidationMessage = (cause: unknown): string =>
|
||||
|
||||
@@ -488,6 +488,7 @@ const evidenceFileMessage = ({
|
||||
operation,
|
||||
reason,
|
||||
published,
|
||||
overwrite,
|
||||
}: EvidenceFileError): string => {
|
||||
if (published !== undefined)
|
||||
return "The output file was published, but staging cleanup remains incomplete.";
|
||||
@@ -498,7 +499,9 @@ const evidenceFileMessage = ({
|
||||
if (reason === "not-file")
|
||||
return "Evidence path does not point to a regular file. Choose a file and try again.";
|
||||
if (reason === "exists")
|
||||
return "Evidence file already exists. Choose another path or allow overwrite.";
|
||||
return overwrite === "never"
|
||||
? "Evidence file already exists, and this operation never overwrites it. Choose another path and try again."
|
||||
: "Evidence file already exists. Choose another path or allow overwrite.";
|
||||
if (reason === "invalid-json")
|
||||
return "Evidence file is not valid JSON. Repair or recreate the file and try again.";
|
||||
return "Evidence file could not be accessed. Check file permissions and try again.";
|
||||
|
||||
@@ -1020,6 +1020,29 @@ describe("analysis error projection: evidence files", () => {
|
||||
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"refuse",
|
||||
"Evidence file already exists. Choose another path or allow overwrite.",
|
||||
],
|
||||
[
|
||||
"never",
|
||||
"Evidence file already exists, and this operation never overwrites it. Choose another path and try again.",
|
||||
],
|
||||
] as const)(
|
||||
"recovers an existing destination refused by the %s policy",
|
||||
(overwrite, message) => {
|
||||
expect(
|
||||
projectAnalysisError(
|
||||
new EvidenceFileError("write", "exists", {
|
||||
path: "/selected.json",
|
||||
overwrite,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ message, remediation: { action: message } });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the primary failure and both cleanup reports after a secondary failure", () => {
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface PublishedEvidenceFile {
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a write treats an existing destination: replace it, refuse it while the
|
||||
* caller may still request replacement, or never replace it for this operation.
|
||||
*/
|
||||
export type OverwritePolicy = "replace" | "refuse" | "never";
|
||||
|
||||
/** Evidence identity, schema, or bundle manifests failed integrity checks. */
|
||||
export class EvidenceIntegrityError extends AnalysisError {
|
||||
readonly _tag = "EvidenceIntegrityError";
|
||||
@@ -54,6 +60,8 @@ export class EvidenceFileError extends AnalysisError {
|
||||
readonly published: PublishedEvidenceFile | undefined;
|
||||
/** Schema issues relative to the selected file's JSON root, never the request envelope. */
|
||||
readonly contentIssues: readonly AnalysisInputIssue[] | undefined;
|
||||
/** Policy that refused an existing destination; `never` offers no replacement. */
|
||||
readonly overwrite: Exclude<OverwritePolicy, "replace"> | undefined;
|
||||
|
||||
constructor(
|
||||
readonly operation: "read" | "write",
|
||||
@@ -69,6 +77,7 @@ export class EvidenceFileError extends AnalysisError {
|
||||
readonly published?: PublishedEvidenceFile;
|
||||
readonly userMessage?: string;
|
||||
readonly contentIssues?: readonly AnalysisInputIssue[];
|
||||
readonly overwrite?: Exclude<OverwritePolicy, "replace">;
|
||||
},
|
||||
) {
|
||||
super(`Evidence bundle ${operation} failed: ${reason}`, options);
|
||||
@@ -76,5 +85,6 @@ export class EvidenceFileError extends AnalysisError {
|
||||
this.published = options?.published;
|
||||
this.userMessage = options?.userMessage;
|
||||
this.contentIssues = options?.contentIssues;
|
||||
this.overwrite = options?.overwrite;
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export class Il2cppGhidraSeedWriter implements Il2cppSeedWriterPort {
|
||||
const written = await writeTextParts(
|
||||
[seeds.text],
|
||||
output.path,
|
||||
false,
|
||||
"never",
|
||||
this.#resources,
|
||||
options?.signal === undefined
|
||||
? undefined
|
||||
|
||||
@@ -53,7 +53,7 @@ const registerExportEvidenceTool = ({
|
||||
const written = await writeEvidenceBundle(
|
||||
bundle,
|
||||
input.path,
|
||||
input.overwrite,
|
||||
input.overwrite ? "replace" : "refuse",
|
||||
resources,
|
||||
context.mcpReq.signal,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readEvidenceBundle } from "../../../src/application/EvidenceBundleFiles.js";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
@@ -95,7 +95,8 @@ cliTest(
|
||||
const saved = await readEvidenceBundle(bundlePath);
|
||||
if (!saved.ok) throw saved.error;
|
||||
expect(saved.value.records).toEqual([evidence]);
|
||||
const overwrite = await cli.run({
|
||||
const published = await readFile(bundlePath);
|
||||
const collision = await cli.run({
|
||||
arguments: [
|
||||
"inspect-desktop-application",
|
||||
installation,
|
||||
@@ -106,7 +107,18 @@ cliTest(
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(overwrite.exitCode).not.toBe(0);
|
||||
expect(collision.exitCode).not.toBe(0);
|
||||
// --evidence_output has no overwrite flag, so recovery must not offer one.
|
||||
const neverOverwrites =
|
||||
"Evidence file already exists, and this operation never overwrites it. Choose another path and try again.";
|
||||
expect(collision.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
message: neverOverwrites,
|
||||
remediation: { action: neverOverwrites },
|
||||
details: { operation: "write", reason: "exists", path: bundlePath },
|
||||
});
|
||||
expect(await readFile(bundlePath)).toEqual(published);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -405,7 +405,13 @@ const writeBundle = (
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
withArtifactResourceScope((resources) =>
|
||||
writeEvidenceBundle(value, path, overwrite, resources, signal),
|
||||
writeEvidenceBundle(
|
||||
value,
|
||||
path,
|
||||
overwrite ? "replace" : "refuse",
|
||||
resources,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
|
||||
const writeParts = (
|
||||
@@ -418,7 +424,13 @@ const writeParts = (
|
||||
},
|
||||
) =>
|
||||
withArtifactResourceScope((resources) =>
|
||||
writeTextParts(parts, path, overwrite, resources, cancellation),
|
||||
writeTextParts(
|
||||
parts,
|
||||
path,
|
||||
overwrite ? "replace" : "refuse",
|
||||
resources,
|
||||
cancellation,
|
||||
),
|
||||
);
|
||||
|
||||
describe("evidence bundle publication", () => {
|
||||
|
||||
@@ -58,7 +58,7 @@ it("closes the descriptor and retains staged content when its final identity is
|
||||
const result = await writeTextParts(
|
||||
["owned bytes"],
|
||||
destination,
|
||||
false,
|
||||
"refuse",
|
||||
resources,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected unverified publication failure");
|
||||
@@ -107,7 +107,7 @@ it.each([false, true])(
|
||||
const result = await writeTextParts(
|
||||
["canonical ", "output"],
|
||||
destination,
|
||||
overwrite,
|
||||
overwrite ? "replace" : "refuse",
|
||||
resources,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
@@ -137,7 +137,7 @@ it("removes cancelled staging after the first write assigned its file ID", async
|
||||
const result = await writeTextParts(
|
||||
cancelledAfterFirstWrite(),
|
||||
destination,
|
||||
false,
|
||||
"refuse",
|
||||
resources,
|
||||
{ signal: controller.signal, operation: "export_evidence_bundle" },
|
||||
);
|
||||
@@ -160,7 +160,7 @@ it("reports committed output when post-publication staging cleanup remains owned
|
||||
const result = await writeTextParts(
|
||||
stagingPollutingParts(root),
|
||||
join(root, "bundle.json"),
|
||||
false,
|
||||
"refuse",
|
||||
resources,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected staged cleanup failure");
|
||||
@@ -214,7 +214,7 @@ it("preserves cancellation when staging cleanup also fails", async () => {
|
||||
const result = await writeTextParts(
|
||||
cancelledParts(),
|
||||
join(root, "cancelled.json"),
|
||||
false,
|
||||
"refuse",
|
||||
resources,
|
||||
{ signal: controller.signal, operation: "export_evidence_bundle" },
|
||||
);
|
||||
@@ -267,7 +267,7 @@ it("restores an unpublished write failure once its retained stage is released",
|
||||
const result = await writeTextParts(
|
||||
failingParts(),
|
||||
destination,
|
||||
false,
|
||||
"refuse",
|
||||
resources,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected staged write failure");
|
||||
@@ -317,7 +317,7 @@ it("clears an admission retry failure only after every retained owner releases",
|
||||
const result = await writeTextParts(
|
||||
["unused"],
|
||||
destination,
|
||||
false,
|
||||
"refuse",
|
||||
resources,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected admission retry failure");
|
||||
@@ -347,7 +347,7 @@ it("refuses to publish a staged path replaced after its descriptor opened", asyn
|
||||
const result = await writeTextParts(
|
||||
replacingStagedParts(root),
|
||||
destination,
|
||||
false,
|
||||
"refuse",
|
||||
resources,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected staged identity rejection");
|
||||
@@ -375,7 +375,7 @@ it("does not invent a staged owner when the supplied scope is closed", async ()
|
||||
const result = await writeTextParts(
|
||||
["unused"],
|
||||
destination,
|
||||
false,
|
||||
"refuse",
|
||||
resources,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected closed-scope admission failure");
|
||||
|
||||
Reference in new issue
Block a user