Files
rea/src/process/capture/ProcessCaptureObservation.test.ts
T
ShuaixinHuang e9fc8fb30c fix(javascript): preserve runtime path bytes and Electron ownership (#4941)
* fix(javascript): preserve literal percent runtime paths

* fix(javascript): ignore resolve prefixes before the final anchor

* fix(electron): retain application roots for loadFile declarations

* fix(electron): resolve loadFile from enclosing application package

* fix(electron): retain outer application root across package scopes

* fix(electron): derive application ownership from inventoried packages

* fix(electron): retain resolved package entrypoint ownership

* fix(electron): preserve competing application ownership

* fix(electron): retain invalid and omitted runtime ownership

* fix(electron): preserve omitted module ownership and runtime paths

* fix(electron): preserve proven roots before confining page paths

* fix(electron): retain possible owners behind missing imports

* fix(electron): distinguish absent imports and owned page candidates

* fix(electron): keep unavailable JSON out of owner gaps

* fix(electron): retain exact loader formats and default entry status

* fix(electron): retain unavailable entrypoint ownership candidates

* test(ghidra): preserve Windows Node probe prerequisites

* test(ghidra): assert cancellation during profile shutdown

* fix(electron): separate examined imports from frontend ownership

* fix(electron): retain unavailable directory entrypoint owners

* test(jeb): retain Linux ownership prerequisite in isolation

* fix(javascript): retain unchecked loader ownership and literal main paths

* fix(javascript): preserve runtime paths and call-site module aliases

* fix(javascript): bound module alias traversal and preserve projections

* fix(javascript): retain host module and nested artifact path boundaries

* fix(javascript): preserve ESM and archive module loader provenance

* test(javascript): distinguish unmapped containers from missing exports

* fix: retain default module aliases and Electron builtin imports

* fix: preserve host file URL and inward ASAR ownership

* fix: preserve CommonJS file URL classification in CI fixtures

* fix: retain bound module loaders and Windows entrypoint uncertainty

* fix: retain ASAR uncertainty and invalidate changed Electron app roots

* fix(javascript): retain ownership uncertainty across loader boundaries

* fix(javascript): preserve loader sources and bound alias queries

* fix(javascript): retain destructured loader sources

* fix(javascript): preserve loader uncertainty at resolution boundaries

* fix(javascript): preserve callable and package launch ownership

* fix(javascript): preserve captured values across member reads

* test(process): assert tagged filesystem cancellation failures

* fix(javascript): preserve inventory bases and invocation read timing

* fix(javascript): preserve ownership across indirect and unread loads

* test(javascript): order fixture directory creation on Windows

* test(javascript): share ordered filesystem fixture writer

* fix(javascript): unify loader provenance and package validation

* fix(javascript): preserve captured Node require loader values

* fix(javascript): preserve loader invocation authority

* fix(javascript): verify generic loader invocation authority

* fix(javascript): preserve conditional loader method alternatives

* fix(javascript): require proven factories for source association

* fix(javascript): honor replaced Electron app path setters

* fix(javascript): prove path and assignment source ownership

* fix(javascript): retain native delegate eligibility through shared writes

* fix(process): settle stale baseline errors after confirmed absence

* fix(javascript): preserve assignment and path boundary evidence

* fix(javascript): prove source-directory resolve anchors

* fix: preserve application path provenance and portable schemas

* fix(javascript): retain CommonJS exported setter effects

* fix: isolate Linux identity observations and retain native module exports

* fix(javascript): preserve published app and helper provenance

* fix(javascript): normalize typed stores and native path fixtures
2026-10-20 17:25:19 +08:00

307 lines
8.7 KiB
TypeScript

import { expect, it } from "vitest";
import {
ProcessCaptureSnapshotFailure,
snapshotRoots,
} from "./FilesystemSnapshot.js";
import { buildCaptureResult } from "./ProcessCaptureLifecycle.js";
import { isInitializedPtyRoot } from "./ProcessSampling.js";
import { readLinuxChildren } from "./LinuxProcessSampling.js";
import { TerminalRenderer } from "./TerminalRenderer.js";
import { parseProcessScenario } from "../../domain/process/processScenario.js";
import type { ProcessCapture } from "../../domain/process/processCaptureParsing.js";
import type { ProcessCaptureTruncationDetails } from "../../domain/process/processCaptureCoverage.js";
import { emptyProcessCapture as emptyCapture } from "../../domain/process/processCapture.fixture.js";
const base = {
executable: "/bin/sh",
working_directory: "/tmp",
};
const emptyFilesystemCoverage = () =>
emptyCapture().truncation_details.filesystem_before;
const truncationDetails = (
frames: readonly ProcessCapture["frames"][number][],
renderedFrames: readonly ProcessCapture["rendered_frames"][number][],
samples: readonly ProcessCapture["process_samples"][number][],
): ProcessCaptureTruncationDetails => {
const emptyDetails = emptyCapture().truncation_details;
const rawBytes = frames.reduce(
(total, frame) => total + Buffer.byteLength(frame.raw_data ?? frame.data),
0,
);
const renderedBytes = renderedFrames.reduce(
(total, frame) =>
frame.lines.reduce(
(sum, line) => sum + Buffer.byteLength(line),
total + Buffer.byteLength(frame.serialized_state),
),
0,
);
return {
raw_terminal: {
...emptyDetails.raw_terminal,
observed_bytes: rawBytes,
retained_bytes: rawBytes,
observed_frames: frames.length,
retained_frames: frames.length,
},
rendered_terminal: {
...emptyDetails.rendered_terminal,
observed_bytes: renderedBytes,
retained_bytes: renderedBytes,
observed_frames: renderedFrames.length,
retained_frames: renderedFrames.length,
},
filesystem_before: emptyDetails.filesystem_before,
filesystem_after: emptyDetails.filesystem_after,
process: {
...emptyDetails.process,
retained_samples: samples.length,
},
};
};
it("returns detached terminal observations", async () => {
const observed: string[] = [];
const renderer = new TerminalRenderer({
columns: 40,
rows: 12,
scrollback: 100,
maxBytes: 100_000,
normalize: (value, maxBytes) => {
const utf8Bytes = Buffer.byteLength(value);
return utf8Bytes <= maxBytes
? { state: "normalized", value, utf8Bytes }
: { state: "over_budget" };
},
recordEvent: (collection, index) =>
observed.push(`${collection}:${String(index)}`),
});
renderer.write("A", 20);
renderer.resize(40, 12, 10);
for (let index = 0; index < 20; index += 1)
renderer.resize(40, 12, 11 + index);
const frames = await renderer.frames();
expect(frames).toHaveLength(22);
expect(frames.slice(0, 2).map(({ at_ms }) => at_ms)).toEqual([20, 10]);
expect(observed).toHaveLength(22);
const frame = frames[0];
if (frame === undefined) throw new Error("expected observed frame");
const originalCursor = frame.cursor_x;
Reflect.set(frame, "cursor_x", 999);
const reread = await renderer.frames();
expect(reread).toHaveLength(frames.length);
expect(reread[0]?.cursor_x).toBe(originalCursor);
await renderer.dispose();
});
it("preserves rendered observation order instead of timestamp sorting", () => {
const capture = emptyCapture();
const renderedFrames: ProcessCapture["rendered_frames"] = [
{
sequence: 0,
at_ms: 20,
columns: 1,
rows: 1,
cursor_x: 0,
cursor_y: 0,
active_buffer: "normal",
lines: ["first"],
serialized_state: "first",
},
{
sequence: 1,
at_ms: 10,
columns: 1,
rows: 1,
cursor_x: 0,
cursor_y: 0,
active_buffer: "normal",
lines: ["second"],
serialized_state: "second",
},
];
const result = buildCaptureResult({
frames: [],
exit: { exitCode: 0, reason: "exited" },
samples: [],
before: {
files: [],
truncated: false,
completeRoots: [],
coverage: emptyFilesystemCoverage(),
},
after: {
files: [],
truncated: false,
completeRoots: [],
coverage: emptyFilesystemCoverage(),
},
truncationDetails: truncationDetails([], renderedFrames, []),
scenario: parseProcessScenario(base),
rootPid: 1,
samplingPartial: false,
renderedFrames,
interactions: [],
checkpoints: capture.filesystem_checkpoints,
settlement: {
state: capture.settlement.state,
elapsed_ms: capture.settlement.elapsed_ms,
},
manifest: capture.manifest,
eventJournal: [],
});
expect(result.rendered_frames).toEqual(renderedFrames);
});
it("marks redacted scripted input as an interaction unknown", () => {
const capture = emptyCapture();
const result = buildCaptureResult({
frames: [],
exit: { exitCode: 0, reason: "exited" },
samples: [],
before: {
files: [],
truncated: false,
completeRoots: [],
coverage: emptyFilesystemCoverage(),
},
after: {
files: [],
truncated: false,
completeRoots: [],
coverage: emptyFilesystemCoverage(),
},
truncationDetails: truncationDetails([], [], []),
scenario: parseProcessScenario({
...base,
events: [{ type: "input", at_ms: 0, data: "secret", sensitive: true }],
}),
rootPid: 1,
samplingPartial: false,
renderedFrames: [],
interactions: [
{
sequence: 0,
scheduled_at_ms: 0,
dispatched_at_ms: 0,
type: "input",
data: "<redacted-input:6-bytes>",
outcome: "dispatched",
},
],
checkpoints: capture.filesystem_checkpoints,
settlement: {
state: capture.settlement.state,
elapsed_ms: capture.settlement.elapsed_ms,
},
manifest: capture.manifest,
eventJournal: [],
});
expect(result.residual_unknowns).toContainEqual({
scope: "interaction",
reason:
"Sensitive scripted input values are redacted from process capture Evidence.",
});
});
it("collects and deduplicates children from every Linux thread", async () => {
const signal = new AbortController().signal;
expect(
await readLinuxChildren(100, signal, {
taskIds: () => Promise.resolve([100, 101, 102]),
children: (_pid, taskId) =>
Promise.resolve(
taskId === 100 ? "201 202" : taskId === 101 ? "202 203" : "",
),
}),
).toEqual({
value: [201, 202, 203],
diagnostics: { count: 0, first: null, interrupted: false },
});
});
it("admits PTY samples only after stable session and token setup", () => {
const initialized = {
pid: 100,
parent_pid: 10,
process_group_id: 100,
session_id: 100,
startTime: "200",
};
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: { ...initialized, process_group_id: 10, session_id: 10 },
observedRunId: undefined,
after: initialized,
}),
).toBe(false);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: initialized,
observedRunId: "run-token",
after: { ...initialized, startTime: "201" },
}),
).toBe(false);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: initialized,
observedRunId: "run-token",
after: initialized,
}),
).toBe(true);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: { ...initialized, session_id: null },
observedRunId: "run-token",
after: { ...initialized, session_id: null },
}),
).toBe(true);
});
it("cancels filesystem snapshots before traversing declared roots", async () => {
const controller = new AbortController();
controller.abort();
const failure: unknown = await snapshotRoots(
parseProcessScenario({
...base,
filesystem_observation_paths: ["/tmp"],
}),
controller.signal,
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(ProcessCaptureSnapshotFailure);
expect(failure).toMatchObject({
_tag: "ProcessCaptureError",
reason: "cancelled",
userCategory: "cancelled",
cause: expect.objectContaining({ name: "AbortError" }),
snapshot: {
files: [],
truncated: true,
completeRoots: [],
coverage: {
hashed_bytes: 0,
enumeration_omissions: [],
hash_omissions: [],
},
},
cleanupLeases: [],
readFailures: [],
});
expect(failure instanceof Error ? failure.cause : undefined).toBe(
controller.signal.reason,
);
});