Presence: the publisher sequence is in-process, the state root keeps no owner-only file, an Idunn generation never self-enrolls
nextPublisherSequence read and rewrote <dataRoot>/runtime-presence-sequence at mode 0600, which the next generation (a fresh DynamicUser uid in the shared state group) cannot open. Odin compares publisher_sequence only within one activation and Idunn mints a new activation per start, so PublisherSequence derives it from the observation clock, never below the previous value plus one, as Muninn does. openProviderHealthIdentity under GAMECULT_IDUNN_RUNTIME_BUNDLE now refuses when the presence descriptor is absent instead of enrolling an identity Idunn does not trust. Adds the cross-generation test (root only) and the sequence, no-dataRoot and missing-descriptor tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
25d43d1121
commit
55732a901d
8 changed files with 351 additions and 32 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
import path from "node:path";
|
|
||||||
import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js";
|
import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js";
|
||||||
import { buildApp } from "./app.js";
|
import { buildApp } from "./app.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
|
|
@ -9,7 +8,7 @@ import type { CultNetOperationServer } from "cultnet-ts";
|
||||||
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
|
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
|
||||||
import {
|
import {
|
||||||
buildPresenceStatement,
|
buildPresenceStatement,
|
||||||
nextPublisherSequence,
|
PublisherSequence,
|
||||||
readRuntimeBundleFacts,
|
readRuntimeBundleFacts,
|
||||||
type RuntimeBundleFacts,
|
type RuntimeBundleFacts,
|
||||||
} from "./runtime-presence.js";
|
} from "./runtime-presence.js";
|
||||||
|
|
@ -76,6 +75,7 @@ async function publishVerseState(): Promise<void> {
|
||||||
* work that can only ever return the same answer or fail.
|
* work that can only ever return the same answer or fail.
|
||||||
*/
|
*/
|
||||||
let runtimeBundleFacts: RuntimeBundleFacts | undefined;
|
let runtimeBundleFacts: RuntimeBundleFacts | undefined;
|
||||||
|
const presenceSequence = new PublisherSequence();
|
||||||
|
|
||||||
async function publishHealthState(): Promise<void> {
|
async function publishHealthState(): Promise<void> {
|
||||||
const pulse = createHeimdallRuntimePulse(config);
|
const pulse = createHeimdallRuntimePulse(config);
|
||||||
|
|
@ -111,9 +111,7 @@ async function publishHealthState(): Promise<void> {
|
||||||
// refuses the pair, and Idunn needs the warming statement to promote it.
|
// refuses the pair, and Idunn needs the warming statement to promote it.
|
||||||
writeLeaseSha256: null,
|
writeLeaseSha256: null,
|
||||||
observedAtUnixMillis,
|
observedAtUnixMillis,
|
||||||
publisherSequence: await nextPublisherSequence(
|
publisherSequence: presenceSequence.next(observedAtUnixMillis),
|
||||||
path.join(config.dataRoot, "runtime-presence-sequence"),
|
|
||||||
),
|
|
||||||
providerHealthIdentityPath: config.providerHealthIdentityPath,
|
providerHealthIdentityPath: config.providerHealthIdentityPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,14 @@ export async function openProviderHealthIdentity(
|
||||||
): Promise<PrivateIdentity> {
|
): Promise<PrivateIdentity> {
|
||||||
const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env);
|
const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env);
|
||||||
if (descriptor === undefined) {
|
if (descriptor === undefined) {
|
||||||
|
if (env.GAMECULT_IDUNN_RUNTIME_BUNDLE) {
|
||||||
|
// Inside an Idunn generation a self-enrolled key is one Idunn does not
|
||||||
|
// trust, and its private store would land in a state root the next
|
||||||
|
// generation must share. The descriptor is the only identity there.
|
||||||
|
throw new Error(
|
||||||
|
`Idunn runtime bundle is set but the ${PRESENCE_IDENTITY_FD_NAME} descriptor was not passed; refusing to self-enroll`
|
||||||
|
);
|
||||||
|
}
|
||||||
return openOrEnrollProviderHealthIdentity(path);
|
return openOrEnrollProviderHealthIdentity(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createHash, createPrivateKey, type KeyObject } from "node:crypto";
|
import { createHash, createPrivateKey, type KeyObject } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { readFile, writeFile, rename } from "node:fs/promises";
|
|
||||||
|
|
||||||
import { decode } from "@msgpack/msgpack";
|
import { decode } from "@msgpack/msgpack";
|
||||||
import {
|
import {
|
||||||
|
|
@ -201,35 +200,25 @@ export function openActivationSigner(env: NodeJS.ProcessEnv = process.env): KeyO
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The publisher sequence must strictly increase for the life of a signer
|
* The publisher sequence a process stamps on its presence statements.
|
||||||
* identity, because Idunn treats a repeat or a regression as a replay and
|
|
||||||
* refuses the observation.
|
|
||||||
*
|
*
|
||||||
* An in-process counter is therefore not enough: a restart would reset it and
|
* Odin compares `publisher_sequence` only against stored presence of the same
|
||||||
* every statement afterwards would be refused until the count climbed past the
|
* activation, and Idunn mints a fresh activation for every start, continuity
|
||||||
* old high-water mark. It lives beside the target's state instead, and is
|
* restarts included. So the sequence need only strictly increase within this
|
||||||
* advanced before use.
|
* process; nothing has to survive a restart, and nothing is written to the
|
||||||
|
* state root. Muninn derives its sequence the same way: the observation clock,
|
||||||
|
* never below the previous value plus one.
|
||||||
*/
|
*/
|
||||||
export async function nextPublisherSequence(sequencePath: string): Promise<number> {
|
export class PublisherSequence {
|
||||||
let current = 0;
|
private last = 0;
|
||||||
try {
|
|
||||||
const raw = (await readFile(sequencePath, "utf8")).trim();
|
next(observedAtUnixMillis: number): number {
|
||||||
const parsed = Number(raw);
|
if (!Number.isSafeInteger(observedAtUnixMillis)) {
|
||||||
if (Number.isSafeInteger(parsed) && parsed > 0) {
|
throw new Error("observedAtUnixMillis is not a safe integer");
|
||||||
current = parsed;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
this.last = Math.max(observedAtUnixMillis, this.last + 1);
|
||||||
|
return this.last;
|
||||||
}
|
}
|
||||||
const next = current + 1;
|
|
||||||
// Written through a temporary file: a torn write here would look like a
|
|
||||||
// regression and cost the target every future observation.
|
|
||||||
const temporary = `${sequencePath}.next`;
|
|
||||||
await writeFile(temporary, `${next}\n`, { mode: 0o600 });
|
|
||||||
await rename(temporary, sequencePath);
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PresenceStatementInputs {
|
export interface PresenceStatementInputs {
|
||||||
|
|
|
||||||
72
tests/fixtures/presence-descriptors.ts
vendored
Normal file
72
tests/fixtures/presence-descriptors.ts
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* What Idunn hands a generation for runtime presence: a parent-only identity
|
||||||
|
* store and an activation seed, passed as descriptors named in LISTEN_FDNAMES.
|
||||||
|
* Every key here is generated for the test or a canary byte pattern.
|
||||||
|
*/
|
||||||
|
import { closeSync, openSync } from "node:fs";
|
||||||
|
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { encode } from "@msgpack/msgpack";
|
||||||
|
import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
|
||||||
|
import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts";
|
||||||
|
|
||||||
|
import { openOrEnrollProviderHealthIdentity } from "../../src/provider-health-identity.js";
|
||||||
|
import type { RuntimeBundleFacts } from "../../src/runtime-presence.js";
|
||||||
|
|
||||||
|
export const bundleFacts: RuntimeBundleFacts = {
|
||||||
|
target: "heimdall",
|
||||||
|
planId: "plan-1",
|
||||||
|
incarnationId: "incarnation-1",
|
||||||
|
sealedReleaseId: "release-1",
|
||||||
|
runtimeId: "runtime-1",
|
||||||
|
runtimeInstanceId: "instance-1",
|
||||||
|
expectedProjectionSha256: "sha256-projection",
|
||||||
|
activationWitnessSha256: "sha256-witness",
|
||||||
|
activationSignerIdentityId: "activation-signer",
|
||||||
|
stateSchemaGeneration: null,
|
||||||
|
stateContractSha256: null,
|
||||||
|
capabilities: [],
|
||||||
|
healthContract: "health-contract",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Write both stores into `dir`, readable by any uid (the children run as others). */
|
||||||
|
export async function writePresenceFixtures(dir: string): Promise<void> {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await chmod(dir, 0o755);
|
||||||
|
const enrolled = path.join(dir, "enrolled.cc");
|
||||||
|
await openOrEnrollProviderHealthIdentity(enrolled);
|
||||||
|
const [record] = await new SingleFileMessagePackBackingStore(enrolled).pullAll();
|
||||||
|
// Re-wrapped the way cultcache-rs writes a store, which is how Idunn writes it.
|
||||||
|
await writeFile(
|
||||||
|
path.join(dir, "runtime-presence-identity.cc"),
|
||||||
|
encode([
|
||||||
|
[
|
||||||
|
"gamecult-provider-health-identity",
|
||||||
|
"gamecult.provider_health_identity.private.v1",
|
||||||
|
record!.payload,
|
||||||
|
"2026-09-06T00:00:00.000Z",
|
||||||
|
"gamecult.provider_health_identity.private.v1",
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
{ mode: 0o644 }
|
||||||
|
);
|
||||||
|
await writeFile(path.join(dir, "activation-seed"), Buffer.alloc(32, 7), { mode: 0o644 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open both descriptors and name them the way systemd's LISTEN_FDNAMES does. */
|
||||||
|
export function openPresenceDescriptors(dir: string): { env: NodeJS.ProcessEnv; close: () => void } {
|
||||||
|
const fds = [
|
||||||
|
[openSync(path.join(dir, "runtime-presence-identity.cc"), "r"), "gamecult-runtime-presence-identity"],
|
||||||
|
[openSync(path.join(dir, "activation-seed"), "r"), IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME],
|
||||||
|
] as const;
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const [fd, name] of fds) {
|
||||||
|
while (names.length < fd - 3) names.push(`pad-${names.length}`);
|
||||||
|
names[fd - 3] = name;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
env: { LISTEN_FDNAMES: names.join(":") },
|
||||||
|
close: () => fds.forEach(([fd]) => closeSync(fd)),
|
||||||
|
};
|
||||||
|
}
|
||||||
58
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
58
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* One Idunn generation, as a process: it runs as the uid and gid its parent
|
||||||
|
* gave it, under UMask=0007, and does what Heimdall does to a state root on a
|
||||||
|
* pulse. The parent test spawns two of these under different uids that share
|
||||||
|
* a group.
|
||||||
|
*
|
||||||
|
* argv: <role: first|next> <stateRoot> <fixtureDir>
|
||||||
|
*/
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { loadConfig } from "../../src/config.js";
|
||||||
|
import { PublisherSequence, buildPresenceStatement } from "../../src/runtime-presence.js";
|
||||||
|
import { bundleFacts, openPresenceDescriptors } from "./presence-descriptors.js";
|
||||||
|
import { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../../src/verse-state.js";
|
||||||
|
|
||||||
|
const [role, stateRoot, fixtureDir] = process.argv.slice(2) as [string, string, string];
|
||||||
|
process.umask(0o007);
|
||||||
|
|
||||||
|
const config = loadConfig({
|
||||||
|
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
|
||||||
|
GC_ACCESS_DATA_ROOT: stateRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function publishPresence(): Promise<void> {
|
||||||
|
const descriptors = openPresenceDescriptors(fixtureDir);
|
||||||
|
try {
|
||||||
|
const observedAtUnixMillis = Date.now();
|
||||||
|
await buildPresenceStatement({
|
||||||
|
bundle: bundleFacts,
|
||||||
|
state: "warming",
|
||||||
|
detail: "generation probe",
|
||||||
|
boundEndpoint: null,
|
||||||
|
writeLeaseSha256: null,
|
||||||
|
observedAtUnixMillis,
|
||||||
|
publisherSequence: new PublisherSequence().next(observedAtUnixMillis),
|
||||||
|
providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"),
|
||||||
|
env: descriptors.env,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
descriptors.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "next") {
|
||||||
|
// Generation N+1 reads what N left before it replaces it.
|
||||||
|
const previous = await readFile(config.cultCachePath);
|
||||||
|
if (previous.length === 0) throw new Error("previous generation's snapshot is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishHeimdallVerseState(config, createHeimdallRuntimePulse(config));
|
||||||
|
await publishPresence();
|
||||||
|
|
||||||
|
if (role === "first") {
|
||||||
|
// The 0600 counter the 2026-09-07 generation left behind. Nothing reads it
|
||||||
|
// now; it stays as the stale fixture the next generation must live beside.
|
||||||
|
await writeFile(path.join(stateRoot, "runtime-presence-sequence"), "41\n", { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
import { openSync, closeSync } from "node:fs";
|
import { openSync, closeSync } from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
@ -112,3 +112,29 @@ describe("presence identity descriptor selection", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("an Idunn generation without the presence identity descriptor", () => {
|
||||||
|
it("refuses, naming the descriptor, and enrolls nothing", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodescriptor-"));
|
||||||
|
const identityPath = path.join(dir, "provider-health-identity.cc");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
openProviderHealthIdentity(identityPath, {
|
||||||
|
GAMECULT_IDUNN_RUNTIME_BUNDLE: "/run/idunn/bundle",
|
||||||
|
LISTEN_FDNAMES: "some-other-descriptor",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("gamecult-runtime-presence-identity");
|
||||||
|
|
||||||
|
await expect(access(identityPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still self-enrolls outside Idunn", async () => {
|
||||||
|
if (!machineIdAvailable) return;
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-outside-idunn-"));
|
||||||
|
const identityPath = path.join(dir, "provider-health-identity.cc");
|
||||||
|
|
||||||
|
await openProviderHealthIdentity(identityPath, {});
|
||||||
|
|
||||||
|
await expect(access(identityPath)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
87
tests/publisher-sequence.test.ts
Normal file
87
tests/publisher-sequence.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { PublisherSequence, buildPresenceStatement } from "../src/runtime-presence.js";
|
||||||
|
import { bundleFacts, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||||
|
|
||||||
|
const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
||||||
|
() => true,
|
||||||
|
() => false
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("PublisherSequence", () => {
|
||||||
|
it("starts at the observed millis", () => {
|
||||||
|
expect(new PublisherSequence().next(1_700_000_000_000)).toBe(1_700_000_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strictly increases for equal, earlier and later observations", () => {
|
||||||
|
const sequence = new PublisherSequence();
|
||||||
|
const first = sequence.next(1_700_000_000_000);
|
||||||
|
const equal = sequence.next(1_700_000_000_000);
|
||||||
|
const earlier = sequence.next(1_699_999_990_000);
|
||||||
|
const later = sequence.next(1_700_000_050_000);
|
||||||
|
expect([first, equal, earlier, later]).toEqual([
|
||||||
|
1_700_000_000_000,
|
||||||
|
1_700_000_000_001,
|
||||||
|
1_700_000_000_002,
|
||||||
|
1_700_000_050_000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never falls below the observed millis once the clock moves ahead", () => {
|
||||||
|
const sequence = new PublisherSequence();
|
||||||
|
sequence.next(1_700_000_000_000);
|
||||||
|
sequence.next(1_700_000_000_000);
|
||||||
|
sequence.next(1_700_000_000_000);
|
||||||
|
expect(sequence.next(1_700_000_000_002)).toBe(1_700_000_000_003);
|
||||||
|
expect(sequence.next(1_700_000_000_010)).toBe(1_700_000_000_010);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a value that is not a safe integer and keeps its place", () => {
|
||||||
|
const sequence = new PublisherSequence();
|
||||||
|
expect(sequence.next(10)).toBe(10);
|
||||||
|
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, 2 ** 60]) {
|
||||||
|
expect(() => sequence.next(bad)).toThrow("observedAtUnixMillis");
|
||||||
|
}
|
||||||
|
expect(sequence.next(5)).toBe(11);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!machineIdAvailable)("presence publication and the state root", () => {
|
||||||
|
it("touches no path under dataRoot", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-"));
|
||||||
|
await writePresenceFixtures(path.join(dir, "fixtures"));
|
||||||
|
// A regular file as dataRoot: opening anything beneath it fails with
|
||||||
|
// ENOTDIR for every uid, root included.
|
||||||
|
const dataRoot = path.join(dir, "not-a-directory");
|
||||||
|
await writeFile(dataRoot, "");
|
||||||
|
const config = loadConfig({
|
||||||
|
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
|
||||||
|
GC_ACCESS_DATA_ROOT: dataRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const descriptors = openPresenceDescriptors(path.join(dir, "fixtures"));
|
||||||
|
try {
|
||||||
|
const observedAtUnixMillis = Date.parse("2026-10-23T12:00:00.000Z");
|
||||||
|
const statement = await buildPresenceStatement({
|
||||||
|
bundle: bundleFacts,
|
||||||
|
state: "warming",
|
||||||
|
detail: "probe",
|
||||||
|
boundEndpoint: null,
|
||||||
|
writeLeaseSha256: null,
|
||||||
|
observedAtUnixMillis,
|
||||||
|
publisherSequence: new PublisherSequence().next(observedAtUnixMillis),
|
||||||
|
providerHealthIdentityPath: config.providerHealthIdentityPath,
|
||||||
|
env: descriptors.env,
|
||||||
|
});
|
||||||
|
expect(statement.target).toBe("heimdall");
|
||||||
|
} finally {
|
||||||
|
descriptors.close();
|
||||||
|
}
|
||||||
|
expect((await readdir(dir)).sort()).toEqual(["fixtures", "not-a-directory"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
tests/state-root-generations.test.ts
Normal file
81
tests/state-root-generations.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { chmod, chown, mkdir, mkdtemp, readdir, stat } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
const STATE_GID = 4242;
|
||||||
|
const GENERATION_N = 61184;
|
||||||
|
const GENERATION_N_PLUS_1 = 61185;
|
||||||
|
|
||||||
|
// Creating uids and groups needs root; the stopgap's node container runs as root.
|
||||||
|
const isRoot = process.getuid?.() === 0;
|
||||||
|
|
||||||
|
async function walk(dir: string): Promise<string[]> {
|
||||||
|
const found: string[] = [];
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
found.push(full);
|
||||||
|
if (entry.isDirectory()) found.push(...(await walk(full)));
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idunn gives every generation a fresh DynamicUser uid and one shared group,
|
||||||
|
* UMask=0007, and a 2770 setgid state root. Generation N+1 runs beside or after
|
||||||
|
* N and must be able to read and replace everything N left there.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!isRoot)("Idunn state root across generations", () => {
|
||||||
|
it("generation N+1, a different uid in the same group, reads and replaces generation N's state", async () => {
|
||||||
|
const base = await mkdtemp(path.join(os.tmpdir(), "heimdall-generations-"));
|
||||||
|
await chmod(base, 0o755);
|
||||||
|
|
||||||
|
// The fixtures stand in for what Idunn passes as descriptors.
|
||||||
|
const fixtures = path.join(base, "fixtures");
|
||||||
|
await writePresenceFixtures(fixtures);
|
||||||
|
|
||||||
|
const stateRoot = path.join(base, "state");
|
||||||
|
await mkdir(stateRoot);
|
||||||
|
await chown(stateRoot, 0, STATE_GID);
|
||||||
|
await chmod(stateRoot, 0o2770);
|
||||||
|
|
||||||
|
const child = path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts");
|
||||||
|
const generation = (role: "first" | "next", uid: number) =>
|
||||||
|
run(process.execPath, ["--import", "tsx", child, role, stateRoot, fixtures], {
|
||||||
|
uid,
|
||||||
|
gid: STATE_GID,
|
||||||
|
cwd: path.resolve(import.meta.dirname, ".."),
|
||||||
|
env: { PATH: process.env.PATH ?? "", HOME: fixtures },
|
||||||
|
});
|
||||||
|
|
||||||
|
await generation("first", GENERATION_N);
|
||||||
|
|
||||||
|
const left = await walk(stateRoot);
|
||||||
|
expect(left.map((entry) => path.relative(stateRoot, entry)).sort()).toEqual([
|
||||||
|
"cultcache",
|
||||||
|
path.join("cultcache", "heimdall.service.cc"),
|
||||||
|
"runtime-presence-sequence",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await generation("next", GENERATION_N_PLUS_1);
|
||||||
|
|
||||||
|
// Generation N+1 replaced the snapshot, and no owner-only file is left
|
||||||
|
// behind for generation N+2: everything but the stale 0600 fixture is
|
||||||
|
// readable and writable by the state group.
|
||||||
|
const snapshot = await stat(path.join(stateRoot, "cultcache", "heimdall.service.cc"));
|
||||||
|
expect(snapshot.uid).toBe(GENERATION_N_PLUS_1);
|
||||||
|
expect(snapshot.gid).toBe(STATE_GID);
|
||||||
|
for (const entry of await walk(stateRoot)) {
|
||||||
|
if (path.basename(entry) === "runtime-presence-sequence") continue;
|
||||||
|
expect((await stat(entry)).mode & 0o060, path.relative(stateRoot, entry)).toBe(0o060);
|
||||||
|
}
|
||||||
|
expect((await readdir(stateRoot)).sort()).toEqual(["cultcache", "runtime-presence-sequence"]);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue