fix(web)!: retain capture read cleanup through export

BREAKING CHANGE: WebScriptExportError accepts error context and residual paths in an options object.
This commit is contained in:
morluto committed 2026-10-11 06:30:56 +08:00
1 parent 67966c69a5
commit 95a77b6e25
4 files changed
+479 -85

No files matched your search

+232 -78
View File
@@ -15,9 +15,11 @@ import {
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import { safeParseJson } from "../domain/safeJson.js";
import {
@@ -27,7 +29,11 @@ import {
import { WebScriptExportError } from "../domain/webScriptExportError.js";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import { WEB_SCRIPT_EXPORT_PROVIDER as PROVIDER } from "./InvestigationProviders.js";
import { readRegularFile } from "./RegularFileRead.js";
import {
readRegularFile,
RegularFileCleanupFailure,
retryRegularFileCleanup,
} from "./RegularFileRead.js";
import {
NonRegularFileReadError,
RegularFileChangedError,
@@ -35,6 +41,20 @@ import {
const OPERATION = "export_web_scripts";
interface CaptureRead {
readonly bytes: Buffer;
readonly cleanup?: {
readonly reason: string;
readonly incomplete: boolean;
readonly cause: unknown;
};
}
interface PreparedCapture {
readonly bytes: Buffer;
readonly loaded: ReturnType<typeof selectScriptCapture>;
}
/** Export one local capture through the shared CLI/MCP application workflow. */
export const exportWebScripts = async (
rawInput: unknown,
@@ -68,14 +88,16 @@ export const exportWebScriptsValidated = async (
);
try {
options.signal?.throwIfAborted();
const read = await readCapture(input.capture_path, options.signal);
if (!read.ok) return read;
const bytes = read.value;
const loaded = parseCapture(bytes);
if (!loaded.ok) return loaded;
const prepared = await prepareCapture(
input.capture_path,
resources,
options.signal,
);
if (!prepared.ok) return prepared;
const { bytes, loaded } = prepared.value;
options.signal?.throwIfAborted();
const sha256 = createHash("sha256").update(bytes).digest("hex");
const result = await publishWebScripts(input, loaded.value, sha256, {
const result = await publishWebScripts(input, loaded, sha256, {
resources,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
@@ -97,55 +119,109 @@ export const exportWebScriptsValidated = async (
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: result.manifest.path }],
evidenceLinks:
loaded.value.sourceEvidenceId === null
? []
: [loaded.value.sourceEvidenceId],
loaded.sourceEvidenceId === null ? [] : [loaded.sourceEvidenceId],
},
),
);
} catch (cause: unknown) {
if (cause instanceof WebScriptExportError) return err(cause);
if (options.signal?.aborted === true)
return err(new AnalysisCancelledError(OPERATION));
if (cause instanceof ArtifactReaderFailure)
return err(
new WebScriptExportError(
cause.reason,
input.output_directory,
cause.message,
),
);
if (cause instanceof z.ZodError)
return err(new AnalysisOutputError(OPERATION, cause.message, { cause }));
if (
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string"
)
return err(
new WebScriptExportError(
"io",
`${input.capture_path} → ${input.output_directory}`,
cause.message,
),
);
return err(mapExportFailure(cause, input, options.signal));
}
};
const prepareCapture = async (
path: string,
resources: ArtifactResourceScope,
signal: AbortSignal | undefined,
): Promise<Result<PreparedCapture, AnalysisError>> => {
const read = await resources.run(() => readCapture(path, resources, signal));
if (!read.ok) return read;
const cleanup = read.value.cleanup;
const loaded = parseCapture(
read.value.bytes,
cleanup === undefined || cleanup.incomplete
? undefined
: { cause: cleanup.cause },
);
if (!loaded.ok)
return err(
new ProviderAdapterError(PROVIDER.id, OPERATION, {
cause,
diagnostics: {
capture_path: input.capture_path,
output_directory: input.output_directory,
error_name: cause instanceof Error ? cause.name : "UnknownError",
error_message:
cause instanceof Error
? cause.message
: typeof cause === "string"
? cause
: "Unknown script export failure",
},
}),
cleanup?.incomplete === true
? incompleteCaptureCleanup(loaded.error, path, cleanup)
: loaded.error,
);
if (cleanup !== undefined) {
const primary = new WebScriptExportError(
"io",
path,
`Capture handle close failed: ${cleanup.reason}`,
{ cause: cleanup.cause },
);
return err(
cleanup.incomplete
? incompleteCaptureCleanup(primary, path, cleanup)
: primary,
);
}
return ok({ bytes: read.value.bytes, loaded: loaded.value });
};
const mapExportFailure = (
cause: unknown,
input: ExportWebScriptsInput,
signal: AbortSignal | undefined,
): AnalysisError => {
if (cause instanceof WebScriptExportError) return cause;
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
return analysisErrorWithCleanupFailure(
new WebScriptExportError(
cause.reason,
input.output_directory,
cause.message,
{ cause },
),
new ProviderCleanupError(
PROVIDER.id,
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ cause, operation: OPERATION },
),
OPERATION,
);
if (signal?.aborted === true)
return new AnalysisCancelledError(OPERATION, { cause });
if (cause instanceof ArtifactReaderFailure)
return new WebScriptExportError(
cause.reason,
input.output_directory,
cause.message,
{ cause },
);
if (cause instanceof z.ZodError)
return new AnalysisOutputError(OPERATION, cause.message, { cause });
if (
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string"
)
return new WebScriptExportError(
"io",
`${input.capture_path} → ${input.output_directory}`,
cause.message,
{ cause },
);
return new ProviderAdapterError(PROVIDER.id, OPERATION, {
cause,
diagnostics: {
capture_path: input.capture_path,
output_directory: input.output_directory,
error_name: cause instanceof Error ? cause.name : "UnknownError",
error_message:
cause instanceof Error
? cause.message
: typeof cause === "string"
? cause
: "Unknown script export failure",
},
});
};
/**
@@ -154,45 +230,115 @@ export const exportWebScriptsValidated = async (
*/
const readCapture = async (
path: string,
resources: ArtifactResourceScope,
signal: AbortSignal | undefined,
): Promise<Result<Buffer, AnalysisError>> => {
): Promise<Result<CaptureRead, AnalysisError>> => {
try {
return ok(await readRegularFile(path, { signal }));
return ok({ bytes: await readRegularFile(path, { signal }) });
} catch (cause: unknown) {
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
return err(
new AnalysisAccessDeniedError(OPERATION, path, code, { cause }),
if (cause instanceof RegularFileCleanupFailure) {
const retry = await retryRegularFileCleanup(cause, resources);
const cleanup = {
reason: retry.cleanup?.reason ?? errorMessage(cause.cleanupCause),
incomplete: retry.cleanup !== undefined,
cause: cause.cleanupCause,
};
if (cause.outcome.kind === "completed")
return ok({ bytes: cause.outcome.value as Buffer, cleanup });
const primary = captureReadFailure(
path,
cause.outcome.cause,
signal,
retry.cleanup === undefined ? { cause: cause.cleanupCause } : undefined,
);
if (
cause instanceof NonRegularFileReadError ||
cause instanceof RegularFileChangedError ||
code === "ENOENT" ||
code === "ENOTDIR" ||
code === "EISDIR" ||
code === "ENXIO"
)
return err(
new AnalysisInputError(OPERATION, { cause }, [
{
path: ["capture_path"],
reason: "invalid_value",
message:
cause instanceof NonRegularFileReadError && code !== "EISDIR"
? cause.message
: code === "EISDIR"
? `Selected capture is a directory, not a file: ${path}`
: `Selected capture could not be read (${code}): ${path}`,
},
]),
retry.cleanup === undefined
? primary
: incompleteCaptureCleanup(primary, path, cleanup),
);
throw cause;
}
return err(captureReadFailure(path, cause, signal));
}
};
const captureReadFailure = (
path: string,
cause: unknown,
signal: AbortSignal | undefined,
closeFailure?: { readonly cause: unknown },
): AnalysisError => {
const causeWithClose =
closeFailure === undefined
? cause
: new AggregateError(
[cause, closeFailure.cause],
"Capture read and file-handle close both failed",
{ cause },
);
if (signal?.aborted === true)
return new AnalysisCancelledError(OPERATION, { cause: causeWithClose });
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
return new AnalysisAccessDeniedError(OPERATION, path, code, {
cause: causeWithClose,
});
if (
cause instanceof NonRegularFileReadError ||
cause instanceof RegularFileChangedError ||
code === "ENOENT" ||
code === "ENOTDIR" ||
code === "EISDIR" ||
code === "ENXIO"
)
return new AnalysisInputError(OPERATION, { cause: causeWithClose }, [
{
path: ["capture_path"],
reason: "invalid_value",
message:
cause instanceof NonRegularFileReadError && code !== "EISDIR"
? cause.message
: code === "EISDIR"
? `Selected capture is a directory, not a file: ${path}`
: `Selected capture could not be read (${code}): ${path}`,
},
]);
if (code !== "")
return new WebScriptExportError("io", path, errorMessage(cause), {
cause: causeWithClose,
});
return new ProviderAdapterError(PROVIDER.id, OPERATION, {
cause: causeWithClose,
diagnostics: {
capture_path: path,
error_name: cause instanceof Error ? cause.name : "UnknownError",
error_message: cause instanceof Error ? cause.message : String(cause),
},
});
};
const incompleteCaptureCleanup = (
primary: AnalysisError,
path: string,
cleanup: NonNullable<CaptureRead["cleanup"]>,
): AnalysisError =>
analysisErrorWithCleanupFailure(
primary,
new ProviderCleanupError(
PROVIDER.id,
[path],
{ reason: cleanup.reason },
{ cause: cleanup.cause, operation: OPERATION },
),
OPERATION,
);
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const parseCapture = (
bytes: Buffer,
closeFailure?: { readonly cause: unknown },
): Result<ReturnType<typeof selectScriptCapture>, AnalysisError> => {
try {
const text = new TextDecoder("utf-8", {
@@ -203,8 +349,16 @@ const parseCapture = (
if (!json.ok) throw new TypeError(json.error);
return ok(selectScriptCapture(json.value));
} catch (cause: unknown) {
const causeWithClose =
closeFailure === undefined
? cause
: new AggregateError(
[cause, closeFailure.cause],
"Capture parse and file-handle close both failed",
{ cause },
);
return err(
new AnalysisInputError(OPERATION, { cause }, [
new AnalysisInputError(OPERATION, { cause: causeWithClose }, [
{
path: ["capture_path"],
reason: "invalid_format",