Resolve the filesystem-selected manifest parent once before deriving script paths and reuse the shared containment rule. Preserve selected manifest spelling and capture metadata while retaining source integrity, symlink refusal and cleanup. Cover differing and identical lexical siblings through both artifact consumers and public CLI/MCP parity.
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
import { lstat, realpath } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
|
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
|
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
|
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
|
|
import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js";
|
|
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
|
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
|
import { isPathWithinRoot } from "../../domain/localPath.js";
|
|
import { err, ok } from "../../domain/result.js";
|
|
import type { SelectedWebScriptInput } from "../../domain/webScriptArtifacts.js";
|
|
import { webScriptExportManifestSchema } from "../../domain/webScriptExport.js";
|
|
import {
|
|
decodeWebArtifact,
|
|
readWebArtifactJson,
|
|
webArtifactReadError,
|
|
WebArtifactFormatFailure,
|
|
type WebArtifactReadContext,
|
|
} from "./WebArtifactReads.js";
|
|
|
|
/** Read one digest-verified captured source under the manifest's current location. */
|
|
export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
|
|
constructor(
|
|
readonly operation: WebArtifactReadContext["operation"],
|
|
readonly resources = new ArtifactResourceScope(),
|
|
) {}
|
|
|
|
close(): Promise<void> {
|
|
return this.resources.close();
|
|
}
|
|
|
|
/** Read the selected source; relocated exports retain their reported original root. */
|
|
async load(input: SelectedWebScriptInput, options?: ExecutionOptions) {
|
|
try {
|
|
return await this.resources.run(() => this.#load(input, options));
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
webArtifactReadError(
|
|
cause,
|
|
{
|
|
operation: this.operation,
|
|
field: ["manifest_path"],
|
|
targetPath: input.manifest_path,
|
|
callerSelected: true,
|
|
},
|
|
options?.signal,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
async #load(input: SelectedWebScriptInput, options?: ExecutionOptions) {
|
|
let field: readonly (string | number)[] = ["manifest_path"];
|
|
let targetPath = input.manifest_path;
|
|
let callerSelected = true;
|
|
try {
|
|
const manifestBytes = await readWebArtifactJson(
|
|
input.manifest_path,
|
|
32 * 1024 * 1024,
|
|
options?.signal,
|
|
this.resources,
|
|
);
|
|
const manifest = webScriptExportManifestSchema.parse(manifestBytes.value);
|
|
field = ["script_index"];
|
|
const selected = manifest.scripts[input.script_index];
|
|
if (selected === undefined)
|
|
throw new WebArtifactFormatFailure(
|
|
`No script at index ${String(input.script_index)}; manifest contains ${String(manifest.scripts.length)} scripts.`,
|
|
);
|
|
if (selected.content.state !== "exported")
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"web-module-artifacts",
|
|
this.operation,
|
|
"selected_source_unavailable",
|
|
{
|
|
userMessage: `Selected script ${String(input.script_index)} has unavailable source bytes (${selected.content.reason}): ${selected.content.message} Capture source bytes before tracing this script.`,
|
|
},
|
|
),
|
|
);
|
|
const portable = normalizeArtifactPath(selected.content.relative_path);
|
|
if (portable !== selected.content.relative_path)
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
`Selected source path changes during normalization: ${selected.content.relative_path}`,
|
|
);
|
|
if (process.platform === "win32" && portable.includes("\\"))
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
`Windows source path cannot contain backslashes as literal name characters: ${JSON.stringify(portable)}`,
|
|
);
|
|
const selectedParent = dirname(input.manifest_path);
|
|
callerSelected = false;
|
|
targetPath = selectedParent;
|
|
const root = await realpath(selectedParent);
|
|
const sourcePath = join(root, "files", portable);
|
|
targetPath = sourcePath;
|
|
await assertContained(root, sourcePath);
|
|
const sourceBytes = await readStableArtifact(
|
|
sourcePath,
|
|
16 * 1024 * 1024,
|
|
options?.signal,
|
|
undefined,
|
|
this.resources,
|
|
);
|
|
await assertContained(root, sourcePath);
|
|
if (
|
|
sourceBytes.sha256 !== selected.content.sha256 ||
|
|
sourceBytes.bytes.length !== selected.content.bytes
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Selected source identity mismatch: ${sourcePath}; expected sha256=${selected.content.sha256}, bytes=${String(selected.content.bytes)}; observed sha256=${sourceBytes.sha256}, bytes=${String(sourceBytes.bytes.length)}.`,
|
|
undefined,
|
|
{
|
|
logicalPath: selected.content.relative_path,
|
|
declaredSha256: selected.content.sha256,
|
|
calculatedSha256: sourceBytes.sha256,
|
|
unpacked: false,
|
|
},
|
|
);
|
|
const source = decodeWebArtifact(sourceBytes.bytes);
|
|
return ok({
|
|
manifest,
|
|
manifestFile: {
|
|
path: input.manifest_path,
|
|
sha256: manifestBytes.sha256,
|
|
bytes: manifestBytes.bytes.length,
|
|
},
|
|
sourceFile: {
|
|
path: sourcePath,
|
|
sha256: sourceBytes.sha256,
|
|
bytes: sourceBytes.bytes.length,
|
|
},
|
|
source,
|
|
});
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
webArtifactReadError(
|
|
cause,
|
|
{ operation: this.operation, field, targetPath, callerSelected },
|
|
options?.signal,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const assertContained = async (root: string, path: string): Promise<void> => {
|
|
const canonicalFile = await realpath(path);
|
|
if (!isPathWithinRoot(root, canonicalFile))
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
`Selected source escapes its manifest directory: ${path}`,
|
|
);
|
|
let directory = dirname(path);
|
|
while (directory !== root) {
|
|
const stat = await lstat(directory);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
`Selected source directory is a symlink or not a directory: ${directory}`,
|
|
);
|
|
const parent = dirname(directory);
|
|
if (parent === directory)
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
"Selected source has no containing manifest directory.",
|
|
);
|
|
directory = parent;
|
|
}
|
|
};
|