fix(process): preserve nested path identities during normalization (#2468)

This commit is contained in:
Rudy Mizrahi Celekli 2026-10-13 02:54:18 -04:00 committed by GitHub
parent 15463bdbe6
commit b9ee921560
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 141 additions and 14 deletions

View File

@ -128,6 +128,9 @@ export const processComparisonContract = (
: {}),
limits: scenario.limits,
normalization: scenario.normalization,
...(scenario.normalization.paths
? { path_normalization_version: "longest-original-paths-v1" }
: {}),
...(scenario.normalization.ports
? { port_normalization_version: "contextual-endpoints-v2" }
: {}),

View File

@ -266,3 +266,47 @@ it("normalizes representative process identities and preserves null, zero, and u
"<working-directory>/program child 42020 parent 41010 group 43030 session 44040 listen:<port> selected",
);
});
it("normalizes nested paths once using the most specific selected identity", () => {
const declared = parseProcessScenario({
executable: "/workspace/project/bin/program",
working_directory: "/workspace/project",
filesystem_observation_paths: [
"/workspace/project/data",
"/workspace/project/data/cache",
],
normalization: { paths: true, pids: false, ports: false, patterns: [] },
});
expect(
normalizeProcessText(
"/workspace/project/bin/program /workspace/project/data/cache/item /workspace/project/data/result /workspace/project/log /workspace/tmp",
declared,
"/workspace",
rootPid,
),
).toBe(
"<executable> <filesystem-root-1>/item <filesystem-root-0>/result <working-directory>/log <temporary-root>/tmp",
);
});
it("treats path metacharacters literally while preferring nested observed roots", () => {
const declared = parseProcessScenario({
executable: "/selected/[tool]+",
working_directory: "/selected",
filesystem_observation_paths: [
"/selected/(data)",
"/selected/(data)/cache",
],
normalization: { paths: true, pids: false, ports: false, patterns: [] },
});
expect(
normalizeProcessText(
"/selected/[tool]+ /selected/(data)/cache/item /selected/(data)/file /selected/log /selected/toolll",
declared,
"/tmp",
rootPid,
),
).toBe(
"<executable> <filesystem-root-1>/item <filesystem-root-0>/file <working-directory>/log <working-directory>/toolll",
);
});

View File

@ -17,19 +17,8 @@ export const normalizeProcessText = (
pid: number,
): string => {
let normalized = value;
if (scenario.normalization.paths) {
normalized = normalized.replaceAll(temporaryRoot, "<temporary-root>");
normalized = normalized.replaceAll(
scenario.working_directory,
"<working-directory>",
);
normalized = normalized.replaceAll(scenario.executable, "<executable>");
for (const [index, root] of scenario.filesystem_observation_paths.entries())
normalized = normalized.replaceAll(
root,
`<filesystem-root-${String(index)}>`,
);
}
if (scenario.normalization.paths)
normalized = normalizePathTokens(normalized, scenario, temporaryRoot);
if (scenario.normalization.ports)
normalized = normalizePortTokens(normalized);
if (scenario.normalization.pids)
@ -44,6 +33,39 @@ export const normalizeProcessText = (
return normalized;
};
/** Match original paths once, preferring the most specific selected identity. */
const normalizePathTokens = (
value: string,
scenario: ProcessScenario,
temporaryRoot: string,
): string => {
const aliases = new Map<string, string>();
const selected: readonly (readonly [string, string])[] = [
[temporaryRoot, "<temporary-root>"],
[scenario.working_directory, "<working-directory>"],
[scenario.executable, "<executable>"],
...scenario.filesystem_observation_paths.map(
(root, index): readonly [string, string] => [
root,
`<filesystem-root-${String(index)}>`,
],
),
];
for (const [path, alias] of selected)
if (path !== "" && !aliases.has(path)) aliases.set(path, alias);
const paths = [...aliases.keys()].sort(
(left, right) => right.length - left.length,
);
if (paths.length === 0) return value;
const pattern = paths
.map((path) => path.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
.join("|");
return value.replace(
new RegExp(pattern, "gu"),
(path) => aliases.get(path) ?? path,
);
};
// A port may end a sentence, but a period followed by a letter, digit, or
// underscore in any script continues a decimal, version, or host name and is
// not a port boundary.

View File

@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises";
import { mkdir, readFile, symlink } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
@ -202,3 +202,61 @@ itWithLinuxCaptureCapability.each([false, true])(
},
20_000,
);
itWithCaptureCapability(
"preserves selected nested path identities in actual PTY Evidence",
async () => {
const root = await createTestTempDirectory("rea-process-nested-paths-");
const executable = join(root, "selected-node");
const observed = join(root, "data");
await symlink(process.execPath, executable);
await mkdir(observed);
const original = `${executable}\n${observed}/result\n${root}/log`;
const scenario = parseProcessScenario({
executable,
working_directory: root,
filesystem_observation_paths: [observed],
arguments: ["-e", `process.stdout.write(${JSON.stringify(original)})`],
normalization: { paths: true, pids: false, ports: false, patterns: [] },
settle_ms: 100,
});
const result = await captureProcessScenario(scenario);
if (!result.ok) throw result.error;
const capture = parseProcessCapture(
createProcessCaptureEvidence(scenario, result.value).normalized_result,
);
expect(capture.frames.map(({ data }) => data).join("")).toBe(
"<executable>\r\n<filesystem-root-0>/result\r\n<working-directory>/log",
);
expect(
capture.frames.map(({ raw_data, data }) => raw_data ?? data).join(""),
).toBe(original.replaceAll("\n", "\r\n"));
expect(capture.manifest.comparison_contract).toHaveProperty(
"path_normalization_version",
"longest-original-paths-v1",
);
const legacyContract = { ...capture.manifest.comparison_contract };
delete legacyContract.path_normalization_version;
const legacy = parseProcessCapture({
...capture,
manifest: {
...capture.manifest,
comparison_contract: legacyContract,
comparison_contract_sha256: digestProcessCommitment(legacyContract),
},
});
expect(() => compareProcessCaptures(legacy, capture)).toThrow(
expect.objectContaining({
issues: [
expect.objectContaining({ expected: ["path_normalization_version"] }),
],
}),
);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
expect(capture.cleanup).toMatchObject({
owned_process_group: "verified",
temporary_root: "removed",
});
},
20_000,
);