From c74bc2108c155be222ca3f8099696769700e7efc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:03:55 +0800 Subject: [PATCH] fix(process): preserve sibling path identities Keep aliases on selected paths at clear text delimiters so a longer sibling filename is not rewritten as the executable. Version the normalization contract and cover source and actual PTY behavior. Fixes #6586 --- docs/process-capture.md | 7 +++ src/domain/process/processScenario.ts | 2 +- .../capture/ProcessNormalization.test.ts | 18 +++++++ src/process/capture/ProcessNormalization.ts | 10 +++- .../processCaptureNormalization.test.ts | 47 ++++++++++++++++++- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index 92a8303584..be1d755c2f 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -269,6 +269,13 @@ while comparisons and trace assertions use the normalized text. Retaining the original adds at most `limits.output_bytes` of text, because it comes from the same admitted PTY chunks rather than a second unbounded stream. +When path normalization is enabled, aliases apply to selected path identities +at clear text delimiters. A selected executable path does not alias a longer +sibling filename such as `tool-helper`; selected parent directories still +normalize within descendant paths. The comparison contract records these rules as +`path-component-boundaries-v2`, so captures made with the earlier prefix rules +cannot be compared as if the text had the same meaning. + Rendered frame `lines` hold the visible rows after normalization, with trailing U+0020 spaces removed. Blank rows and internal spaces remain. `columns` records terminal cell width; normalization and Unicode can make string lengths differ diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 8efc23c575..0339842822 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -133,7 +133,7 @@ export const processComparisonContract = ( limits: scenario.limits, normalization: scenario.normalization, ...(scenario.normalization.paths - ? { path_normalization_version: "longest-original-paths-v1" } + ? { path_normalization_version: "path-component-boundaries-v2" } : {}), ...(scenario.normalization.ports ? { port_normalization_version: "contextual-endpoints-v2" } diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index e23ced1ca9..156523afc8 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -520,3 +520,21 @@ it("treats path metacharacters literally while preferring nested observed roots" " /item /file /log /toolll", ); }); + +it("does not give a selected executable identity to a sibling filename", () => { + const declared = parseProcessScenario({ + executable: "/tmp/process-paths/original/tool", + working_directory: "/tmp/process-paths", + normalization: { paths: true, pids: false, ports: false, patterns: [] }, + }); + expect( + normalizeProcessText( + "/tmp/process-paths/original/tool-helper /tmp/process-paths/original/tool, /tmp/process-paths/original/tool", + declared, + "/tmp/rea-private", + rootPid, + ), + ).toBe( + "/original/tool-helper /original/tool, ", + ); +}); diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index 1f5402993e..34c0992710 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -314,10 +314,16 @@ const createPathRule = ( const pattern = paths .map((path) => path.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")) .join("|"); - const matcher = new RegExp(pattern, "gu"); + // A selected path is an identity, not an arbitrary text prefix. Only match + // it at a clear text delimiter; keep the parent-directory case (`/root` in + // `/root/file`) while leaving punctuation-adjacent names untouched. + const matcher = new RegExp( + `(^|[\\s"'=:(\\[{<])(${pattern})(?=$|/|[\\s"'])`, + "gu", + ); return { matcher, - replace: (path) => aliases.get(path) ?? path, + replace: (_match, prefix, path) => `${prefix}${aliases.get(path) ?? path}`, }; }; diff --git a/tests/boundary/process/processCaptureNormalization.test.ts b/tests/boundary/process/processCaptureNormalization.test.ts index 159f84c935..42ea74a211 100644 --- a/tests/boundary/process/processCaptureNormalization.test.ts +++ b/tests/boundary/process/processCaptureNormalization.test.ts @@ -262,7 +262,7 @@ itWithCaptureCapability( ).toBe(original.replaceAll("\n", "\r\n")); expect(capture.manifest.comparison_contract).toHaveProperty( "path_normalization_version", - "longest-original-paths-v1", + "path-component-boundaries-v2", ); const legacyContract = { ...capture.manifest.comparison_contract }; delete legacyContract.path_normalization_version; @@ -281,6 +281,25 @@ itWithCaptureCapability( ], }), ); + const formerPathSemantics = { + ...capture, + manifest: { + ...capture.manifest, + comparison_contract: { + ...capture.manifest.comparison_contract, + path_normalization_version: "longest-original-paths-v1", + }, + comparison_contract_sha256: digestProcessCommitment({ + ...capture.manifest.comparison_contract, + path_normalization_version: "longest-original-paths-v1", + }), + }, + }; + expect(() => compareProcessCaptures(formerPathSemantics, capture)).toThrow( + expect.objectContaining({ + issues: [expect.objectContaining({ path: ["right"] })], + }), + ); expect(capture.exit).toMatchObject({ code: 0, reason: "exited" }); expect(capture.cleanup).toMatchObject({ owned_process_group: "verified", @@ -289,3 +308,29 @@ itWithCaptureCapability( }, 20_000, ); + +itWithCaptureCapability( + "keeps sibling executable names distinct in actual PTY Evidence", + async () => { + const root = await createTestTempDirectory("rea-process-sibling-path-"); + const executable = join(root, "original", "tool"); + await mkdir(join(root, "original"), { recursive: true }); + await symlink(process.execPath, executable); + const sibling = `${executable}-helper`; + const scenario = parseProcessScenario({ + executable, + working_directory: root, + arguments: ["-e", `process.stdout.write(${JSON.stringify(sibling)})`], + 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( + `/original/tool-helper`, + ); + }, +);