diff --git a/docs/process-capture.md b/docs/process-capture.md index d93a968f3..24cdac571 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -112,8 +112,11 @@ same admitted PTY chunks rather than a second unbounded stream. Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and `listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with -ports from 0 through 65535. It preserves ordinary counters, dimensions, file -line numbers, and ambiguous bare host labels. Use explicit literal `patterns` +ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`; +a period followed by a letter or digit continues a decimal, version, or host +name instead. Each PTY chunk is normalized on its own, so a port whose period +ends the chunk is left unchanged because its continuation is not yet known. It preserves ordinary counters, dimensions, file line numbers, +and ambiguous bare host labels. Use explicit literal `patterns` for a different application-specific spelling. Set `ports: false` to preserve all endpoint numbers in comparison text. Captures commit the port-normalization version so older broad numeric normalization cannot silently compare as the diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 08672cdaf..a23e2bd4e 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -85,7 +85,7 @@ export const processComparisonContract = ( limits: scenario.limits, normalization: scenario.normalization, ...(scenario.normalization.ports - ? { port_normalization_version: "contextual-endpoints-v1" } + ? { port_normalization_version: "contextual-endpoints-v2" } : {}), }); diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index c28d1e42d..8598a91f1 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -99,6 +99,34 @@ it("normalizes only contextual ports before overlapping PID values and honors di ).toBe(input); }); +it("normalizes ports that end a sentence but not decimal or host continuations", () => { + const input = [ + "Listening on port: 8080.", + "Server at http://localhost:8080. Ready", + "Bound [::1]:3000.", + "listen=9000.\n", + "port: 80.5 localhost:8080.5 localhost:8080.example.test", + "port: 8080.β localhost:8080.é port: 8080é", + ].join("\n"); + expect(normalizeProcessText(input, scenario({}), "/temporary", rootPid)).toBe( + [ + "Listening on port: .", + "Server at http://localhost:. Ready", + "Bound [::1]:.", + "listen=.\n", + "port: 80.5 localhost:8080.5 localhost:8080.example.test", + "port: 8080.β localhost:8080.é port: 8080é", + ].join("\n"), + ); +}); + +it("keeps a port before a chunk-final period, whose continuation is unknown", () => { + for (const chunk of ["port: 80.", "http://localhost:8080.", "[::1]:3000."]) + expect( + normalizeProcessText(chunk, scenario({}), "/temporary", rootPid), + ).toBe(chunk); +}); + it("normalizes representative process identities and preserves null, zero, and unrelated numbers", () => { const before = JSON.stringify(samples); const normalized = normalizeProcessSamples(samples, scenario({}), rootPid); diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index d9dd68385..675ac9529 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -41,9 +41,14 @@ export const normalizeProcessText = ( return normalized; }; +// 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. +// PTY chunks are normalized independently, so a period that ends the chunk +// stays unnormalized: its continuation may arrive in the next chunk. const normalizePortTokens = (value: string): string => { const endpointNormalized = value.replaceAll( - /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\w.@:])/giu, + /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#"'<>]+|\blocalhost|\b(?:\d{1,3}\.){3}\d{1,3}|\[[\da-f:.%]+\]):(\d{1,5})(?![\p{L}\p{N}_@:]|\.(?:[\p{L}\p{N}_]|$))/giu, (match: string, endpoint: string, port: string) => { if (Number(port) > 65_535) return match; const isEndpoint = endpoint.includes("://") @@ -54,7 +59,7 @@ const normalizePortTokens = (value: string): string => { }, ); return endpointNormalized.replaceAll( - /(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?![\w.])/giu, + /(\b(?:port|tcp_port|udp_port|listen)\b["']?\s*[:=]\s*)(\d{1,5})(?![\p{L}\p{N}_]|\.(?:[\p{L}\p{N}_]|$))/giu, (match: string, prefix: string, port: string) => Number(port) <= 65_535 ? `${prefix}` : match, ); diff --git a/tests/boundary/process/processCaptureNormalization.test.ts b/tests/boundary/process/processCaptureNormalization.test.ts index 9006cd41e..697a06f63 100644 --- a/tests/boundary/process/processCaptureNormalization.test.ts +++ b/tests/boundary/process/processCaptureNormalization.test.ts @@ -63,7 +63,7 @@ itWithCaptureCapability( ), ).toBe(true); expect(capture.manifest.comparison_contract).toMatchObject({ - port_normalization_version: "contextual-endpoints-v1", + port_normalization_version: "contextual-endpoints-v2", }); expect(capture.exit).toMatchObject({ code: 0, reason: "exited" }); expect(capture.cleanup).toMatchObject({