mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
* fix(filesystem): settle a Windows ChangeTime before recording a baseline Windows advances a file's ChangeTime shortly after it is written while security components attach catalog and file-hash extended attributes. A baseline captured during that advance disagrees with the next observation, so an untouched file was reported as changed: "Root artifact changed before inventory" and "Selected input changed while being read". Dropping ctime on Windows is not an option because it is the only field that catches a same-size rewrite whose modification time was restored. Instead, keep ctime at full strength and stop capturing it mid-flight: on win32 a ChangeTime still inside the drift window is observed again until two consecutive observations agree, and the settled value becomes the baseline. Applied where a baseline is captured and later compared: openRegularFile's admitted metadata and the inventory root snapshot. The exhaustive process capture walk is left alone, since settling there would delay every freshly written file in the tree. Refs #8975 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(filesystem): wait out the drift window instead of a repeated value Settling on two matching observations was not enough. Two reads taken before Windows performs its delayed ChangeTime update agree with each other and disagree with every later one, so an unchanged file still failed a later check: the reported drift lands 0.3-1.0 s after the write, well past a pair of reads 25 ms apart. Decide on the observation's age in the drift window instead, and keep observing until the ChangeTime itself has aged out. Both the first read and the empty-observation path now go through the same window check. The window-exit test could not fail before: its clock was frozen, so the window never closed and the exit branch never ran. Advance the clock while sleeping and cover a match that precedes a delayed update. Refs #8975 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(filesystem): configure Windows ChangeTime settle limits REA_WINDOWS_CHANGE_TIME_DRIFT_MS, REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, and REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS keep today's 2000 ms, 25 ms, and 40-poll defaults. Invalid values name the setting, current value, source, and range, and say to restart REA. * fix(filesystem): settle on the selected environment and stop when cancelled `settleChangeTimeBaseline` resolved its limits from `process.env`, but the inventory already carries the environment its owner selected. A Windows runtime with explicit settle settings therefore ran on ambient values or the defaults, and an invalid ambient value could fail a runtime whose own configuration was valid. `scanCanonical` now passes `options.environment` through. The wait loop also never observed the owning operation's signal, so a cancelled inventory could keep polling for the whole drift window — up to a minute at the configured maximum — and closing the artifact scope waited on it. The loop now checks the signal before each poll and after the sleep, and both call sites pass theirs. Refs morluto/rea#8979 * fix(filesystem): keep the caller's typed error on cancellation Checking the signal was not enough: `throwIfAborted` threw the raw `signal.reason`, which `openRegularFile` passed through unchanged. Its callers recognise cancellation through `RegularFileOperationCancelled`, so a cancelled admission surfaced as an `ArtifactReaderFailure` with reason `io` instead of a cancellation. Let the seam supply the error a caller matches on, and have `openRegularFile` pass its own cancellation type. Refs morluto/rea#8979 * fix(filesystem): keep inventory cancellation typed during ChangeTime settle scanCanonical passed the abort signal into settleChangeTimeBaseline without a cancellationError, so an aborted inventory threw the raw AbortError and analyze reported execution_failure. Inventory now throws ArtifactReaderFailure reason cancelled, which callers already translate as cancellation. * fix(filesystem): skip ChangeTime settling for private PYZ images External admission still waits out a fresh Windows ChangeTime. A private image REA just wrote is recorded as written, so a compressed PYZ spool does not add about a second per image. --------- Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
import type { FileHandle } from "node:fs/promises";
|
|
import { lstat, mkdtemp, open, rm } from "node:fs/promises";
|
|
import type { Stats } from "node:fs";
|
|
import { Readable } from "node:stream";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
|
|
import {
|
|
openRegularFile,
|
|
RegularFileAdmissionFailure,
|
|
sameRegularFileState,
|
|
type StableRegularFileDescriptor,
|
|
} from "../../filesystem/RegularFile.js";
|
|
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
|
import {
|
|
ArtifactResourceScope,
|
|
type ArtifactResourceOwner,
|
|
} from "../ArtifactResourceScope.js";
|
|
import { abortIfNeeded } from "../ArtifactHash.js";
|
|
import { readArtifactFileChunks } from "../readArtifactFileChunks.js";
|
|
import {
|
|
pyInstallerMemberStream,
|
|
readPyInstallerMemberPrefix,
|
|
} from "./PyInstallerMemberStream.js";
|
|
import {
|
|
isCArchivePythonModule,
|
|
type CArchiveEntry,
|
|
} from "./CArchiveLayout.js";
|
|
import type { PyInstallerMember } from "./CArchiveMembers.js";
|
|
import { artifactFileAdmissionFailure } from "../ArtifactFileAdmission.js";
|
|
import {
|
|
reconstructedPycHeader,
|
|
isHeaderlessPythonCode,
|
|
type CArchiveBytecodeHeader,
|
|
} from "./PythonBytecode.js";
|
|
|
|
// Each outer-compressed image retains one inode and its identity until close.
|
|
// This is a per-reader filesystem capacity budget, independent of byte budgets.
|
|
const MAX_PRIVATE_PYZ_IMAGES = 128;
|
|
|
|
interface StoredImage {
|
|
readonly path: string;
|
|
readonly initial: Stats;
|
|
}
|
|
export interface PyInstallerMemberSource {
|
|
readonly source: Readable;
|
|
readonly verify?: () => Promise<void>;
|
|
readonly close?: (primary?: unknown) => Promise<void>;
|
|
}
|
|
|
|
/** The reader's operation scope drains work before retiring these private files. */
|
|
export class PyInstallerPyzStore {
|
|
#root: string | undefined;
|
|
#serial = 0;
|
|
readonly #owners = new Map<
|
|
FileHandle,
|
|
Extract<ArtifactResourceOwner, { kind: "file-handle" }>
|
|
>();
|
|
readonly #cleanup = new ArtifactResourceScope();
|
|
readonly #images = new Map<FileHandle, StoredImage>();
|
|
|
|
reserveImages(count: number): void {
|
|
if (count > MAX_PRIVATE_PYZ_IMAGES)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"PyInstaller compressed PYZ images exceed the 128-file private storage budget",
|
|
{ boundary: "capacity" },
|
|
);
|
|
}
|
|
|
|
async bytecodeHeader(
|
|
handle: FileHandle,
|
|
archiveStart: number,
|
|
entry: CArchiveEntry,
|
|
magic: Buffer | undefined,
|
|
signal?: AbortSignal,
|
|
): Promise<CArchiveBytecodeHeader> {
|
|
const unchanged = { header: undefined, limitation: undefined };
|
|
if (magic === undefined) return unchanged;
|
|
if (entry.role === "script")
|
|
return { header: reconstructedPycHeader(magic), limitation: undefined };
|
|
if (!isCArchivePythonModule(entry)) return unchanged;
|
|
const prefix = await readPyInstallerMemberPrefix(
|
|
async (active) =>
|
|
(
|
|
await this.memberSource(
|
|
{
|
|
kind: "carchive",
|
|
archiveStart,
|
|
source: entry,
|
|
header: undefined,
|
|
},
|
|
handle,
|
|
active,
|
|
)
|
|
).source,
|
|
{
|
|
sourceBytes: entry.compressedSize,
|
|
compressed: entry.compressionFlag === 1,
|
|
expectedBytes: entry.uncompressedSize,
|
|
maxBytes: entry.uncompressedSize,
|
|
signal: signal ?? new AbortController().signal,
|
|
},
|
|
4,
|
|
);
|
|
return isHeaderlessPythonCode(prefix)
|
|
? { header: reconstructedPycHeader(magic), limitation: undefined }
|
|
: {
|
|
header: undefined,
|
|
limitation:
|
|
"CArchive module is not identified as headerless marshal code; its original bytes are preserved without reconstructing a Python bytecode header.",
|
|
};
|
|
}
|
|
|
|
async spool(
|
|
handle: FileHandle,
|
|
archiveStart: number,
|
|
entry: CArchiveEntry,
|
|
signal?: AbortSignal,
|
|
): Promise<{ handle: FileHandle; start: number }> {
|
|
abortIfNeeded(signal);
|
|
if (entry.compressedSize === 0)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"Compressed PYZ image has no zlib bytes",
|
|
);
|
|
this.#root ??= await mkdtemp(join(tmpdir(), "rea-pyz-"));
|
|
const path = join(this.#root, String(this.#serial++));
|
|
const writer = await open(path, "wx", 0o600);
|
|
this.#retain(path, new OwnedFileHandle(writer));
|
|
abortIfNeeded(signal);
|
|
const source = readArtifactFileChunks(handle, {
|
|
start: archiveStart + entry.offset,
|
|
end: archiveStart + entry.offset + entry.compressedSize - 1,
|
|
signal,
|
|
});
|
|
const stream = pyInstallerMemberStream(source, {
|
|
sourceBytes: entry.compressedSize,
|
|
compressed: true,
|
|
header: undefined,
|
|
expectedBytes: entry.uncompressedSize,
|
|
maxBytes: entry.uncompressedSize,
|
|
signal: signal ?? new AbortController().signal,
|
|
});
|
|
const copied = this.#writeImage(writer, stream.output, signal);
|
|
const outcomes = await Promise.allSettled([stream.completion, copied]);
|
|
// A failed native write can retire the upstream stream. Preserve that I/O
|
|
// failure instead of replacing it with a secondary premature-close error.
|
|
const copy = outcomes[1];
|
|
if (copy?.status === "rejected") throw copy.reason;
|
|
const decoded = outcomes[0];
|
|
if (decoded?.status === "rejected") throw decoded.reason;
|
|
await this.release(writer);
|
|
abortIfNeeded(signal);
|
|
const { handle: opened, initial } = await this.#admit(path, signal);
|
|
this.#images.set(opened, { path, initial });
|
|
return { handle: opened, start: 0 };
|
|
}
|
|
|
|
async release(handle: FileHandle): Promise<void> {
|
|
const owner = this.#owners.get(handle);
|
|
if (owner === undefined) return;
|
|
const outcome = await this.#cleanup.release(owner);
|
|
if (outcome.kind === "failed") throw outcome.cause;
|
|
this.#owners.delete(handle);
|
|
}
|
|
|
|
async memberSource(
|
|
read: NonNullable<PyInstallerMember["read"]>,
|
|
root: FileHandle | undefined,
|
|
signal: AbortSignal,
|
|
): Promise<PyInstallerMemberSource> {
|
|
let handle = read.kind === "carchive" ? root : read.handle;
|
|
if (handle === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"PyInstaller archive is closed",
|
|
);
|
|
const image = read.kind === "pyz" ? this.#images.get(handle) : undefined;
|
|
let verify: (() => Promise<void>) | undefined;
|
|
let close: ((primary?: unknown) => Promise<void>) | undefined;
|
|
if (image !== undefined) {
|
|
({ handle } = await this.#admit(image.path, signal));
|
|
const opened = handle;
|
|
verify = () => this.#verifyImage(opened, image);
|
|
close = async (primary) => {
|
|
try {
|
|
await this.release(opened);
|
|
} catch (cause: unknown) {
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
primary ?? cause,
|
|
ArtifactReaderFailure.cleanupObservation(cause, image.path),
|
|
);
|
|
}
|
|
};
|
|
await verify();
|
|
}
|
|
abortIfNeeded(signal);
|
|
const start =
|
|
read.kind === "carchive"
|
|
? read.archiveStart + read.source.offset
|
|
: read.offset;
|
|
const length =
|
|
read.kind === "carchive" ? read.source.compressedSize : read.length;
|
|
const source =
|
|
length === 0
|
|
? Readable.from([])
|
|
: readArtifactFileChunks(handle, {
|
|
start,
|
|
end: start + length - 1,
|
|
signal,
|
|
});
|
|
source.on("error", () => undefined);
|
|
return {
|
|
source,
|
|
...(verify === undefined ? {} : { verify }),
|
|
...(close === undefined ? {} : { close }),
|
|
};
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await this.#cleanup.close();
|
|
this.#owners.clear();
|
|
if (this.#root !== undefined) {
|
|
try {
|
|
await rm(this.#root, { recursive: true, force: true });
|
|
} catch (cause: unknown) {
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
cause,
|
|
ArtifactReaderFailure.cleanupObservation(cause, this.#root),
|
|
);
|
|
}
|
|
this.#root = undefined;
|
|
}
|
|
this.#images.clear();
|
|
}
|
|
|
|
async #admit(
|
|
path: string,
|
|
signal?: AbortSignal,
|
|
): Promise<StableRegularFileDescriptor> {
|
|
try {
|
|
const source = await openRegularFile(path, {
|
|
symlinks: "reject",
|
|
signal,
|
|
// REA just wrote this private image. Settling would wait out a fresh
|
|
// Windows ChangeTime once per image; external archives still settle.
|
|
changeTime: "as-written",
|
|
});
|
|
this.#retain(path, new OwnedFileHandle(source.handle));
|
|
return source;
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileAdmissionFailure)
|
|
this.#retain(path, cause.owner);
|
|
throw artifactFileAdmissionFailure(path, "PyInstaller", cause);
|
|
}
|
|
}
|
|
|
|
#retain(path: string, handle: OwnedFileHandle): void {
|
|
const owner = { kind: "file-handle" as const, resource: path, handle };
|
|
this.#owners.set(handle.handle, owner);
|
|
this.#cleanup.retain(owner);
|
|
}
|
|
|
|
async #writeImage(
|
|
handle: FileHandle,
|
|
source: Readable,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
let position = 0;
|
|
for await (const chunk of source as AsyncIterable<Buffer>) {
|
|
// Stop writing after cancellation; drain only already-produced bytes so
|
|
// the descriptor source can report its in-flight I/O outcome truthfully.
|
|
if (signal?.aborted) continue;
|
|
let offset = 0;
|
|
try {
|
|
while (offset < chunk.length) {
|
|
const { bytesWritten } = await handle.write(
|
|
chunk,
|
|
offset,
|
|
chunk.length - offset,
|
|
position,
|
|
);
|
|
if (bytesWritten === 0)
|
|
throw new ArtifactReaderFailure(
|
|
"io",
|
|
"Private PYZ write made no progress",
|
|
);
|
|
offset += bytesWritten;
|
|
position += bytesWritten;
|
|
if (signal?.aborted) break;
|
|
}
|
|
} catch (cause: unknown) {
|
|
const failure =
|
|
cause instanceof ArtifactReaderFailure
|
|
? cause
|
|
: new ArtifactReaderFailure(
|
|
"io",
|
|
"Private PYZ image could not be written",
|
|
{ cause },
|
|
);
|
|
source.destroy(failure);
|
|
throw failure;
|
|
}
|
|
}
|
|
abortIfNeeded(signal);
|
|
}
|
|
|
|
async #verifyImage(handle: FileHandle, image: StoredImage): Promise<void> {
|
|
const states = await Promise.all([handle.stat(), lstat(image.path)]);
|
|
if (states.some((state) => !sameRegularFileState(image.initial, state)))
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
"Private PYZ image changed after parsing",
|
|
);
|
|
}
|
|
}
|