From cb0c73246d360ce4ab363b67bd02564e69368153 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:18:04 +0800 Subject: [PATCH] refactor(artifacts): reuse admitted sources for extraction --- src/artifacts/ArtifactFileSource.ts | 65 ++-- .../extraction/ArtifactExtraction.ts | 67 ++-- .../EmbeddedArtifactExtraction.test.ts | 348 ++++++++++++++++++ src/artifacts/inventory/ArtifactInventory.ts | 4 +- src/artifacts/inventory/scanCanonical.ts | 31 +- ...tifactProvider.extractionAdmission.test.ts | 6 +- 6 files changed, 447 insertions(+), 74 deletions(-) create mode 100644 src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts diff --git a/src/artifacts/ArtifactFileSource.ts b/src/artifacts/ArtifactFileSource.ts index cf055a4284..925190ef99 100644 --- a/src/artifacts/ArtifactFileSource.ts +++ b/src/artifacts/ArtifactFileSource.ts @@ -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 => { + 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; } diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index eea3bfbc2a..4a68ff81c6 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -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 => { - 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 => { 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 => { const byPath = new Map(); @@ -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 => { - 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 ?? diff --git a/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts b/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts new file mode 100644 index 0000000000..ed5dc33038 --- /dev/null +++ b/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts @@ -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 }; + }, + 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 } }, + 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 } }, + 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 }) { + 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 }) { + 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 }) { + 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 }) { + 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(); + } + }, +); diff --git a/src/artifacts/inventory/ArtifactInventory.ts b/src/artifacts/inventory/ArtifactInventory.ts index a8959683b7..421fe71a4d 100644 --- a/src/artifacts/inventory/ArtifactInventory.ts +++ b/src/artifacts/inventory/ArtifactInventory.ts @@ -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); diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 0bbda27539..aff814e394 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -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[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[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); diff --git a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts index 6ceda198fb..4492647a4d 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts @@ -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 {