mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 02:32:54 +00:00
refactor(artifacts): reuse admitted sources for extraction
This commit is contained in:
1 parent
3fcbabe593
commit
cb0c73246d
6 files changed
+447
-74
No files matched your search
@@ -14,6 +14,40 @@ import { hashReadable } from "./ArtifactHash.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
||||
|
||||
/** Verify an admitted descriptor against the regular file still named by its path. */
|
||||
export const assertArtifactFileSelectedSource = async (
|
||||
path: string,
|
||||
source: StableRegularFileDescriptor,
|
||||
diagnosticLabel: string,
|
||||
): Promise<void> => {
|
||||
const [observed, selected] = await Promise.all([
|
||||
source.handle.stat(),
|
||||
lstat(path).catch((cause: unknown) => {
|
||||
// Admission already established this selected name. Its disappearance
|
||||
// or a replaced ancestor is a source change, not an unrelated I/O fault.
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`${diagnosticLabel} source identity changed: ${path}`,
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}),
|
||||
]);
|
||||
if (
|
||||
!sameRegularFileState(source.initial, observed) ||
|
||||
!sameRegularFileState(source.initial, selected)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`${diagnosticLabel} source identity changed: ${path}`,
|
||||
);
|
||||
};
|
||||
|
||||
/** One persistent descriptor and hash binding, borrowing caller-admitted sources. */
|
||||
export class ArtifactFileSource {
|
||||
readonly #scope = new ArtifactResourceScope();
|
||||
@@ -140,32 +174,11 @@ export class ArtifactFileSource {
|
||||
assertArtifactReadActive(signal);
|
||||
this.#opening ??= this.#admit(signal);
|
||||
const source = await this.#opening;
|
||||
const [observed, selected] = await Promise.all([
|
||||
source.handle.stat(),
|
||||
lstat(this.path).catch((cause: unknown) => {
|
||||
// Admission already established this selected name. Its disappearance
|
||||
// or a replaced ancestor is a source change, not an unrelated I/O fault.
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`${this.diagnosticLabel} source identity changed: ${this.path}`,
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}),
|
||||
]);
|
||||
if (
|
||||
!sameRegularFileState(source.initial, observed) ||
|
||||
!sameRegularFileState(source.initial, selected)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`${this.diagnosticLabel} source identity changed: ${this.path}`,
|
||||
);
|
||||
await assertArtifactFileSelectedSource(
|
||||
this.path,
|
||||
source,
|
||||
this.diagnosticLabel,
|
||||
);
|
||||
assertArtifactReadActive(signal);
|
||||
return source;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { assertAdmittedRootDigest } from "../inventory/classify.js";
|
||||
import { assertArtifactFileSelectedSource } from "../ArtifactFileSource.js";
|
||||
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
|
||||
import { hashStableRootArtifactHandle } from "../inventory/hashStableRootArtifact.js";
|
||||
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
|
||||
@@ -52,7 +53,7 @@ import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.j
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import { scanArtifactInventoryForExtractionInScope } from "../inventory/ArtifactInventory.js";
|
||||
import type { TransferredZipInventorySource } from "../inventory/scanCanonical.js";
|
||||
import type { TransferredArtifactInventorySource } from "../inventory/scanCanonical.js";
|
||||
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
|
||||
|
||||
/** Local extraction input with the output root chosen by the adapter. */
|
||||
@@ -86,7 +87,7 @@ const extractArtifactInScope = async (
|
||||
if (input.expectedRootSha256 !== undefined)
|
||||
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
|
||||
const admission = await requireExtractableFormat(sourcePath, input, signal);
|
||||
const { snapshot, zipSource } =
|
||||
const { snapshot, transferredSource } =
|
||||
await scanArtifactInventoryForExtractionInScope(
|
||||
input.inputPath,
|
||||
{
|
||||
@@ -102,28 +103,28 @@ const extractArtifactInScope = async (
|
||||
},
|
||||
admission.probeFacts,
|
||||
);
|
||||
const activeZipSource =
|
||||
zipSource === undefined
|
||||
const activeSource =
|
||||
transferredSource === undefined
|
||||
? undefined
|
||||
: { ...zipSource, releaseAttempted: false };
|
||||
: { ...transferredSource, releaseAttempted: false };
|
||||
try {
|
||||
return await materializeArtifactInventoryInScope(
|
||||
input,
|
||||
sourcePath,
|
||||
snapshot,
|
||||
signal,
|
||||
activeZipSource,
|
||||
activeSource,
|
||||
admission.probeFacts,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (activeZipSource !== undefined && cause instanceof ArtifactReaderFailure)
|
||||
if (activeSource !== undefined && cause instanceof ArtifactReaderFailure)
|
||||
cause.retainPartialObservation({
|
||||
kind: "artifact-inventory",
|
||||
inventory: snapshot,
|
||||
});
|
||||
const attempt = await releaseTransferredZipSource(
|
||||
const attempt = await releaseTransferredArtifactSource(
|
||||
input.resourceScope,
|
||||
activeZipSource,
|
||||
activeSource,
|
||||
);
|
||||
if (attempt?.kind === "failed")
|
||||
throw input.resourceScope.cleanupFailure(
|
||||
@@ -135,32 +136,33 @@ const extractArtifactInScope = async (
|
||||
}
|
||||
};
|
||||
|
||||
interface ActiveZipInventorySource extends TransferredZipInventorySource {
|
||||
interface ActiveArtifactInventorySource extends TransferredArtifactInventorySource {
|
||||
releaseAttempted: boolean;
|
||||
}
|
||||
|
||||
const releaseTransferredZipSource = async (
|
||||
const releaseTransferredArtifactSource = async (
|
||||
resourceScope: ArtifactResourceScope,
|
||||
zipSource: ActiveZipInventorySource | undefined,
|
||||
transferredSource: ActiveArtifactInventorySource | undefined,
|
||||
): Promise<ArtifactCleanupAttempt | undefined> => {
|
||||
if (zipSource === undefined || zipSource.releaseAttempted) return undefined;
|
||||
zipSource.releaseAttempted = true;
|
||||
return resourceScope.release(zipSource.owner);
|
||||
if (transferredSource === undefined || transferredSource.releaseAttempted)
|
||||
return undefined;
|
||||
transferredSource.releaseAttempted = true;
|
||||
return resourceScope.release(transferredSource.owner);
|
||||
};
|
||||
|
||||
/** Materialize a scanned inventory, borrowing its active ZIP source when supplied. */
|
||||
/** Materialize a scanned inventory, borrowing its admitted source when supplied. */
|
||||
export const materializeArtifactInventoryInScope = async (
|
||||
input: ArtifactExtractionInput,
|
||||
sourcePath: string,
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
signal?: AbortSignal,
|
||||
zipSource?: ActiveZipInventorySource,
|
||||
transferredSource?: ActiveArtifactInventorySource,
|
||||
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
|
||||
): Promise<ArtifactExtractionResult> => {
|
||||
abortIfNeeded(signal);
|
||||
// A directory source must never acquire its own output as a new member on
|
||||
// the verification traversal. Resolve the selected parent before any write.
|
||||
if (zipSource === undefined) {
|
||||
if (transferredSource === undefined) {
|
||||
const canonicalSource = await realpath(sourcePath);
|
||||
if ((await lstat(canonicalSource)).isDirectory()) {
|
||||
const { canonicalOutput } = await SafeOutputTree.resolveDestination(
|
||||
@@ -271,7 +273,7 @@ export const materializeArtifactInventoryInScope = async (
|
||||
inventory: snapshot,
|
||||
selected,
|
||||
signal,
|
||||
zipSource,
|
||||
transferredSource,
|
||||
probeFacts,
|
||||
});
|
||||
};
|
||||
@@ -295,7 +297,7 @@ const materializeSelection = async ({
|
||||
inventory,
|
||||
selected,
|
||||
signal,
|
||||
zipSource,
|
||||
transferredSource,
|
||||
probeFacts,
|
||||
}: {
|
||||
readonly input: ArtifactExtractionInput;
|
||||
@@ -303,7 +305,7 @@ const materializeSelection = async ({
|
||||
readonly inventory: ArtifactInventorySnapshot;
|
||||
readonly selected: readonly SelectedOccurrence[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly zipSource: ActiveZipInventorySource | undefined;
|
||||
readonly transferredSource: ActiveArtifactInventorySource | undefined;
|
||||
readonly probeFacts: ArtifactEmbeddedFormatProbeFacts | undefined;
|
||||
}): Promise<ArtifactExtractionResult> => {
|
||||
const byPath = new Map<string, SelectedOccurrence>();
|
||||
@@ -316,12 +318,18 @@ const materializeSelection = async ({
|
||||
);
|
||||
byPath.set(path, item);
|
||||
}
|
||||
if (transferredSource !== undefined && "kind" in transferredSource.admission)
|
||||
await assertArtifactFileSelectedSource(
|
||||
sourcePath,
|
||||
transferredSource.admission.source,
|
||||
"Artifact extraction",
|
||||
);
|
||||
const reader = await createReader(
|
||||
sourcePath,
|
||||
input,
|
||||
inventory.manifest.root_format,
|
||||
signal,
|
||||
zipSource,
|
||||
transferredSource,
|
||||
probeFacts,
|
||||
);
|
||||
const readerOwner: ArtifactResourceOwner = {
|
||||
@@ -362,9 +370,9 @@ const materializeSelection = async ({
|
||||
[closeAttempt],
|
||||
{ kind: "artifact-inventory", inventory },
|
||||
);
|
||||
const sourceClose = await releaseTransferredZipSource(
|
||||
const sourceClose = await releaseTransferredArtifactSource(
|
||||
input.resourceScope,
|
||||
zipSource,
|
||||
transferredSource,
|
||||
);
|
||||
if (sourceClose?.kind === "failed")
|
||||
throw input.resourceScope.cleanupFailure(
|
||||
@@ -372,7 +380,7 @@ const materializeSelection = async ({
|
||||
[sourceClose],
|
||||
{ kind: "artifact-inventory", inventory },
|
||||
);
|
||||
if (zipSource !== undefined) abortIfNeeded(signal);
|
||||
if (transferredSource !== undefined) abortIfNeeded(signal);
|
||||
extracted.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.relative_path, right.relative_path),
|
||||
);
|
||||
@@ -644,13 +652,12 @@ const createReader = async (
|
||||
>,
|
||||
containerFormat: ArtifactGraphManifest["root_format"],
|
||||
signal?: AbortSignal,
|
||||
zipSource?: ActiveZipInventorySource,
|
||||
transferredSource?: ActiveArtifactInventorySource,
|
||||
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
|
||||
): Promise<ArtifactReader> => {
|
||||
const transferredZip =
|
||||
zipSource !== undefined && isZipPackageFormat(containerFormat);
|
||||
let format: ArtifactReaderFormat;
|
||||
if (transferredZip) format = containerFormat;
|
||||
if (transferredSource !== undefined && isZipPackageFormat(containerFormat))
|
||||
format = containerFormat;
|
||||
else if (
|
||||
containerFormat === "godot-pck" ||
|
||||
containerFormat === "pyinstaller" ||
|
||||
@@ -671,7 +678,7 @@ const createReader = async (
|
||||
environment: input.environment,
|
||||
unreadableDirectories: "fail",
|
||||
selectedPath: absoluteCallerPath(input.selectedPath ?? input.inputPath),
|
||||
admission: transferredZip ? zipSource.source : undefined,
|
||||
admission: transferredSource?.admission,
|
||||
asarHeaderAdmission:
|
||||
format === "asar"
|
||||
? (input.asarHeaderAdmission ??
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
appendFile,
|
||||
mkdir,
|
||||
rename,
|
||||
readFile,
|
||||
stat,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { buildGodotPck } from "../../../tests/fixtures/godotPck.js";
|
||||
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import { GodotPckArtifactReader } from "../GodotPckArtifactReader.js";
|
||||
import { NodeSeaArtifactReader } from "../NodeSeaArtifactReader.js";
|
||||
import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js";
|
||||
import { cArchiveExecutable } from "../pyinstaller/CArchive.fixture.js";
|
||||
import { nodeSeaElf } from "../nodeSea/NodeSea.fixture.js";
|
||||
import * as cArchive from "../pyinstaller/CArchiveLayout.js";
|
||||
import * as nodeSea from "../nodeSea/NodeSeaBlob.js";
|
||||
import * as godot from "../GodotPckLocation.js";
|
||||
import { extractArtifact } from "./ArtifactExtraction.js";
|
||||
|
||||
const cases = [
|
||||
{
|
||||
format: "pyinstaller" as const,
|
||||
inputFormat: "pe" as const,
|
||||
reader: PyInstallerArtifactReader as {
|
||||
prototype: { close(): Promise<void> };
|
||||
},
|
||||
bytes: () =>
|
||||
cArchiveExecutable({
|
||||
carrier: "pe",
|
||||
layout: "2.1",
|
||||
pythonVersion: 314,
|
||||
members: [
|
||||
{
|
||||
name: String.raw`pkg\payload.txt`,
|
||||
typeCode: "x",
|
||||
payload: Buffer.from("fixture"),
|
||||
compress: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
member: "pkg/payload.txt",
|
||||
observe: () => vi.spyOn(cArchive, "findPyInstallerCookie"),
|
||||
},
|
||||
{
|
||||
format: "node-sea" as const,
|
||||
inputFormat: "elf" as const,
|
||||
reader: NodeSeaArtifactReader as { prototype: { close(): Promise<void> } },
|
||||
bytes: () => nodeSeaElf({ codePath: "payload.js", main: "fixture" }),
|
||||
member: "payload.js",
|
||||
observe: () => vi.spyOn(nodeSea, "decodeNodeSeaBlob"),
|
||||
},
|
||||
{
|
||||
format: "godot-pck" as const,
|
||||
inputFormat: "godot-pck" as const,
|
||||
reader: GodotPckArtifactReader as { prototype: { close(): Promise<void> } },
|
||||
bytes: () => buildGodotPck(3, [{ path: "payload.txt", data: "fixture" }]),
|
||||
member: "payload.txt",
|
||||
observe: () => vi.spyOn(godot, "locateGodotPck"),
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)(
|
||||
"extracts $format using the inventory admission once",
|
||||
async ({ format, inputFormat, reader, bytes, member, observe }) => {
|
||||
const directory = await createTestTempDirectory("rea-embedded-extraction-");
|
||||
const path = join(directory, "source");
|
||||
const output = join(directory, "out");
|
||||
const carrier = bytes();
|
||||
await writeFile(path, carrier);
|
||||
const scope = new ArtifactResourceScope();
|
||||
const observation = observe();
|
||||
const close = vi.spyOn(reader.prototype, "close");
|
||||
const release = vi.spyOn(scope, "release");
|
||||
try {
|
||||
const result = await extractArtifact({
|
||||
inputPath: path,
|
||||
inputFormat,
|
||||
outputRoot: output,
|
||||
environment: {},
|
||||
integrityPolicy: "fail",
|
||||
resourceScope: scope,
|
||||
expectedRootSha256: createHash("sha256").update(carrier).digest("hex"),
|
||||
selectedPath: path,
|
||||
});
|
||||
expect(result.manifest.root_format).toBe(format);
|
||||
expect(await readFile(join(output, member), "utf8")).toBe("fixture");
|
||||
expect(observation).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(2);
|
||||
const rootRelease = release.mock.calls.findIndex(
|
||||
([owner]) =>
|
||||
owner.kind === "file-handle" &&
|
||||
owner.resource === `root artifact descriptor for ${path}`,
|
||||
);
|
||||
expect(rootRelease).toBeGreaterThanOrEqual(0);
|
||||
expect(
|
||||
release.mock.calls
|
||||
.slice(0, rootRelease)
|
||||
.filter(([owner]) => owner.kind === "reader"),
|
||||
).toHaveLength(2);
|
||||
expect(scope.hasPending).toBe(false);
|
||||
} finally {
|
||||
await scope.close();
|
||||
observation.mockRestore();
|
||||
close.mockRestore();
|
||||
release.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
"rejects changed $format bytes before materializing admitted facts",
|
||||
async ({ inputFormat, reader, bytes }) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-embedded-extraction-stale-",
|
||||
);
|
||||
const path = join(directory, "source");
|
||||
const output = join(directory, "out");
|
||||
await writeFile(path, bytes());
|
||||
const scope = new ArtifactResourceScope();
|
||||
const original = reader.prototype.close;
|
||||
let closes = 0;
|
||||
const close = vi
|
||||
.spyOn(reader.prototype, "close")
|
||||
.mockImplementation(async function (this: { close(): Promise<void> }) {
|
||||
await original.call(this);
|
||||
if (++closes === 1) await appendFile(path, "changed");
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
extractArtifact({
|
||||
inputPath: path,
|
||||
inputFormat,
|
||||
outputRoot: output,
|
||||
environment: {},
|
||||
integrityPolicy: "fail",
|
||||
resourceScope: scope,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(scope.hasPending).toBe(false);
|
||||
} finally {
|
||||
await scope.close();
|
||||
close.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
cases.flatMap((fixture) =>
|
||||
["cancel", "retry", "mixed"].map((mode) => ({ ...fixture, mode })),
|
||||
),
|
||||
)(
|
||||
"retains $format finalization semantics during $mode",
|
||||
async ({ inputFormat, reader, bytes, mode }) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-embedded-extraction-cleanup-",
|
||||
);
|
||||
const path = join(directory, "source");
|
||||
const output = join(directory, "out");
|
||||
await writeFile(path, bytes());
|
||||
const scope = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const failure = new Error("root close denied");
|
||||
const originalReaderClose = reader.prototype.close;
|
||||
let readerAttempts = 0;
|
||||
const readerClose = vi
|
||||
.spyOn(reader.prototype, "close")
|
||||
.mockImplementation(async function (this: { close(): Promise<void> }) {
|
||||
if (++readerAttempts === 2 && mode === "mixed")
|
||||
throw new Error("reader close denied");
|
||||
await originalReaderClose.call(this);
|
||||
});
|
||||
const originalRelease = scope.release.bind(scope);
|
||||
let rootAttempts = 0;
|
||||
let rootClose: { mockRestore(): void } | undefined;
|
||||
const release = vi
|
||||
.spyOn(scope, "release")
|
||||
.mockImplementation(async (owner) => {
|
||||
if (
|
||||
owner.kind === "file-handle" &&
|
||||
owner.resource === `root artifact descriptor for ${path}`
|
||||
) {
|
||||
const originalClose = owner.handle.close.bind(owner.handle);
|
||||
rootClose = vi
|
||||
.spyOn(owner.handle, "close")
|
||||
.mockImplementation(async () => {
|
||||
rootAttempts++;
|
||||
expect(readerAttempts).toBeGreaterThanOrEqual(2);
|
||||
if (rootAttempts === 1) {
|
||||
if (mode === "cancel") controller.abort(failure);
|
||||
else throw failure;
|
||||
}
|
||||
await originalClose();
|
||||
});
|
||||
}
|
||||
return originalRelease(owner);
|
||||
});
|
||||
try {
|
||||
const execution = extractArtifact(
|
||||
{
|
||||
inputPath: path,
|
||||
inputFormat,
|
||||
outputRoot: output,
|
||||
environment: {},
|
||||
integrityPolicy: "fail",
|
||||
resourceScope: scope,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (mode === "cancel")
|
||||
await expect(execution).rejects.toMatchObject({
|
||||
reason: "cancelled",
|
||||
cause: failure,
|
||||
});
|
||||
else
|
||||
await expect(execution).rejects.toMatchObject({
|
||||
cleanup: {
|
||||
resources: expect.arrayContaining([
|
||||
`root artifact descriptor for ${path}`,
|
||||
]),
|
||||
},
|
||||
});
|
||||
await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
if (mode === "mixed")
|
||||
expect(scope.cleanupResources).toEqual([
|
||||
`artifact reader for ${path}`,
|
||||
`root artifact descriptor for ${path}`,
|
||||
]);
|
||||
else if (mode === "retry")
|
||||
expect(scope.cleanupResources).toEqual([
|
||||
`root artifact descriptor for ${path}`,
|
||||
]);
|
||||
await scope.close();
|
||||
expect(rootAttempts).toBe(mode === "cancel" ? 1 : 2);
|
||||
expect(readerAttempts).toBe(mode === "mixed" ? 3 : 2);
|
||||
expect(scope.hasPending).toBe(false);
|
||||
} finally {
|
||||
await scope.close().catch(() => undefined);
|
||||
rootClose?.mockRestore();
|
||||
release.mockRestore();
|
||||
readerClose.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
"rejects a retargeted selected path before publishing $format extraction",
|
||||
async ({ inputFormat, reader, bytes }) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-embedded-extraction-selection-",
|
||||
);
|
||||
const path = join(directory, "source");
|
||||
const selectedPath = join(directory, "selected");
|
||||
const replacement = join(directory, "replacement");
|
||||
const output = join(directory, "out");
|
||||
const carrier = bytes();
|
||||
await writeFile(path, carrier);
|
||||
await writeFile(replacement, carrier);
|
||||
await symlink(path, selectedPath);
|
||||
const scope = new ArtifactResourceScope();
|
||||
const originalClose = reader.prototype.close;
|
||||
let closes = 0;
|
||||
const close = vi
|
||||
.spyOn(reader.prototype, "close")
|
||||
.mockImplementation(async function (this: { close(): Promise<void> }) {
|
||||
await originalClose.call(this);
|
||||
if (++closes === 2) {
|
||||
await unlink(selectedPath);
|
||||
await symlink(replacement, selectedPath);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
extractArtifact({
|
||||
inputPath: path,
|
||||
selectedPath,
|
||||
expectedRootSha256: createHash("sha256")
|
||||
.update(carrier)
|
||||
.digest("hex"),
|
||||
inputFormat,
|
||||
outputRoot: output,
|
||||
environment: {},
|
||||
integrityPolicy: "fail",
|
||||
resourceScope: scope,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(closes).toBe(2);
|
||||
expect(scope.hasPending).toBe(false);
|
||||
} finally {
|
||||
await scope.close();
|
||||
close.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
"rejects a replacement at the admitted $format pathname",
|
||||
async ({ inputFormat, reader, bytes }) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-embedded-extraction-replacement-",
|
||||
);
|
||||
const selectedDirectory = join(directory, "selected");
|
||||
await mkdir(selectedDirectory);
|
||||
const path = join(selectedDirectory, "source");
|
||||
const output = join(directory, "out");
|
||||
await writeFile(path, bytes());
|
||||
const scope = new ArtifactResourceScope();
|
||||
const originalClose = reader.prototype.close;
|
||||
let closes = 0;
|
||||
const close = vi
|
||||
.spyOn(reader.prototype, "close")
|
||||
.mockImplementation(async function (this: { close(): Promise<void> }) {
|
||||
await originalClose.call(this);
|
||||
if (++closes === 1) {
|
||||
// Moving the parent leaves the admitted regular file's metadata intact.
|
||||
await rename(selectedDirectory, join(directory, "admitted"));
|
||||
await mkdir(selectedDirectory);
|
||||
await writeFile(path, Buffer.from("replacement"));
|
||||
}
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
extractArtifact({
|
||||
inputPath: path,
|
||||
inputFormat,
|
||||
outputRoot: output,
|
||||
environment: {},
|
||||
integrityPolicy: "fail",
|
||||
resourceScope: scope,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(closes).toBe(1);
|
||||
expect(scope.hasPending).toBe(false);
|
||||
} finally {
|
||||
await scope.close();
|
||||
close.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -9,7 +9,7 @@ import { abortIfNeeded } from "../ArtifactHash.js";
|
||||
import {
|
||||
scanCanonicalArtifactInventoryInScope,
|
||||
scanCanonicalArtifactInventoryForExtractionInScope,
|
||||
type TransferredZipInventorySource,
|
||||
type TransferredArtifactInventorySource,
|
||||
} from "./scanCanonical.js";
|
||||
import type { ArtifactInventoryOptions } from "./types.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
@@ -46,7 +46,7 @@ export const scanArtifactInventoryForExtractionInScope = async (
|
||||
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
|
||||
): Promise<{
|
||||
readonly snapshot: ArtifactInventorySnapshot;
|
||||
readonly zipSource: TransferredZipInventorySource | undefined;
|
||||
readonly transferredSource: TransferredArtifactInventorySource | undefined;
|
||||
}> => {
|
||||
abortIfNeeded(options.signal);
|
||||
const path = await realpath(inputPath);
|
||||
|
||||
@@ -52,9 +52,9 @@ import {
|
||||
type ArtifactInventoryOptions,
|
||||
} from "./types.js";
|
||||
|
||||
export interface TransferredZipInventorySource {
|
||||
readonly source: RootInventorySource;
|
||||
export interface TransferredArtifactInventorySource {
|
||||
readonly owner: ArtifactResourceOwner;
|
||||
readonly admission: NonNullable<Parameters<typeof createReader>[3]>;
|
||||
}
|
||||
|
||||
export const scanCanonicalArtifactInventoryInScope = async (
|
||||
@@ -74,7 +74,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
)
|
||||
).snapshot;
|
||||
|
||||
/** Keep the admitted ZIP descriptor alive for extraction of this inventory. */
|
||||
/** Keep the admitted source and parsed format facts alive for extraction of this inventory. */
|
||||
export const scanCanonicalArtifactInventoryForExtractionInScope = async (
|
||||
path: string,
|
||||
options: ArtifactInventoryOptions,
|
||||
@@ -82,13 +82,13 @@ export const scanCanonicalArtifactInventoryForExtractionInScope = async (
|
||||
selectedPath: string = path,
|
||||
): Promise<{
|
||||
readonly snapshot: ArtifactInventorySnapshot;
|
||||
readonly zipSource: TransferredZipInventorySource | undefined;
|
||||
readonly transferredSource: TransferredArtifactInventorySource | undefined;
|
||||
}> =>
|
||||
scanCanonical(
|
||||
path,
|
||||
options,
|
||||
createReader,
|
||||
"transfer-zip",
|
||||
"transfer-source",
|
||||
probeFacts,
|
||||
selectedPath,
|
||||
);
|
||||
@@ -97,12 +97,12 @@ const scanCanonical = async (
|
||||
path: string,
|
||||
options: ArtifactInventoryOptions,
|
||||
readerFactory: typeof createReader,
|
||||
rootDisposition: "release" | "transfer-zip",
|
||||
rootDisposition: "release" | "transfer-source",
|
||||
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
|
||||
selectedPath: string = path,
|
||||
): Promise<{
|
||||
readonly snapshot: ArtifactInventorySnapshot;
|
||||
readonly zipSource: TransferredZipInventorySource | undefined;
|
||||
readonly transferredSource: TransferredArtifactInventorySource | undefined;
|
||||
}> => {
|
||||
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
|
||||
const metadata = await lstat(path);
|
||||
@@ -247,18 +247,20 @@ const scanCanonical = async (
|
||||
options,
|
||||
rootSource,
|
||||
ownedReaders,
|
||||
readerAdmission,
|
||||
outcome,
|
||||
rootDisposition:
|
||||
rootDisposition === "transfer-zip" &&
|
||||
rootDisposition === "transfer-source" &&
|
||||
outcome.kind === "completed" &&
|
||||
rootSource !== undefined &&
|
||||
isZipPackageFormat(rootFormat)
|
||||
(isZipPackageFormat(rootFormat) ||
|
||||
(readerAdmission !== undefined && "kind" in readerAdmission))
|
||||
? "transfer"
|
||||
: "release",
|
||||
});
|
||||
if (cleanup.failure !== undefined) throw cleanup.failure;
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
return { snapshot: outcome.snapshot, zipSource: cleanup.transferred };
|
||||
return { snapshot: outcome.snapshot, transferredSource: cleanup.transferred };
|
||||
};
|
||||
|
||||
type InventoryScanOutcome =
|
||||
@@ -273,6 +275,7 @@ const cleanupInventoryOwners = async ({
|
||||
options,
|
||||
rootSource,
|
||||
ownedReaders,
|
||||
readerAdmission,
|
||||
outcome,
|
||||
rootDisposition,
|
||||
}: {
|
||||
@@ -280,11 +283,12 @@ const cleanupInventoryOwners = async ({
|
||||
readonly options: ArtifactInventoryOptions;
|
||||
readonly rootSource: RootInventorySource | undefined;
|
||||
readonly ownedReaders: readonly ArtifactReader[];
|
||||
readonly readerAdmission: Parameters<typeof createReader>[3];
|
||||
readonly outcome: InventoryScanOutcome;
|
||||
readonly rootDisposition: "release" | "transfer";
|
||||
}): Promise<{
|
||||
readonly failure: ArtifactReaderFailure | undefined;
|
||||
readonly transferred: TransferredZipInventorySource | undefined;
|
||||
readonly transferred: TransferredArtifactInventorySource | undefined;
|
||||
}> => {
|
||||
let cleanupFailure: ArtifactReaderFailure | undefined;
|
||||
const readerOwners: ArtifactResourceOwner[] = [...ownedReaders]
|
||||
@@ -319,11 +323,12 @@ const cleanupInventoryOwners = async ({
|
||||
rootDisposition === "transfer" &&
|
||||
cleanupFailure === undefined &&
|
||||
rootSource !== undefined &&
|
||||
rootOwner !== undefined
|
||||
rootOwner !== undefined &&
|
||||
readerAdmission !== undefined
|
||||
)
|
||||
return {
|
||||
failure: undefined,
|
||||
transferred: { source: rootSource, owner: rootOwner },
|
||||
transferred: { owner: rootOwner, admission: readerAdmission },
|
||||
};
|
||||
if (rootOwner !== undefined) {
|
||||
const attempt = await options.resourceScope.release(rootOwner);
|
||||
|
||||
@@ -307,10 +307,10 @@ it.each([
|
||||
expect(extracted.manifest.root_format).toBe(format);
|
||||
expect(extracted.artifacts.length).toBeGreaterThan(0);
|
||||
expect(probesBeforeHash).toEqual(probes);
|
||||
// Preflight's cookie also supplies inventory. Extraction opens its
|
||||
// own reader and establishes that reader's cookie independently.
|
||||
// Preflight's cookie supplies inventory and the transferred extraction
|
||||
// admission; descriptor/path and digest checks still validate freshness.
|
||||
if (format === "pyinstaller")
|
||||
expect(pyInstaller).toHaveBeenCalledTimes(2);
|
||||
expect(pyInstaller).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user