fix(process): preserve nested path identities during normalization (#2468)
This commit is contained in:
parent
15463bdbe6
commit
b9ee921560
|
|
@ -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" }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue