fix(artifacts): bind operations to admitted sources
This commit is contained in:
1 parent
ace4dbf36e
commit
2dd2ffcd45
20 files changed
+704
-238
No files matched your search
@@ -158,6 +158,7 @@ const isArtifactBundleSubject = (
|
||||
subject: EvidenceSubjectTarget,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
target.bundleInfoPlist !== undefined &&
|
||||
(operation === "inventory_artifact" ||
|
||||
operation === "inspect_artifact" ||
|
||||
operation === "extract_artifact") &&
|
||||
|
||||
@@ -311,6 +311,10 @@ class ArtifactClient implements AnalysisClient {
|
||||
const result = await extractArtifact(
|
||||
{
|
||||
inputPath: this.target.sourcePath ?? this.target.path,
|
||||
expectedRootSha256:
|
||||
this.target.bundleInfoPlist === undefined
|
||||
? this.target.sha256
|
||||
: undefined,
|
||||
selectedPath: this.target.artifactSelectedPath,
|
||||
inputFormat: this.target.format,
|
||||
outputRoot: parsed.output_root,
|
||||
@@ -506,6 +510,10 @@ class ArtifactClient implements AnalysisClient {
|
||||
options?: ExecutionOptions,
|
||||
) {
|
||||
return inventoryArtifact(this.target.sourcePath ?? this.target.path, {
|
||||
expectedRootSha256:
|
||||
this.target.bundleInfoPlist === undefined
|
||||
? this.target.sha256
|
||||
: undefined,
|
||||
selectedPath: this.target.artifactSelectedPath,
|
||||
resourceScope: this.#resourceScope,
|
||||
environment: this.environment,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
/** Verify selection spelling independently of canonical descriptor ownership. */
|
||||
export const assertArtifactSelectedSource = async (
|
||||
canonicalPath: string,
|
||||
selectedPath: string | undefined,
|
||||
): Promise<void> => {
|
||||
if (selectedPath === undefined) return;
|
||||
const changed = (cause?: unknown) =>
|
||||
new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Selected artifact no longer resolves to its admitted source: ${selectedPath}`,
|
||||
{
|
||||
userMessage:
|
||||
"The selected artifact path no longer names the opened file. Reopen the selected artifact, then retry.",
|
||||
...(cause === undefined ? {} : { cause }),
|
||||
},
|
||||
);
|
||||
let current: string;
|
||||
try {
|
||||
current = await realpath(selectedPath);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code))
|
||||
)
|
||||
throw changed(cause);
|
||||
throw cause;
|
||||
}
|
||||
if (current !== canonicalPath) throw changed();
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertArtifactSelectedSource } from "./ArtifactSelectedSource.js";
|
||||
import { appendCallerPath } from "../filesystem/callerPath.js";
|
||||
import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
|
||||
import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
|
||||
@@ -437,12 +438,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
// The packed pathname is fixed for this reader; member checks only need
|
||||
// to re-observe the caller's independently selected alias.
|
||||
this.#selectedCanonicalSource ??= await realpath(this.path);
|
||||
const current = await realpath(selected);
|
||||
if (this.#selectedCanonicalSource !== current)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Selected ASAR archive no longer resolves to its admitted source: ${selected}`,
|
||||
);
|
||||
await assertArtifactSelectedSource(this.#selectedCanonicalSource, selected);
|
||||
}
|
||||
|
||||
async #openUnpackedStream(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { assertAdmittedRootDigest } from "../inventory/classify.js";
|
||||
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
|
||||
import { hashStableRootArtifactHandle } from "../inventory/hashStableRootArtifact.js";
|
||||
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import {
|
||||
@@ -56,7 +59,8 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity
|
||||
export interface ArtifactExtractionInput {
|
||||
readonly asarHeaderAdmission?: AsarHeaderAdmission;
|
||||
readonly inputPath: string;
|
||||
/** Selected ASAR spelling; packed I/O remains on inputPath. */
|
||||
readonly expectedRootSha256?: string | undefined;
|
||||
/** Selected artifact spelling; packed I/O remains on inputPath. */
|
||||
readonly selectedPath?: string | undefined;
|
||||
readonly inputFormat: BinaryTarget["format"];
|
||||
readonly outputRoot: string;
|
||||
@@ -79,6 +83,8 @@ const extractArtifactInScope = async (
|
||||
// Cancellation wins over every later refusal, including unsupported formats.
|
||||
abortIfNeeded(signal);
|
||||
const sourcePath = await realpath(input.inputPath);
|
||||
if (input.expectedRootSha256 !== undefined)
|
||||
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
|
||||
const admission = await requireExtractableFormat(sourcePath, input, signal);
|
||||
const { snapshot, zipSource } =
|
||||
await scanArtifactInventoryForExtractionInScope(
|
||||
@@ -86,6 +92,7 @@ const extractArtifactInScope = async (
|
||||
{
|
||||
resourceScope: input.resourceScope,
|
||||
selectedPath: input.selectedPath,
|
||||
expectedRootSha256: input.expectedRootSha256,
|
||||
signal,
|
||||
environment: input.environment,
|
||||
asarHeaderAdmission:
|
||||
@@ -176,7 +183,7 @@ export const materializeArtifactInventoryInScope = async (
|
||||
)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
input.selectedPath ?? input.inputPath,
|
||||
`Godot PCK directory is ${containerMetadata.directory_status}; its complete contents cannot be extracted`,
|
||||
);
|
||||
const selectedOccurrences = snapshot.occurrences.filter(
|
||||
@@ -218,7 +225,7 @@ export const materializeArtifactInventoryInScope = async (
|
||||
if (occurrence.encrypted)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
input.selectedPath ?? input.inputPath,
|
||||
`Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`,
|
||||
);
|
||||
// Inventory retains unavailable source occurrences, but extraction cannot
|
||||
@@ -377,6 +384,8 @@ const materializeSelection = async ({
|
||||
traversal.snapshot(),
|
||||
reader.provenance(),
|
||||
);
|
||||
if (input.expectedRootSha256 !== undefined)
|
||||
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
|
||||
await output.commit(signal);
|
||||
return completed;
|
||||
} catch (cause: unknown) {
|
||||
@@ -550,7 +559,11 @@ const requireExtractableFormat = async (
|
||||
path: string,
|
||||
input: Pick<
|
||||
ArtifactExtractionInput,
|
||||
"inputPath" | "inputFormat" | "resourceScope"
|
||||
| "inputPath"
|
||||
| "selectedPath"
|
||||
| "inputFormat"
|
||||
| "resourceScope"
|
||||
| "expectedRootSha256"
|
||||
>,
|
||||
signal?: AbortSignal,
|
||||
previous?: ArtifactEmbeddedFormatProbeFacts,
|
||||
@@ -591,13 +604,26 @@ const requireExtractableFormat = async (
|
||||
((format === "elf" || format === "pe" || format === "mach-o") &&
|
||||
((await probes.nodeSea()) !== undefined ||
|
||||
(await probes.pyInstaller())));
|
||||
if (pack && input.expectedRootSha256 !== undefined) {
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
source.handle,
|
||||
source.initial,
|
||||
signal,
|
||||
);
|
||||
assertAdmittedRootDigest(
|
||||
path,
|
||||
input.expectedRootSha256,
|
||||
digest.sha256,
|
||||
);
|
||||
}
|
||||
return { pack, probeFacts: probes.completedFacts() };
|
||||
},
|
||||
);
|
||||
if (!detected.pack)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
input.selectedPath ?? input.inputPath,
|
||||
`Artifact format has no extraction reader: ${format}`,
|
||||
);
|
||||
return { directory, probeFacts: detected.probeFacts };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
|
||||
import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
|
||||
import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js";
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
@@ -65,7 +66,11 @@ export const classifyAndHashRootForInventory = async (
|
||||
expectedMetadata: Stats,
|
||||
options: Pick<
|
||||
ArtifactInventoryOptions,
|
||||
"resourceScope" | "signal" | "asarHeaderAdmission" | "selectedPath"
|
||||
| "resourceScope"
|
||||
| "signal"
|
||||
| "asarHeaderAdmission"
|
||||
| "selectedPath"
|
||||
| "expectedRootSha256"
|
||||
>,
|
||||
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
|
||||
): Promise<RootInventoryClassification> => {
|
||||
@@ -76,6 +81,15 @@ export const classifyAndHashRootForInventory = async (
|
||||
`Root artifact changed before inventory: ${path}`,
|
||||
);
|
||||
}
|
||||
if (directory && options.expectedRootSha256 !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact root changed after target admission: expected an admitted file at ${path}, observed a directory`,
|
||||
{
|
||||
userMessage:
|
||||
"The opened file was replaced by a directory. Select the intended artifact and reopen it before retrying.",
|
||||
},
|
||||
);
|
||||
if (directory)
|
||||
return {
|
||||
format: "directory",
|
||||
@@ -83,6 +97,8 @@ export const classifyAndHashRootForInventory = async (
|
||||
rootSource: undefined,
|
||||
readerAdmission: undefined,
|
||||
};
|
||||
if (options.expectedRootSha256 !== undefined)
|
||||
await assertArtifactSelectedSource(path, options.selectedPath);
|
||||
const source = await openRootArtifact(
|
||||
path,
|
||||
options.resourceScope,
|
||||
@@ -101,27 +117,46 @@ export const classifyAndHashRootForInventory = async (
|
||||
"integrity",
|
||||
`Root artifact changed before inventory: ${path}`,
|
||||
);
|
||||
// Prior probe state can prove replacement without interpreting any bytes.
|
||||
const probes = new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
source,
|
||||
options.signal,
|
||||
probeFacts,
|
||||
);
|
||||
const admittedDigest =
|
||||
options.expectedRootSha256 === undefined
|
||||
? undefined
|
||||
: await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
initial,
|
||||
options.signal,
|
||||
);
|
||||
if (admittedDigest !== undefined)
|
||||
assertAdmittedRootDigest(
|
||||
path,
|
||||
options.expectedRootSha256,
|
||||
admittedDigest.sha256,
|
||||
);
|
||||
const classified = await classifyRootFormat(
|
||||
path,
|
||||
source,
|
||||
new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
source,
|
||||
options.signal,
|
||||
probeFacts,
|
||||
),
|
||||
probes,
|
||||
options.signal,
|
||||
options.asarHeaderAdmission,
|
||||
options.selectedPath,
|
||||
);
|
||||
const format =
|
||||
typeof classified === "string" ? classified : classified.kind;
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
initial,
|
||||
options.signal,
|
||||
);
|
||||
const digest =
|
||||
admittedDigest ??
|
||||
(await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
initial,
|
||||
options.signal,
|
||||
));
|
||||
const retainSource =
|
||||
isZipPackageFormat(format) ||
|
||||
format === "mach-o-universal" ||
|
||||
@@ -357,3 +392,20 @@ const readRootMzWindowsHeaderSignature = async (
|
||||
if (available === 0) return undefined;
|
||||
return { offset, bytes: window.subarray(0, available) };
|
||||
};
|
||||
|
||||
/** Compare physically completed bytes before interpreting an admitted file root. */
|
||||
export const assertAdmittedRootDigest = (
|
||||
path: string,
|
||||
expected: string | undefined,
|
||||
observed: string,
|
||||
): void => {
|
||||
if (expected !== undefined && observed !== expected)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact root changed after target admission at ${path}: expected ${expected}, observed ${observed}`,
|
||||
{
|
||||
userMessage:
|
||||
"The artifact changed after it was opened. Reopen the selected artifact, then retry.",
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,8 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
|
||||
/** Options shared by artifact inventory scans. */
|
||||
export interface ArtifactInventoryOptions {
|
||||
readonly resourceScope: ArtifactResourceScope;
|
||||
/** Identity of a previously admitted file root; directory roots have no file digest. */
|
||||
readonly expectedRootSha256?: string | undefined;
|
||||
/** Independently selected root spelling used only for ASAR companion lookup. */
|
||||
readonly selectedPath?: string | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
|
||||
Reference in new issue
Block a user