diff --git a/src/process/ProcessOwnership.runId.test.ts b/src/process/ProcessOwnership.runId.test.ts index f9893d0e29..ff447ad17b 100644 --- a/src/process/ProcessOwnership.runId.test.ts +++ b/src/process/ProcessOwnership.runId.test.ts @@ -3,6 +3,22 @@ import { expect, it } from "vitest"; import type { ProcessOwnershipHost } from "./ProcessOwnership.js"; import { readProcessRunId } from "./ProcessOwnership.js"; +it.each([undefined, "selected-token", ""])( + "reads only the process environment's own run token %j", + async (selected) => { + const environment: Record = Object.create({ + REA_PROCESS_RUN_ID: "inherited-token", + }); + if (selected !== undefined) environment.REA_PROCESS_RUN_ID = selected; + const host: ProcessOwnershipHost = { + listProcesses: async () => [], + environment: async () => environment, + signalGroup: () => undefined, + }; + expect(await readProcessRunId(42, host)).toBe(selected); + }, +); + it("passes cancellation through run-token reads", async () => { const controller = new AbortController(); const cancellation = new Error("cancelled"); diff --git a/src/process/ProcessOwnership.test.ts b/src/process/ProcessOwnership.test.ts index 424211e09c..d2e268e008 100644 --- a/src/process/ProcessOwnership.test.ts +++ b/src/process/ProcessOwnership.test.ts @@ -44,10 +44,18 @@ describe("owned process-group cleanup discovery", () => { ]), ).toEqual([100, 102]); }); - it("drops nameless Linux environment entries", () => { - expect( - parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"), - ).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" }); + it("preserves only named source entries in the Linux environment", () => { + const environment = parseProcessEnvironment( + "=ignored\0REA_PROCESS_RUN_ID=earlier\0EMPTY=\0REA_PROCESS_RUN_ID=owned\0constructor=source\0__proto__=retained\0VALUE=a=b\0", + ); + expect(environment).toEqual({ + REA_PROCESS_RUN_ID: "owned", + EMPTY: "", + constructor: "source", + ["__proto__"]: "retained", + VALUE: "a=b", + }); + expect(environment["toString"]).toBeUndefined(); }); it("matches macOS executable names after process-table normalization", () => { expect( @@ -89,6 +97,19 @@ describe("owned process-group cleanup discovery", () => { }); expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL"); }); + it("does not signal a group when a member only inherits its matching token", async () => { + const inherited: Record = Object.create({ + REA_PROCESS_RUN_ID: ownership.runId, + }); + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: ownership.runId }, + 101: inherited, + }); + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toMatchObject({ + cleaned: false, + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); }); describe("opaque process neighbors during cleanup", () => { diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 2038c92971..027e5de518 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -12,7 +12,10 @@ import { } from "./ProcessOwnershipObservation.js"; import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js"; import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js"; -import { readProcessRunTokens } from "./ProcessRunTokenObservations.js"; +import { + processRunIdFromEnvironment, + readProcessRunTokens, +} from "./ProcessRunTokenObservations.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import type { LinuxEnvironmentRestriction } from "./LinuxProcessStatus.js"; import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; @@ -310,7 +313,7 @@ export const readProcessRunId = async ( host: ProcessOwnershipHost = systemHost, signal?: AbortSignal, ): Promise => - (await host.environment(pid, signal)).REA_PROCESS_RUN_ID; + processRunIdFromEnvironment(await host.environment(pid, signal)); /** Merge duplicate uncertainty rows from discovery and final verification. */ const mergeUnverifiedObservations = ( diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 057cacc855..55df0b1071 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -691,16 +691,15 @@ const unavailableLineage = ( /** Parse the NUL-delimited Linux process environment without nameless keys. */ export const parseProcessEnvironment = ( value: string, -): Readonly> => - Object.fromEntries( - value - .split("\0") - .filter((entry) => entry.indexOf("=") > 0) - .map((entry) => { - const separator = entry.indexOf("="); - return [entry.slice(0, separator), entry.slice(separator + 1)]; - }), - ); +): Readonly> => { + const environment: Record = Object.create(null); + for (const entry of value.split("\0")) { + const separator = entry.indexOf("="); + if (separator > 0) + environment[entry.slice(0, separator)] = entry.slice(separator + 1); + } + return environment; +}; const identityFact = ( observations: ReadonlyMap | undefined, @@ -1274,9 +1273,10 @@ export const createSystemProcessOwnershipHost = ( throw new Error( observation?.reason ?? "process run token could not be read", ); - return observation.runId === undefined - ? {} - : { REA_PROCESS_RUN_ID: observation.runId }; + const environment: Record = Object.create(null); + if (observation.runId !== undefined) + environment.REA_PROCESS_RUN_ID = observation.runId; + return environment; } const { stdout } = await execFileOutput( "ps", @@ -1287,7 +1287,7 @@ export const createSystemProcessOwnershipHost = ( ...(signal === undefined ? {} : { signal }), }, ); - const observedEnvironment: Record = {}; + const observedEnvironment: Record = Object.create(null); for (const match of stdout.matchAll( /(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu, )) { diff --git a/src/process/ProcessRunTokenObservations.ts b/src/process/ProcessRunTokenObservations.ts index c0e3b5cdd1..076b6c936d 100644 --- a/src/process/ProcessRunTokenObservations.ts +++ b/src/process/ProcessRunTokenObservations.ts @@ -7,6 +7,14 @@ import type { const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); +/** Only a variable observed on this process supplies its run token. */ +export const processRunIdFromEnvironment = ( + environment: Readonly>, +): string | undefined => + Object.hasOwn(environment, "REA_PROCESS_RUN_ID") + ? environment.REA_PROCESS_RUN_ID + : undefined; + /** Read only ownership tokens, using per-process fallback for missing batch rows. */ export async function* readProcessRunTokens( host: ProcessOwnershipHost, @@ -42,7 +50,7 @@ export async function* readProcessRunTokens( : await host.environment(process.pid, signal); observation = { state: "readable", - runId: environment.REA_PROCESS_RUN_ID, + runId: processRunIdFromEnvironment(environment), }; } catch (cause: unknown) { signal?.throwIfAborted(); diff --git a/tests/boundary/process/processRunTokenEnvironment.test.ts b/tests/boundary/process/processRunTokenEnvironment.test.ts new file mode 100644 index 0000000000..3742a5374c --- /dev/null +++ b/tests/boundary/process/processRunTokenEnvironment.test.ts @@ -0,0 +1,43 @@ +import { once } from "node:events"; +import { expect } from "vitest"; + +import { readProcessRunId } from "../../../src/process/ProcessOwnership.js"; +import { createSystemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { readProcessRunTokens } from "../../../src/process/ProcessRunTokenObservations.js"; +import { processTest } from "../../support/process/processFixture.js"; + +processTest.skipIf(process.platform !== "linux").for([ + { name: "absent", runId: undefined }, + { name: "selected", runId: "source-process-token" }, +])( + "reads $name run tokens from the live process environment", + async ({ runId }, { processes }) => { + const environment = { + EMPTY: "", + VALUE: "a=b", + ["__proto__"]: "source-value", + ...(runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId }), + }; + const child = processes.spawn( + process.execPath, + ["-e", "process.stdin.resume()"], + { env: environment }, + ); + await once(child, "spawn"); + const host = createSystemProcessOwnershipHost("linux", {}); + const row = (await host.listProcesses()).find( + ({ pid }) => pid === child.pid, + ); + if (row === undefined) throw new Error("Selected child was not observed"); + + expect(await host.environment(row.pid)).toEqual(environment); + expect(await readProcessRunId(row.pid, host)).toBe(runId); + const observations = []; + for await (const observation of readProcessRunTokens(host, [row])) + observations.push(observation); + expect(observations).toEqual([ + { process: row, observation: { state: "readable", runId } }, + ]); + child.stdin.end(); + }, +);