Skip to content

Commit 283f999

Browse files
author
hack-cli-tests
committed
test: explain closed process-policy replay refusals
1 parent d472db6 commit 283f999

5 files changed

Lines changed: 369 additions & 4 deletions

‎tests/e2e/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ Cleanup checks exact native ownership and separately removes its captured read-o
205205
evidence reader. An incomplete teardown fails and preserves both private roots and
206206
recovery identities; ordinary successful cleanup remains unchanged.
207207

208+
The initial-observation replay reports the original query count, protocol match and
209+
ownership outcome. Future replay refusals may also include a fixed callsite reason
210+
for the exact hash-pinned ownership source. Only the immediate owning caller is
211+
matched; source drift, unknown stacks and other failures report `unavailable`.
212+
This diagnostic emits no stack, source path, identifiers, labels or reply values,
213+
and changes no ownership decision, query or budget. It does not reproduce wall
214+
timing, establish the original caller mode, or recover replies that were not saved.
215+
208216
## Native config routing qualification
209217

210218
`native-config-routing` is a required Docker CI scenario using the current compiled

‎tests/e2e/native-process-policy-initial-replay.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
type ProcessPolicyInitialTraceQuery,
1010
readProcessPolicyInitialTrace,
1111
} from "./native-process-policy-initial-trace.ts";
12+
import {
13+
isProcessPolicyReplayReason,
14+
type ProcessPolicyReplayReason,
15+
} from "./native-process-policy-replay-reason.ts";
1216

1317
const REFUSAL = "Initial process-policy replay unavailable; values omitted";
1418
const SERVICES = ["forced", "graceful", "reaper", "retry"] as const;
@@ -24,6 +28,7 @@ type Replay = {
2428
readonly code: (typeof CODES)[number] | null;
2529
readonly consumed: number;
2630
readonly protocolMatched: boolean;
31+
readonly reason: ProcessPolicyReplayReason | null;
2732
};
2833
function replayCode(value: unknown): value is Replay["code"] {
2934
return value === null || CODES.some((code) => code === value);
@@ -65,13 +70,16 @@ await Bun.write(Bun.stdout,row.stdout);process.exit(row.exitCode);
6570
);
6671
await chmod(docker, 0o700);
6772
const program = `
68-
import {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError} from ${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))};
73+
import {captureProcessPolicyOwnershipSource,sameProcessPolicyOwnershipSource,processPolicyReplayReason} from ${JSON.stringify(join(import.meta.dir, "native-process-policy-replay-reason.ts"))};
74+
const sourceBefore=captureProcessPolicyOwnershipSource();
75+
const {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError}=await import(${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))});
6976
const {selection}=await Bun.file(${JSON.stringify(inputs)}).json();
70-
let outcome="refused",code=null;
77+
let outcome="refused",code=null,reason=null;
7178
try {const value=${opts.mode === "startup" ? 'await observeNativeComposeStartupOwned(selection,["retry"])' : "await assertNativeComposeOwned(selection)"};outcome=value===null?"unready":"owned";}
72-
catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;}
79+
catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=code==="E_NATIVE_COMPOSE_OWNERSHIP"&&sourceBefore?processPolicyReplayReason({stack:error.stack,sourcePath:sourceBefore.path,sourceSha256:sourceBefore.sha256}):"unavailable";}
80+
if(outcome==="refused"&&!sameProcessPolicyOwnershipSource(sourceBefore,captureProcessPolicyOwnershipSource()))reason="unavailable";
7381
const consumed=(await Bun.file(${JSON.stringify(cursor)}).exists())?Number(await Bun.file(${JSON.stringify(cursor)}).text()):0;
74-
process.stdout.write(JSON.stringify({outcome,code,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())}));
82+
process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())}));
7583
`;
7684
const result = await exec(
7785
[process.execPath, "--no-env-file", "-e", program],
@@ -93,6 +101,13 @@ process.stdout.write(JSON.stringify({outcome,code,consumed,protocolMatched:!(awa
93101
value.outcome === "unready" ||
94102
value.outcome === "refused") &&
95103
replayCode(value.code) &&
104+
(value.reason === null || isProcessPolicyReplayReason(value.reason)) &&
105+
(value.outcome === "refused"
106+
? value.reason !== null
107+
: value.reason === null) &&
108+
(value.code === "E_NATIVE_COMPOSE_OWNERSHIP" ||
109+
value.reason === null ||
110+
value.reason === "unavailable") &&
96111
Number.isInteger(value.consumed) &&
97112
typeof value.consumed === "number" &&
98113
value.consumed >= 0 &&
@@ -105,6 +120,7 @@ process.stdout.write(JSON.stringify({outcome,code,consumed,protocolMatched:!(awa
105120
return {
106121
outcome: value.outcome,
107122
code: value.code,
123+
reason: value.reason,
108124
consumed: value.consumed,
109125
protocolMatched: value.protocolMatched,
110126
};
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { createHash } from "node:crypto";
2+
import {
3+
closeSync,
4+
constants,
5+
fstatSync,
6+
lstatSync,
7+
openSync,
8+
readSync,
9+
realpathSync,
10+
} from "node:fs";
11+
import { fileURLToPath } from "node:url";
12+
13+
const SOURCE_SHA256 =
14+
"8913d0e1f1390b443e04bfb8264f16565f3c2a482c64c6235bb74b645c81c56a";
15+
const SOURCE_PATH = fileURLToPath(
16+
new URL("../../src/lib/native-compose-ownership.ts", import.meta.url)
17+
);
18+
const SOURCE_BUDGET = 128 * 1024;
19+
const HEADER =
20+
"NativeComposeOwnershipError: Native Compose resource ownership is missing, conflicting or changed; values omitted.";
21+
const CALLSITES = [
22+
["collectInspections", 602, "resource-owner-labels"],
23+
["collectInspections", 660, "network-policy"],
24+
["endpointAliases", 745, "endpoint-alias-shape"],
25+
["validateEndpointIdentity", 785, "endpoint-network-id"],
26+
["validateEndpointIdentity", 787, "endpoint-aliases"],
27+
["requireLiveMember", 862, "live-network-membership"],
28+
] as const;
29+
export type ProcessPolicyReplayReason =
30+
| (typeof CALLSITES)[number][2]
31+
| "unavailable";
32+
type SourcePin = {
33+
readonly path: string;
34+
readonly sha256: string;
35+
readonly identity: string;
36+
};
37+
38+
/** Diagnostic evidence only: no probe, admission or cleanup authority. */
39+
export function captureProcessPolicyOwnershipSource(): SourcePin | null {
40+
let fd: number | undefined;
41+
try {
42+
if (realpathSync(SOURCE_PATH) !== SOURCE_PATH) {
43+
return null;
44+
}
45+
fd = openSync(
46+
SOURCE_PATH,
47+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
48+
);
49+
const before = fstatSync(fd);
50+
if (!(before.isFile() && before.size > 0 && before.size <= SOURCE_BUDGET)) {
51+
return null;
52+
}
53+
const bytes = Buffer.alloc(before.size + 1);
54+
let length = 0;
55+
while (length < bytes.length) {
56+
const count = readSync(fd, bytes, length, bytes.length - length, length);
57+
if (count === 0) {
58+
break;
59+
}
60+
length += count;
61+
}
62+
const after = fstatSync(fd);
63+
const identity = (value: typeof before): string =>
64+
JSON.stringify([
65+
value.dev,
66+
value.ino,
67+
value.size,
68+
value.mtimeMs,
69+
value.ctimeMs,
70+
]);
71+
if (
72+
length !== before.size ||
73+
identity(before) !== identity(after) ||
74+
realpathSync(SOURCE_PATH) !== SOURCE_PATH
75+
) {
76+
return null;
77+
}
78+
// The descriptor must still name the current source, not a replaced path.
79+
const current = lstatSync(SOURCE_PATH);
80+
if (!current.isFile() || identity(current) !== identity(after)) {
81+
return null;
82+
}
83+
const sha256 = createHash("sha256")
84+
.update(bytes.subarray(0, length))
85+
.digest("hex");
86+
return sha256 === SOURCE_SHA256
87+
? { path: SOURCE_PATH, sha256, identity: identity(after) }
88+
: null;
89+
} catch {
90+
return null;
91+
} finally {
92+
if (fd !== undefined) {
93+
try {
94+
closeSync(fd);
95+
} catch {
96+
// Diagnostic unavailability must not replace the replay's ownership result.
97+
}
98+
}
99+
}
100+
}
101+
102+
export function sameProcessPolicyOwnershipSource(
103+
before: SourcePin | null,
104+
after: SourcePin | null
105+
): boolean {
106+
return (
107+
before !== null &&
108+
after !== null &&
109+
before.path === after.path &&
110+
before.sha256 === after.sha256 &&
111+
before.identity === after.identity
112+
);
113+
}
114+
115+
export function isProcessPolicyReplayReason(
116+
value: unknown
117+
): value is ProcessPolicyReplayReason {
118+
return (
119+
value === "unavailable" || CALLSITES.some((entry) => entry[2] === value)
120+
);
121+
}
122+
123+
/** The first owning caller must match; never search later frames for a plausible reason. */
124+
export function processPolicyReplayReason(opts: {
125+
readonly stack: unknown;
126+
readonly sourcePath: string;
127+
readonly sourceSha256: unknown;
128+
}): ProcessPolicyReplayReason {
129+
if (
130+
opts.sourceSha256 !== SOURCE_SHA256 ||
131+
opts.sourcePath !== SOURCE_PATH ||
132+
typeof opts.stack !== "string" ||
133+
opts.stack.length > 16 * 1024
134+
) {
135+
return "unavailable";
136+
}
137+
const frames = opts.stack.split("\n");
138+
const matches = (
139+
frame: string | undefined,
140+
name: string,
141+
line: number
142+
): boolean => {
143+
const prefix = ` at ${name} (${SOURCE_PATH}:${line}:`;
144+
if (!(frame?.startsWith(prefix) && frame.endsWith(")"))) {
145+
return false;
146+
}
147+
return /^[1-9][0-9]{0,5}$/.test(frame.slice(prefix.length, -1));
148+
};
149+
if (
150+
frames[0] !== HEADER ||
151+
!matches(frames[1], "refuse", 200) ||
152+
!matches(frames[2], "requireValue", 204)
153+
) {
154+
return "unavailable";
155+
}
156+
return (
157+
CALLSITES.find(([name, line]) => matches(frames[3], name, line))?.[2] ??
158+
"unavailable"
159+
);
160+
}

‎tests/native-process-policy-initial-trace.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc
391391
expect(startup).toEqual({
392392
outcome: "unready",
393393
code: null,
394+
reason: null,
394395
consumed: calls.length,
395396
protocolMatched: true,
396397
});
@@ -403,11 +404,62 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc
403404
expect(strict).toEqual({
404405
outcome: "refused",
405406
code: "E_NATIVE_COMPOSE_OWNERSHIP",
407+
reason: "live-network-membership",
406408
consumed: 6,
407409
protocolMatched: true,
408410
});
409411
expect(JSON.stringify({ startup, strict })).not.toContain(owner);
410412
expect(JSON.stringify({ startup, strict })).not.toContain(id);
413+
const refused = async (
414+
name: string,
415+
replace: (
416+
row: ProcessPolicyInitialTraceQuery
417+
) => ProcessPolicyInitialTraceQuery
418+
) =>
419+
replayProcessPolicyInitialOwnership({
420+
directory: join(root, name),
421+
queries: queries.map(replace),
422+
selection,
423+
mode: "startup",
424+
});
425+
const policy = await refused("policy", (row) =>
426+
JSON.stringify(row.args) === JSON.stringify(inspect("network"))
427+
? { ...row, stdout: JSON.stringify({ ...network, internal: true }) }
428+
: row
429+
);
430+
expect(policy).toEqual({
431+
outcome: "refused",
432+
code: "E_NATIVE_COMPOSE_OWNERSHIP",
433+
reason: "network-policy",
434+
consumed: 6,
435+
protocolMatched: true,
436+
});
437+
const aliases = await refused("aliases", (row) =>
438+
JSON.stringify(row.args) === JSON.stringify(inspect("container"))
439+
? {
440+
...row,
441+
stdout: JSON.stringify({
442+
...container,
443+
networks: {
444+
[`${project}_default`]: {
445+
NetworkID: networkId,
446+
Aliases: ["unexpected-alias"],
447+
},
448+
},
449+
}),
450+
}
451+
: row
452+
);
453+
expect(aliases).toEqual({
454+
outcome: "refused",
455+
code: "E_NATIVE_COMPOSE_OWNERSHIP",
456+
reason: "endpoint-aliases",
457+
consumed: 6,
458+
protocolMatched: true,
459+
});
460+
expect(JSON.stringify({ policy, aliases })).not.toContain(owner);
461+
expect(JSON.stringify({ policy, aliases })).not.toContain(id);
462+
expect(JSON.stringify({ policy, aliases })).not.toContain(import.meta.dir);
411463
const missing = await replayProcessPolicyInitialOwnership({
412464
directory: join(root, "missing"),
413465
queries: queries.slice(0, 1),
@@ -416,6 +468,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc
416468
});
417469
expect(missing.protocolMatched).toBe(false);
418470
expect(missing.outcome).toBe("refused");
471+
expect(missing.reason).toBe("unavailable");
419472
const first = queries[0];
420473
if (!first) {
421474
throw new Error("Missing synthetic query");
@@ -428,6 +481,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc
428481
});
429482
expect(changed.protocolMatched).toBe(false);
430483
expect(changed.outcome).toBe("refused");
484+
expect(changed.reason).toBe("unavailable");
431485
} finally {
432486
await rm(root, { recursive: true, force: true });
433487
}

0 commit comments

Comments
 (0)