fix: harden runtime config lease boundary
This commit is contained in:
@@ -77,9 +77,9 @@ export function sessionRoutes(
|
|||||||
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
|
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
|
||||||
};
|
};
|
||||||
|
|
||||||
const optionsWithRuntimeConfig = (runner: any, workspaceConfigPath: string | undefined, options: any) => (
|
const optionsWithRuntimeConfig = async (runner: any, workspaceConfigPath: string | undefined, options: any) => (
|
||||||
workspaceConfigPath && typeof runner.acquireWorkspaceRuntime === "function"
|
workspaceConfigPath && typeof runner.acquireWorkspaceRuntime === "function"
|
||||||
? { ...options, runtimeConfig: runner.acquireWorkspaceRuntime(workspaceConfigPath) }
|
? { ...options, runtimeConfig: await runner.acquireWorkspaceRuntime(workspaceConfigPath) }
|
||||||
: options
|
: options
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -431,7 +431,7 @@ export function sessionRoutes(
|
|||||||
let runtimeOptions = options;
|
let runtimeOptions = options;
|
||||||
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
|
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
|
||||||
try {
|
try {
|
||||||
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
runtimeOptions = await optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
||||||
rt = d.mgr.createFor(id, runtimeOptions);
|
rt = d.mgr.createFor(id, runtimeOptions);
|
||||||
bindRuntime(id, rt, runner, workspaceConfigPath);
|
bindRuntime(id, rt, runner, workspaceConfigPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -616,7 +616,7 @@ export function sessionRoutes(
|
|||||||
if (boundRuntimes.get(id) === current) boundRuntimes.delete(id);
|
if (boundRuntimes.get(id) === current) boundRuntimes.delete(id);
|
||||||
d.mgr.teardownIfCurrent(id, current);
|
d.mgr.teardownIfCurrent(id, current);
|
||||||
}
|
}
|
||||||
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
runtimeOptions = await optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
||||||
rt = d.mgr.createFor(id, runtimeOptions);
|
rt = d.mgr.createFor(id, runtimeOptions);
|
||||||
bindRuntime(id, rt, runner, workspaceConfigPath);
|
bindRuntime(id, rt, runner, workspaceConfigPath);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export class ThtRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Render the pinned registry revision through the shared deterministic lease. */
|
/** Render the pinned registry revision through the shared deterministic lease. */
|
||||||
acquireWorkspaceRuntime(workspaceConfigPath: string): RuntimeConfigLease {
|
async acquireWorkspaceRuntime(workspaceConfigPath: string): Promise<RuntimeConfigLease> {
|
||||||
return this.runtimeConfigLeases().acquireSession(workspaceConfigPath);
|
return this.runtimeConfigLeases().acquireSession(workspaceConfigPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ export class ThtRunner {
|
|||||||
static readonly DEFAULT_TIMEOUT_MS = 60_000;
|
static readonly DEFAULT_TIMEOUT_MS = 60_000;
|
||||||
static readonly DWH_TIMEOUT_MS = 120_000;
|
static readonly DWH_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
run(
|
async run(
|
||||||
args: string[], workspaceConfigPath?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS,
|
args: string[], workspaceConfigPath?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS,
|
||||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
if (
|
if (
|
||||||
@@ -293,7 +293,7 @@ export class ThtRunner {
|
|||||||
) {
|
) {
|
||||||
let runtime: RuntimeConfigLease;
|
let runtime: RuntimeConfigLease;
|
||||||
try {
|
try {
|
||||||
runtime = this.acquireWorkspaceRuntime(workspaceConfigPath);
|
runtime = await this.acquireWorkspaceRuntime(workspaceConfigPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { isIP } from "node:net";
|
import { isIP } from "node:net";
|
||||||
import { domainToASCII } from "node:url";
|
import { domainToASCII } from "node:url";
|
||||||
import {
|
import {
|
||||||
existsSync, lstatSync, readFileSync,
|
existsSync, lstatSync, readFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
import { dirname, isAbsolute, join, relative, resolve, normalize } from "node:path";
|
||||||
import { parseAllDocuments } from "yaml";
|
import { parseAllDocuments } from "yaml";
|
||||||
import { resolveRuntimeBindings } from "./bindings.js";
|
import { resolveRuntimeBindings } from "./bindings.js";
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +53,14 @@ interface SnapshotIdentity {
|
|||||||
descriptorDev: string;
|
descriptorDev: string;
|
||||||
descriptorIno: string;
|
descriptorIno: string;
|
||||||
}
|
}
|
||||||
|
interface PublishedResponse {
|
||||||
|
path: string;
|
||||||
|
manifestPath: string;
|
||||||
|
manifest: string;
|
||||||
|
manifest_sha256: string;
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
}
|
||||||
interface PublishedIdentity {
|
interface PublishedIdentity {
|
||||||
path: string;
|
path: string;
|
||||||
manifestPath: string;
|
manifestPath: string;
|
||||||
@@ -142,28 +150,25 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
} as RuntimeInstallationOverlay;
|
} as RuntimeInstallationOverlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
acquireSession(snapshotPath: string): RuntimeConfigLease { return this.acquire(snapshotPath); }
|
async acquireSession(snapshotPath: string): Promise<RuntimeConfigLease> { return this.acquire(snapshotPath); }
|
||||||
acquireMaintenance(input: MaintenanceRuntimeInput): RuntimeConfigLease {
|
async acquireMaintenance(input: MaintenanceRuntimeInput): Promise<RuntimeConfigLease> {
|
||||||
const snapshotPath = input.snapshotPath ?? input.workspaceConfigPath;
|
const snapshotPath = input.snapshotPath ?? input.workspaceConfigPath;
|
||||||
if (!snapshotPath) throw new Error("maintenance runtime snapshot is required");
|
if (!snapshotPath) throw new Error("maintenance runtime snapshot is required");
|
||||||
return this.acquire(snapshotPath);
|
return this.acquire(snapshotPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private acquire(snapshotPath: string): RuntimeConfigLease {
|
private async acquire(snapshotPath: string): Promise<RuntimeConfigLease> {
|
||||||
const snapshot = this.readSnapshot(snapshotPath);
|
const snapshot = await this.readSnapshot(snapshotPath);
|
||||||
const paths = this.runtimePaths(snapshot.workspaceId);
|
const paths = this.runtimePaths(snapshot.workspaceId);
|
||||||
const rendered = renderRuntimeConfig(snapshot.workspace, resolveRuntimeBindings(snapshot.workspace, this.env, this.secretRoots), paths, snapshot, this.installation, this.input.semanticRuntime ?? DEFAULT_SEMANTIC_RUNTIME);
|
const rendered = renderRuntimeConfig(snapshot.workspace, resolveRuntimeBindings(snapshot.workspace, this.env, this.secretRoots), paths, snapshot, this.installation, this.input.semanticRuntime ?? DEFAULT_SEMANTIC_RUNTIME);
|
||||||
const renderedDigest = digest(rendered);
|
const renderedDigest = digest(rendered);
|
||||||
const base = {
|
const base = {
|
||||||
workspace_id: snapshot.workspaceId, workspace_revision: snapshot.workspaceRevision,
|
workspace_id: snapshot.workspaceId, workspace_revision: snapshot.workspaceRevision,
|
||||||
descriptor_git_blob: snapshot.descriptorBlob!,
|
descriptor_git_blob: snapshot.descriptorBlob!, descriptor_sha256: snapshot.digest,
|
||||||
descriptor_sha256: snapshot.digest,
|
descriptor_dev: snapshot.descriptorDev, descriptor_ino: snapshot.descriptorIno,
|
||||||
descriptor_dev: snapshot.descriptorDev,
|
config_sha256: renderedDigest, config_dwh_binding: await this.computeBinding(rendered),
|
||||||
descriptor_ino: snapshot.descriptorIno,
|
|
||||||
config_sha256: renderedDigest,
|
|
||||||
config_dwh_binding: this.computeBinding(rendered),
|
|
||||||
};
|
};
|
||||||
const result = this.publishSecure(snapshot.workspaceId, snapshot.workspaceRevision, rendered, base);
|
const result = await this.publishSecure(snapshot.workspaceId, snapshot.workspaceRevision, rendered, base);
|
||||||
const identity: PublishedIdentity = {
|
const identity: PublishedIdentity = {
|
||||||
path: result.path, manifestPath: result.manifestPath, workspaceId: snapshot.workspaceId,
|
path: result.path, manifestPath: result.manifestPath, workspaceId: snapshot.workspaceId,
|
||||||
workspaceRevision: snapshot.workspaceRevision, digest: renderedDigest, content: rendered,
|
workspaceRevision: snapshot.workspaceRevision, digest: renderedDigest, content: rendered,
|
||||||
@@ -172,41 +177,68 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
return this.lease(identity);
|
return this.lease(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private helper(action: string, extra: Record<string, unknown>): any {
|
private async helper(action: string, extra: Record<string, unknown>): Promise<unknown> {
|
||||||
const python = join(this.input.harnessDir, ".venv", "bin", "python");
|
const python = join(this.input.harnessDir, ".venv", "bin", "python");
|
||||||
const modulePath = existsSync(join(this.input.harnessDir, "tht", "runtime_config_lease_io.py"))
|
const modulePath = existsSync(join(this.input.harnessDir, "tht", "runtime_config_lease_io.py"))
|
||||||
? join(this.input.harnessDir, "tht", "runtime_config_lease_io.py")
|
? join(this.input.harnessDir, "tht", "runtime_config_lease_io.py")
|
||||||
: join(process.cwd(), "../harness/tht/runtime_config_lease_io.py");
|
: join(process.cwd(), "../harness/tht/runtime_config_lease_io.py");
|
||||||
// Fixtures may provide a temporary harness directory; still execute the real
|
|
||||||
// project helper environment, never a fabricated TypeScript binding.
|
|
||||||
const projectPython = join(dirname(dirname(modulePath)), ".venv", "bin", "python");
|
const projectPython = join(dirname(dirname(modulePath)), ".venv", "bin", "python");
|
||||||
const executable = existsSync(python) ? python
|
const executable = existsSync(python) ? python : existsSync(projectPython) ? projectPython : (process.env.PYTHON ?? "python3");
|
||||||
: existsSync(projectPython) ? projectPython : (process.env.PYTHON ?? "python3");
|
|
||||||
const helperArgs = existsSync(modulePath) ? [modulePath] : ["-m", "tht.runtime_config_lease_io"];
|
const helperArgs = existsSync(modulePath) ? [modulePath] : ["-m", "tht.runtime_config_lease_io"];
|
||||||
const result = spawnSync(executable, helperArgs, { cwd: this.input.harnessDir,
|
const env = { ...this.env };
|
||||||
input: JSON.stringify({ action, ...extra }), encoding: "utf8",
|
// Never pass ambient capability variables to binding/snapshot/publication.
|
||||||
env: { ...this.env, PYTHONPATH: [this.input.harnessDir, dirname(dirname(modulePath)), this.env.PYTHONPATH].filter(Boolean).join(":"), }, });
|
for (const key of Object.keys(env)) {
|
||||||
if (result.status !== 0) {
|
if ((key.startsWith("THT_RUNTIME_CONFIG_") && !["THT_RUNTIME_CONFIG_FSYNC_FAIL", "THT_RUNTIME_CONFIG_RENAME_FAIL"].includes(key)) || key.startsWith("THT_CONFIG_")) delete env[key];
|
||||||
let detail = result.stderr?.trim() || result.stdout?.trim() || `runtime config ${action} failed`;
|
|
||||||
try { detail = JSON.parse(result.stdout).error ?? detail; } catch { /* preserve helper detail */ }
|
|
||||||
throw new Error(detail);
|
|
||||||
}
|
}
|
||||||
try { return JSON.parse(result.stdout); } catch { throw new Error(`runtime config ${action} returned invalid JSON`); }
|
env.PYTHONPATH = [this.input.harnessDir, dirname(dirname(modulePath)), env.PYTHONPATH].filter(Boolean).join(":");
|
||||||
|
const payload = JSON.stringify({ protocol_version: 1, action, ...extra });
|
||||||
|
const timeoutMs = 10_000;
|
||||||
|
return await new Promise((resolveResult, reject) => {
|
||||||
|
const child = spawn(executable, helperArgs, { cwd: this.input.harnessDir, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
||||||
|
let stdout = ""; let stderr = ""; let settled = false;
|
||||||
|
const finish = (error?: Error, value?: unknown) => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolveResult(value); };
|
||||||
|
const kill = () => { try { process.kill(-child.pid!, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* gone */ } } };
|
||||||
|
const timer = setTimeout(() => { kill(); finish(new Error(`runtime config ${action} timed out`)); }, timeoutMs);
|
||||||
|
const append = (target: "stdout" | "stderr", data: Buffer) => {
|
||||||
|
const next = target === "stdout" ? stdout + data.toString() : stderr + data.toString();
|
||||||
|
if (next.length > 16 * 1024 * 1024) { kill(); finish(new Error(`runtime config ${action} output exceeded limit`)); return; }
|
||||||
|
if (target === "stdout") stdout = next; else stderr = next;
|
||||||
|
};
|
||||||
|
child.stdout.on("data", (d: Buffer) => append("stdout", d)); child.stderr.on("data", (d: Buffer) => append("stderr", d));
|
||||||
|
child.on("error", (error) => finish(error));
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) { let detail = stderr.trim() || stdout.trim() || `runtime config ${action} failed`; try { detail = (JSON.parse(stdout) as {error?: string}).error ?? detail; } catch { /* preserve detail */ } finish(new Error(detail)); return; }
|
||||||
|
try { finish(undefined, JSON.parse(stdout)); } catch { finish(new Error(`runtime config ${action} returned invalid JSON`)); }
|
||||||
|
});
|
||||||
|
child.stdin.end(payload);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeBinding(content: string): Record<string, string> {
|
private async computeBinding(content: string): Promise<Record<string, string>> {
|
||||||
try {
|
const value = await this.helper("binding", { config_hex: Buffer.from(content).toString("hex") });
|
||||||
const value = this.helper("binding", { config_hex: Buffer.from(content).toString("hex") });
|
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "config_fingerprint,input_fingerprint,workspace_id") throw new Error("runtime config binding helper returned malformed output");
|
||||||
if (value && typeof value.workspace_id === "string" && typeof value.config_fingerprint === "string" && typeof value.input_fingerprint === "string") return value;
|
const record = value as Record<string, unknown>;
|
||||||
throw new Error("runtime config binding helper returned malformed output");
|
if (Object.values(record).some((v) => typeof v !== "string")) throw new Error("runtime config binding helper returned malformed output");
|
||||||
} catch (error) {
|
return record as Record<string, string>;
|
||||||
throw error instanceof Error ? error : new Error("runtime config binding failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record<string, unknown>): {path:string; manifestPath:string; manifest:string; manifest_sha256:string} {
|
private async publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record<string, unknown>): Promise<PublishedResponse> {
|
||||||
return this.helper("publish", { data_root: this.input.dataRoot, workspace_id: workspaceId,
|
const value = await this.helper("publish", { data_root: this.input.dataRoot, workspace_id: workspaceId,
|
||||||
workspace_revision: revision, config_hex: Buffer.from(content).toString("hex"), manifest_base: manifestBase });
|
workspace_revision: revision, config_hex: Buffer.from(content).toString("hex"), manifest_base: manifestBase });
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("runtime config publish helper returned malformed output");
|
||||||
|
const result = value as Record<string, unknown>;
|
||||||
|
if (Object.keys(result).sort().join(",") !== "dev,ino,manifest,manifestPath,manifest_sha256,path") throw new Error("runtime config publish helper returned malformed output");
|
||||||
|
let expectedRoot = resolve(this.input.dataRoot);
|
||||||
|
if (process.platform === "darwin" && (expectedRoot === "/var" || expectedRoot.startsWith("/var/") || expectedRoot === "/tmp" || expectedRoot.startsWith("/tmp/"))) expectedRoot = `/private${expectedRoot}`;
|
||||||
|
const expectedPath = join(expectedRoot, "sessions", workspaceId, "preprocessing", "runtime-config", `${revision}.yaml`);
|
||||||
|
const expectedManifestPath = join(expectedRoot, "sessions", workspaceId, "preprocessing", "runtime-config-manifests", `${revision}.json`);
|
||||||
|
if (result.path !== expectedPath || result.manifestPath !== expectedManifestPath
|
||||||
|
|| typeof result.path !== "string" || !isAbsolute(result.path) || normalize(result.path) !== result.path
|
||||||
|
|| typeof result.manifestPath !== "string" || !isAbsolute(result.manifestPath) || normalize(result.manifestPath) !== result.manifestPath
|
||||||
|
|| result.workspace_id !== undefined || typeof result.manifest !== "string"
|
||||||
|
|| typeof result.manifest_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(result.manifest_sha256)
|
||||||
|
|| typeof result.dev !== "number" || !Number.isSafeInteger(result.dev) || typeof result.ino !== "number" || !Number.isSafeInteger(result.ino)) throw new Error("runtime config publish helper returned malformed output");
|
||||||
|
return result as unknown as PublishedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
private lease(identity: PublishedIdentity): RuntimeConfigLease {
|
private lease(identity: PublishedIdentity): RuntimeConfigLease {
|
||||||
@@ -225,7 +257,7 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
if (!e.isDirectory() || e.isSymbolicLink() || e.nlink < 1 || (e.mode & 0o077) !== 0 || e.uid !== process.getuid?.()) throw new Error(`${label} is not trusted`);
|
if (!e.isDirectory() || e.isSymbolicLink() || e.nlink < 1 || (e.mode & 0o077) !== 0 || e.uid !== process.getuid?.()) throw new Error(`${label} is not trusted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private readSnapshot(path: string): SnapshotIdentity {
|
private async readSnapshot(path: string): Promise<SnapshotIdentity> {
|
||||||
if (!isAbsolute(path)) throw new Error("workspace snapshot path must be absolute");
|
if (!isAbsolute(path)) throw new Error("workspace snapshot path must be absolute");
|
||||||
const root = resolve(this.input.runtimeSnapshotRoot);
|
const root = resolve(this.input.runtimeSnapshotRoot);
|
||||||
const rel = relative(root, path);
|
const rel = relative(root, path);
|
||||||
@@ -235,11 +267,14 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
// production snapshot.json and descriptor component-by-component, and MUST prove
|
// production snapshot.json and descriptor component-by-component, and MUST prove
|
||||||
// the Git commit/blob identity; a pathname-shaped file is never sufficient.
|
// the Git commit/blob identity; a pathname-shaped file is never sufficient.
|
||||||
const repositoryRoot = join(dirname(root), "repo");
|
const repositoryRoot = join(dirname(root), "repo");
|
||||||
const verified = this.helper("verified-snapshot", {
|
const verified = await this.helper("verified-snapshot", {
|
||||||
snapshots_root: root, repository_root: repositoryRoot,
|
snapshots_root: root, repository_root: repositoryRoot,
|
||||||
workspace_revision: match[1], workspace_id: match[2],
|
workspace_revision: match[1], workspace_id: match[2],
|
||||||
});
|
}) as Record<string, unknown>;
|
||||||
if (!verified || typeof verified.source !== "string" || typeof verified.git_source !== "string"
|
const verifiedKeys = ["descriptor_dev", "descriptor_git_blob", "descriptor_ino", "git_source", "sha256", "snapshot_path", "source", "workspace_id", "workspace_revision"];
|
||||||
|
if (Object.keys(verified).sort().join(",") !== verifiedKeys.join(",")
|
||||||
|
|| verified.workspace_id !== match[2] || verified.workspace_revision !== match[1]
|
||||||
|
|| typeof verified.source !== "string" || typeof verified.git_source !== "string"
|
||||||
|| verified.sha256 !== digest(verified.source) || verified.snapshot_path !== path
|
|| verified.sha256 !== digest(verified.source) || verified.snapshot_path !== path
|
||||||
|| !/^\d+$/.test(String(verified.descriptor_dev)) || !/^\d+$/.test(String(verified.descriptor_ino))) {
|
|| !/^\d+$/.test(String(verified.descriptor_dev)) || !/^\d+$/.test(String(verified.descriptor_ino))) {
|
||||||
throw new Error("workspace snapshot integrity check failed");
|
throw new Error("workspace snapshot integrity check failed");
|
||||||
|
|||||||
@@ -84,11 +84,11 @@ function fixture(extraEnv: Record<string, string> = {}) {
|
|||||||
return { root, repo, snapshotPath, factory, factoryInput, canonicalDescriptor, snapshotManifest: join(snapshotsDir, "snapshot.json") };
|
return { root, repo, snapshotPath, factory, factoryInput, canonicalDescriptor, snapshotManifest: join(snapshotsDir, "snapshot.json") };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session and maintenance share deterministic bytes and path", () => {
|
test("session and maintenance share deterministic bytes and path", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const session = f.factory.acquireSession(f.snapshotPath);
|
const session = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const maintenance = f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath });
|
const maintenance = await f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath });
|
||||||
expect(session.path).toBe(maintenance.path);
|
expect(session.path).toBe(maintenance.path);
|
||||||
expect(readFileSync(session.path, "utf8")).toBe(readFileSync(maintenance.path, "utf8"));
|
expect(readFileSync(session.path, "utf8")).toBe(readFileSync(maintenance.path, "utf8"));
|
||||||
expect(lstatSync(session.path).mode & 0o777).toBe(0o400);
|
expect(lstatSync(session.path).mode & 0o777).toBe(0o400);
|
||||||
@@ -101,19 +101,19 @@ test("session and maintenance share deterministic bytes and path", () => {
|
|||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("same revision changed bytes are refused", () => {
|
test("same revision changed bytes are refused", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const first = f.factory.acquireSession(f.snapshotPath);
|
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||||
chmodSync(first.path, 0o600);
|
chmodSync(first.path, 0o600);
|
||||||
writeFileSync(first.path, "changed", { mode: 0o600 });
|
writeFileSync(first.path, "changed", { mode: 0o600 });
|
||||||
chmodSync(first.path, 0o400);
|
chmodSync(first.path, 0o400);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/changed|mismatch|trusted/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|trusted/i);
|
||||||
first.release();
|
first.release();
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("snapshot descriptor must equal the Git canonical descriptor", () => {
|
test("snapshot descriptor must equal the Git canonical descriptor", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const mutated = f.canonicalDescriptor.replace("database: analytics", "database: evil").replace("name: Lease", "name: Lease analytics");
|
const mutated = f.canonicalDescriptor.replace("database: analytics", "database: evil").replace("name: Lease", "name: Lease analytics");
|
||||||
@@ -126,29 +126,29 @@ test("snapshot descriptor must equal the Git canonical descriptor", () => {
|
|||||||
chmodSync(manifestPath, 0o600);
|
chmodSync(manifestPath, 0o600);
|
||||||
writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 });
|
writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 });
|
||||||
chmodSync(manifestPath, 0o400);
|
chmodSync(manifestPath, 0o400);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/Git descriptor|integrity|identity/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/Git descriptor|integrity|identity/i);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("same-byte replacement of the registry descriptor is refused", () => {
|
test("same-byte replacement of the registry descriptor is refused", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const first = f.factory.acquireSession(f.snapshotPath);
|
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const replacement = `${f.snapshotPath}.replacement`;
|
const replacement = `${f.snapshotPath}.replacement`;
|
||||||
writeFileSync(replacement, readFileSync(f.snapshotPath), { mode: 0o400 });
|
writeFileSync(replacement, readFileSync(f.snapshotPath), { mode: 0o400 });
|
||||||
chmodSync(f.snapshotPath, 0o600);
|
chmodSync(f.snapshotPath, 0o600);
|
||||||
rmSync(f.snapshotPath);
|
rmSync(f.snapshotPath);
|
||||||
writeFileSync(f.snapshotPath, readFileSync(replacement), { mode: 0o400 });
|
writeFileSync(f.snapshotPath, readFileSync(replacement), { mode: 0o400 });
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/identity|changed|mismatch|trusted/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/identity|changed|mismatch|trusted/i);
|
||||||
first.release();
|
first.release();
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test("manifest binds the complete canonical destination directory chain", () => {
|
test("manifest binds the complete canonical destination directory chain", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const lease = f.factory.acquireSession(f.snapshotPath);
|
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8"));
|
const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8"));
|
||||||
expect(manifest.directory_identities.length).toBeGreaterThan(5);
|
expect(manifest.directory_identities.length).toBeGreaterThan(5);
|
||||||
expect(manifest.directory_identities.map((entry: { path: string }) => entry.path)).toContain(
|
expect(manifest.directory_identities.map((entry: { path: string }) => entry.path)).toContain(
|
||||||
@@ -160,7 +160,7 @@ test("manifest binds the complete canonical destination directory chain", () =>
|
|||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("raw Git identity ignores replacement refs", () => {
|
test("raw Git identity ignores replacement refs", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const evil = f.canonicalDescriptor.replace("database: analytics", "database: evil");
|
const evil = f.canonicalDescriptor.replace("database: analytics", "database: evil");
|
||||||
@@ -179,14 +179,14 @@ test("raw Git identity ignores replacement refs", () => {
|
|||||||
chmodSync(f.snapshotManifest, 0o600);
|
chmodSync(f.snapshotManifest, 0o600);
|
||||||
writeFileSync(f.snapshotManifest, JSON.stringify(snapshot));
|
writeFileSync(f.snapshotManifest, JSON.stringify(snapshot));
|
||||||
chmodSync(f.snapshotManifest, 0o400);
|
chmodSync(f.snapshotManifest, 0o400);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/Git descriptor|integrity|identity/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/Git descriptor|integrity|identity/i);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replacement of canonical destination directories is refused", () => {
|
test("replacement of canonical destination directories is refused", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const lease = f.factory.acquireSession(f.snapshotPath);
|
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const original = join(f.root, "data", "sessions", workspace);
|
const original = join(f.root, "data", "sessions", workspace);
|
||||||
const moved = `${original}.moved`;
|
const moved = `${original}.moved`;
|
||||||
renameSync(original, moved);
|
renameSync(original, moved);
|
||||||
@@ -194,14 +194,14 @@ test("replacement of canonical destination directories is refused", () => {
|
|||||||
mkdirSync(join(original, "preprocessing", "runtime-config-manifests"), { recursive: true, mode: 0o700 });
|
mkdirSync(join(original, "preprocessing", "runtime-config-manifests"), { recursive: true, mode: 0o700 });
|
||||||
renameSync(join(moved, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`), join(original, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`));
|
renameSync(join(moved, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`), join(original, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`));
|
||||||
renameSync(join(moved, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`), join(original, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`));
|
renameSync(join(moved, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`), join(original, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`));
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/changed|mismatch|same-revision|identity|trusted/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|same-revision|identity|trusted/i);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rename faults fail closed and remove staging files", () => {
|
test("rename faults fail closed and remove staging files", async () => {
|
||||||
const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: "1" });
|
const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: "1" });
|
||||||
try {
|
try {
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/rename|failed/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/rename|failed/i);
|
||||||
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
||||||
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
||||||
if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).filter((name) => name.includes("staging")).length).toBe(0);
|
if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).filter((name) => name.includes("staging")).length).toBe(0);
|
||||||
@@ -210,11 +210,11 @@ test("rename faults fail closed and remove staging files", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test("session and operator outputs retain normalized private-host policy and binding", () => {
|
test("session and operator outputs retain normalized private-host policy and binding", async () => {
|
||||||
const f = fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: "internal.example,warehouse.example" });
|
const f = fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: "internal.example,warehouse.example" });
|
||||||
try {
|
try {
|
||||||
const session = f.factory.acquireSession(f.snapshotPath);
|
const session = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const maintenance = f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath });
|
const maintenance = await f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath });
|
||||||
const output = readFileSync(session.path, "utf8");
|
const output = readFileSync(session.path, "utf8");
|
||||||
expect(output).toContain("http_private_host_allowlist");
|
expect(output).toContain("http_private_host_allowlist");
|
||||||
expect(output).toContain("- internal.example");
|
expect(output).toContain("- internal.example");
|
||||||
@@ -228,29 +228,29 @@ test("session and operator outputs retain normalized private-host policy and bin
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test("unexpected manifest fields are refused before handoff", () => {
|
test("unexpected manifest fields are refused before handoff", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const lease = f.factory.acquireSession(f.snapshotPath);
|
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8"));
|
const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8"));
|
||||||
manifest.unexpected = true;
|
manifest.unexpected = true;
|
||||||
chmodSync(lease.manifestPath, 0o600);
|
chmodSync(lease.manifestPath, 0o600);
|
||||||
writeFileSync(lease.manifestPath, JSON.stringify(manifest));
|
writeFileSync(lease.manifestPath, JSON.stringify(manifest));
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/manifest|invalid|changed/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/manifest|invalid|changed/i);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runtime config symlink replacement is refused", () => {
|
test("runtime config symlink replacement is refused", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const lease = f.factory.acquireSession(f.snapshotPath);
|
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const replacement = `${lease.path}.real`;
|
const replacement = `${lease.path}.real`;
|
||||||
writeFileSync(replacement, readFileSync(lease.path), { mode: 0o400 });
|
writeFileSync(replacement, readFileSync(lease.path), { mode: 0o400 });
|
||||||
chmodSync(lease.path, 0o600);
|
chmodSync(lease.path, 0o600);
|
||||||
rmSync(lease.path);
|
rmSync(lease.path);
|
||||||
// A no-follow handoff must never consume this pathname.
|
// A no-follow handoff must never consume this pathname.
|
||||||
execFileSync("ln", ["-s", replacement, lease.path]);
|
execFileSync("ln", ["-s", replacement, lease.path]);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|changed|configuration|symbolic/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|changed|configuration|symbolic/i);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,11 +260,11 @@ function realHarnessBinding(config: string): Record<string, string> {
|
|||||||
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
||||||
return JSON.parse(execFileSync(python, [helper], {
|
return JSON.parse(execFileSync(python, [helper], {
|
||||||
cwd: join(process.cwd(), "..", "harness"), encoding: "utf8",
|
cwd: join(process.cwd(), "..", "harness"), encoding: "utf8",
|
||||||
input: JSON.stringify({ action: "binding", config_hex: Buffer.from(config).toString("hex") }),
|
input: JSON.stringify({ protocol_version: 1, action: "binding", config_hex: Buffer.from(config).toString("hex") }),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("explicit installation overlay is canonical and has one real harness binding", () => {
|
test("explicit installation overlay is canonical and has one real harness binding", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
const overlay = {
|
const overlay = {
|
||||||
profile: "workstation",
|
profile: "workstation",
|
||||||
@@ -277,8 +277,8 @@ test("explicit installation overlay is canonical and has one real harness bindin
|
|||||||
const sessionFactory = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, installationOverlay: overlay });
|
const sessionFactory = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, installationOverlay: overlay });
|
||||||
const maintenanceFactory = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, installationOverlay: overlay });
|
const maintenanceFactory = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, installationOverlay: overlay });
|
||||||
try {
|
try {
|
||||||
const session = sessionFactory.acquireSession(f.snapshotPath);
|
const session = await sessionFactory.acquireSession(f.snapshotPath);
|
||||||
const maintenance = maintenanceFactory.acquireMaintenance({ workspaceConfigPath: f.snapshotPath });
|
const maintenance = await maintenanceFactory.acquireMaintenance({ workspaceConfigPath: f.snapshotPath });
|
||||||
const sessionYaml = readFileSync(session.path, "utf8");
|
const sessionYaml = readFileSync(session.path, "utf8");
|
||||||
const maintenanceYaml = readFileSync(maintenance.path, "utf8");
|
const maintenanceYaml = readFileSync(maintenance.path, "utf8");
|
||||||
expect(session.path).toBe(maintenance.path);
|
expect(session.path).toBe(maintenance.path);
|
||||||
@@ -298,10 +298,10 @@ test("explicit installation overlay is canonical and has one real harness bindin
|
|||||||
test.each([
|
test.each([
|
||||||
["config-file", "config-file"], ["config-parent", "config-parent"],
|
["config-file", "config-file"], ["config-parent", "config-parent"],
|
||||||
["manifest-file", "manifest-file"], ["manifest-parent", "manifest-parent"],
|
["manifest-file", "manifest-file"], ["manifest-parent", "manifest-parent"],
|
||||||
] as const)("fsync fault at %s fails closed and retries to the same durable pair", (_label, stage) => {
|
] as const)("fsync fault at %s fails closed and retries to the same durable pair", async (_label, stage) => {
|
||||||
const f = fixture({ THT_RUNTIME_CONFIG_FSYNC_FAIL: stage });
|
const f = fixture({ THT_RUNTIME_CONFIG_FSYNC_FAIL: stage });
|
||||||
try {
|
try {
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/fsync|failed/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/fsync|failed/i);
|
||||||
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
||||||
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
||||||
if (existsSync(join(runtime, dir))) {
|
if (existsSync(join(runtime, dir))) {
|
||||||
@@ -311,7 +311,7 @@ test.each([
|
|||||||
const recovered = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, env: {
|
const recovered = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, env: {
|
||||||
...f.factoryInput.env, THT_RUNTIME_CONFIG_FSYNC_FAIL: undefined,
|
...f.factoryInput.env, THT_RUNTIME_CONFIG_FSYNC_FAIL: undefined,
|
||||||
} });
|
} });
|
||||||
const lease = recovered.acquireSession(f.snapshotPath);
|
const lease = await recovered.acquireSession(f.snapshotPath);
|
||||||
expect(existsSync(lease.path)).toBe(true);
|
expect(existsSync(lease.path)).toBe(true);
|
||||||
expect(existsSync(lease.manifestPath)).toBe(true);
|
expect(existsSync(lease.manifestPath)).toBe(true);
|
||||||
expect(JSON.parse(readFileSync(lease.manifestPath, "utf8")).config_sha256)
|
expect(JSON.parse(readFileSync(lease.manifestPath, "utf8")).config_sha256)
|
||||||
@@ -324,10 +324,10 @@ for (const [label, mutate] of [
|
|||||||
["outside path", (f: ReturnType<typeof fixture>) => join(f.root, "outside.yaml")],
|
["outside path", (f: ReturnType<typeof fixture>) => join(f.root, "outside.yaml")],
|
||||||
["wrong workspace id", (f: ReturnType<typeof fixture>) => f.snapshotPath.replace("abc.yaml", "abd.yaml")],
|
["wrong workspace id", (f: ReturnType<typeof fixture>) => f.snapshotPath.replace("abc.yaml", "abd.yaml")],
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`rejects ${label} before publication`, () => {
|
test(`rejects ${label} before publication`, async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
expect(() => f.factory.acquireSession(mutate(f))).toThrow(/trusted|snapshot|identity|path|Git|unavailable|ENOENT|No such/i);
|
await expect(f.factory.acquireSession(mutate(f))).rejects.toThrow(/trusted|snapshot|identity|path|Git|unavailable|ENOENT|No such/i);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -336,11 +336,11 @@ for (const [label, replace] of [
|
|||||||
["descriptor symlink", (path: string, root: string) => { const target = `${path}.target`; writeFileSync(target, readFileSync(path), { mode: 0o400 }); rmSync(path); execFileSync("ln", ["-s", target, path]); }],
|
["descriptor symlink", (path: string, root: string) => { const target = `${path}.target`; writeFileSync(target, readFileSync(path), { mode: 0o400 }); rmSync(path); execFileSync("ln", ["-s", target, path]); }],
|
||||||
["descriptor hardlink", (path: string, root: string) => { const target = `${path}.target`; execFileSync("ln", [path, target]); rmSync(path); execFileSync("ln", [target, path]); }],
|
["descriptor hardlink", (path: string, root: string) => { const target = `${path}.target`; execFileSync("ln", [path, target]); rmSync(path); execFileSync("ln", [target, path]); }],
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`rejects ${label}`, () => {
|
test(`rejects ${label}`, async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
replace(f.snapshotPath, f.root);
|
replace(f.snapshotPath, f.root);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|integrity|identity|link/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|integrity|identity|link/i);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -349,23 +349,23 @@ for (const [label, target] of [
|
|||||||
["config symlink", "config"], ["config hardlink", "config"],
|
["config symlink", "config"], ["config hardlink", "config"],
|
||||||
["manifest symlink", "manifest"], ["manifest hardlink", "manifest"],
|
["manifest symlink", "manifest"], ["manifest hardlink", "manifest"],
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`rejects destination ${label} and recovers safely`, () => {
|
test(`rejects destination ${label} and recovers safely`, async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const first = f.factory.acquireSession(f.snapshotPath);
|
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const path = target === "config" ? first.path : first.manifestPath;
|
const path = target === "config" ? first.path : first.manifestPath;
|
||||||
const backup = `${path}.target`;
|
const backup = `${path}.target`;
|
||||||
writeFileSync(backup, readFileSync(path), { mode: target === "config" ? 0o400 : 0o600 });
|
writeFileSync(backup, readFileSync(path), { mode: target === "config" ? 0o400 : 0o600 });
|
||||||
rmSync(path);
|
rmSync(path);
|
||||||
if (label.includes("symlink")) execFileSync("ln", ["-s", backup, path]);
|
if (label.includes("symlink")) execFileSync("ln", ["-s", backup, path]);
|
||||||
else execFileSync("ln", [backup, path]);
|
else execFileSync("ln", [backup, path]);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|configuration|manifest|link|changed/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|configuration|manifest|link|changed/i);
|
||||||
rmSync(path);
|
rmSync(path);
|
||||||
// A replaced inode can never be trusted again. Remove the paired durable
|
// A replaced inode can never be trusted again. Remove the paired durable
|
||||||
// publication and let a fresh no-replace publication recover the layout.
|
// publication and let a fresh no-replace publication recover the layout.
|
||||||
rmSync(first.path, { force: true });
|
rmSync(first.path, { force: true });
|
||||||
rmSync(first.manifestPath, { force: true });
|
rmSync(first.manifestPath, { force: true });
|
||||||
const recovered = f.factory.acquireSession(f.snapshotPath);
|
const recovered = await f.factory.acquireSession(f.snapshotPath);
|
||||||
expect(recovered.path).toBe(first.path);
|
expect(recovered.path).toBe(first.path);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
@@ -374,44 +374,44 @@ for (const [label, target] of [
|
|||||||
for (const [label, target, mode] of [
|
for (const [label, target, mode] of [
|
||||||
["config", "config", 0o600], ["manifest", "manifest", 0o400],
|
["config", "config", 0o600], ["manifest", "manifest", 0o400],
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`refuses wrong ${label} mode then recovers after restoring mode`, () => {
|
test(`refuses wrong ${label} mode then recovers after restoring mode`, async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const first = f.factory.acquireSession(f.snapshotPath);
|
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const path = target === "config" ? first.path : first.manifestPath;
|
const path = target === "config" ? first.path : first.manifestPath;
|
||||||
chmodSync(path, mode);
|
chmodSync(path, mode);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|mode|configuration|manifest/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|mode|configuration|manifest/i);
|
||||||
chmodSync(path, target === "config" ? 0o400 : 0o600);
|
chmodSync(path, target === "config" ? 0o400 : 0o600);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).not.toThrow();
|
await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined();
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("release retains durable state while changed binding is refused by a new factory", () => {
|
test("release retains durable state while changed binding is refused by a new factory", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const first = f.factory.acquireSession(f.snapshotPath);
|
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||||
first.release();
|
first.release();
|
||||||
const changed = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, env: {
|
const changed = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, env: {
|
||||||
...f.factoryInput.env, THT_WS_ABC_DWH_HOST: "other-dwh",
|
...f.factoryInput.env, THT_WS_ABC_DWH_HOST: "other-dwh",
|
||||||
} });
|
} });
|
||||||
expect(() => changed.acquireSession(f.snapshotPath)).toThrow(/changed|mismatch|configuration/i);
|
await expect(changed.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|configuration/i);
|
||||||
expect(existsSync(first.path)).toBe(true);
|
expect(existsSync(first.path)).toBe(true);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strict manifest rejects an extra field and recovery preserves exact bytes", () => {
|
test("strict manifest rejects an extra field and recovery preserves exact bytes", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const first = f.factory.acquireSession(f.snapshotPath);
|
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const original = readFileSync(first.manifestPath, "utf8");
|
const original = readFileSync(first.manifestPath, "utf8");
|
||||||
const manifest = JSON.parse(original);
|
const manifest = JSON.parse(original);
|
||||||
manifest.extra = "reject";
|
manifest.extra = "reject";
|
||||||
chmodSync(first.manifestPath, 0o600);
|
chmodSync(first.manifestPath, 0o600);
|
||||||
writeFileSync(first.manifestPath, JSON.stringify(manifest), { mode: 0o600 });
|
writeFileSync(first.manifestPath, JSON.stringify(manifest), { mode: 0o600 });
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/manifest|invalid|changed/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/manifest|invalid|changed/i);
|
||||||
writeFileSync(first.manifestPath, original, { mode: 0o600 });
|
writeFileSync(first.manifestPath, original, { mode: 0o600 });
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).not.toThrow();
|
await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined();
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,40 +420,40 @@ test.each([
|
|||||||
["duplicate", "alpha.example,alpha.example"],
|
["duplicate", "alpha.example,alpha.example"],
|
||||||
["uppercase", "Alpha.example"],
|
["uppercase", "Alpha.example"],
|
||||||
["ip address", "127.0.0.1"],
|
["ip address", "127.0.0.1"],
|
||||||
] as const)("rejects %s private-host policy", (_label, allowlist) => {
|
] as const)("rejects %s private-host policy", async (_label, allowlist) => {
|
||||||
expect(() => fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: allowlist }))
|
expect(() => fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: allowlist }))
|
||||||
.toThrow(/allowlist|hostname|duplicate|invalid/i);
|
.toThrow(/allowlist|hostname|duplicate|invalid/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["runtime-config", "config"], ["runtime-config-manifests", "manifest"],
|
["runtime-config", "config"], ["runtime-config-manifests", "manifest"],
|
||||||
] as const)("rejects a replaced %s destination ancestor", (directory, _kind) => {
|
] as const)("rejects a replaced %s destination ancestor", async (directory, _kind) => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const lease = f.factory.acquireSession(f.snapshotPath);
|
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const parent = join(f.root, "data", "sessions", workspace, "preprocessing", directory);
|
const parent = join(f.root, "data", "sessions", workspace, "preprocessing", directory);
|
||||||
const moved = `${parent}.moved`;
|
const moved = `${parent}.moved`;
|
||||||
renameSync(parent, moved);
|
renameSync(parent, moved);
|
||||||
execFileSync("ln", ["-s", moved, parent]);
|
execFileSync("ln", ["-s", moved, parent]);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|symbolic|changed|directory/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|symbolic|changed|directory/i);
|
||||||
rmSync(parent);
|
rmSync(parent);
|
||||||
renameSync(moved, parent);
|
renameSync(moved, parent);
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).not.toThrow();
|
await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined();
|
||||||
lease.release();
|
lease.release();
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("release is idempotent and does not remove either durable publication", () => {
|
test("release is idempotent and does not remove either durable publication", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const lease = f.factory.acquireSession(f.snapshotPath);
|
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||||
lease.release(); lease.release();
|
lease.release(); lease.release();
|
||||||
expect(existsSync(lease.path)).toBe(true);
|
expect(existsSync(lease.path)).toBe(true);
|
||||||
expect(existsSync(lease.manifestPath)).toBe(true);
|
expect(existsSync(lease.manifestPath)).toBe(true);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("partial os.write calls are completed by the real Python publication helper", () => {
|
test("partial os.write calls are completed by the real Python publication helper", async () => {
|
||||||
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
||||||
const helper = join(process.cwd(), "..", "harness", "tht", "runtime_config_lease_io.py");
|
const helper = join(process.cwd(), "..", "harness", "tht", "runtime_config_lease_io.py");
|
||||||
const code = `import os, sys; sys.path.insert(0, ${JSON.stringify(dirname(helper))}); import runtime_config_lease_io as m; real=os.write; os.write=lambda fd,b: real(fd,b[:3]); m.write_all(1, b'partial-write-ok\\n')`;
|
const code = `import os, sys; sys.path.insert(0, ${JSON.stringify(dirname(helper))}); import runtime_config_lease_io as m; real=os.write; os.write=lambda fd,b: real(fd,b[:3]); m.write_all(1, b'partial-write-ok\\n')`;
|
||||||
@@ -461,20 +461,20 @@ test("partial os.write calls are completed by the real Python publication helper
|
|||||||
expect(output).toBe("partial-write-ok\n");
|
expect(output).toBe("partial-write-ok\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a clean existing equal publication is reconciled by a new factory", () => {
|
test("a clean existing equal publication is reconciled by a new factory", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const first = f.factory.acquireSession(f.snapshotPath);
|
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const second = new WorkspaceRuntimeConfigLeaseFactory(f.factoryInput).acquireMaintenance({ snapshotPath: f.snapshotPath });
|
const second = await new WorkspaceRuntimeConfigLeaseFactory(f.factoryInput).acquireMaintenance({ snapshotPath: f.snapshotPath });
|
||||||
expect(readFileSync(second.path)).toEqual(readFileSync(first.path));
|
expect(readFileSync(second.path)).toEqual(readFileSync(first.path));
|
||||||
expect(readFileSync(second.manifestPath)).toEqual(readFileSync(first.manifestPath));
|
expect(readFileSync(second.manifestPath)).toEqual(readFileSync(first.manifestPath));
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([["config"], ["manifest"]] as const)("rename failure is scoped to the %s branch and leaves no staging", (kind) => {
|
test.each([["config"], ["manifest"]] as const)("rename failure is scoped to the %s branch and leaves no staging", async (kind) => {
|
||||||
const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: kind });
|
const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: kind });
|
||||||
try {
|
try {
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/rename|failed/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/rename|failed/i);
|
||||||
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
||||||
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
||||||
if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).some((name) => name.includes("staging"))).toBe(false);
|
if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).some((name) => name.includes("staging"))).toBe(false);
|
||||||
@@ -482,7 +482,7 @@ test.each([["config"], ["manifest"]] as const)("rename failure is scoped to the
|
|||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("snapshot manifest rejects an undeclared extra immutable file", () => {
|
test("snapshot manifest rejects an undeclared extra immutable file", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const snapshot = JSON.parse(readFileSync(f.snapshotManifest, "utf8"));
|
const snapshot = JSON.parse(readFileSync(f.snapshotManifest, "utf8"));
|
||||||
@@ -490,15 +490,15 @@ test("snapshot manifest rejects an undeclared extra immutable file", () => {
|
|||||||
snapshot.files["smuggled.txt"] = createHash("sha256").update("smuggled").digest("hex");
|
snapshot.files["smuggled.txt"] = createHash("sha256").update("smuggled").digest("hex");
|
||||||
chmodSync(f.snapshotManifest, 0o600);
|
chmodSync(f.snapshotManifest, 0o600);
|
||||||
writeFileSync(f.snapshotManifest, JSON.stringify(snapshot), { mode: 0o400 });
|
writeFileSync(f.snapshotManifest, JSON.stringify(snapshot), { mode: 0o400 });
|
||||||
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/integrity|snapshot|trusted/i);
|
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/integrity|snapshot|trusted/i);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test("independent OS publishers converge when equal and elect one winner when unequal", () => {
|
test("independent OS publishers converge when equal and elect one winner when unequal", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const seed = f.factory.acquireSession(f.snapshotPath);
|
const seed = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const full = JSON.parse(readFileSync(seed.manifestPath, "utf8"));
|
const full = JSON.parse(readFileSync(seed.manifestPath, "utf8"));
|
||||||
const base = Object.fromEntries(["workspace_id", "workspace_revision", "descriptor_git_blob",
|
const base = Object.fromEntries(["workspace_id", "workspace_revision", "descriptor_git_blob",
|
||||||
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_dwh_binding"]
|
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_dwh_binding"]
|
||||||
@@ -509,7 +509,7 @@ test("independent OS publishers converge when equal and elect one winner when un
|
|||||||
// Leave the workspace-owned destination directories in place, but remove
|
// Leave the workspace-owned destination directories in place, but remove
|
||||||
// both durable leaves: the following OS processes race on a clean layout.
|
// both durable leaves: the following OS processes race on a clean layout.
|
||||||
rmSync(seed.path); rmSync(seed.manifestPath);
|
rmSync(seed.path); rmSync(seed.manifestPath);
|
||||||
const payload = JSON.stringify({ action: "publish", data_root: f.factoryInput.dataRoot,
|
const payload = JSON.stringify({ protocol_version: 1, action: "publish", data_root: f.factoryInput.dataRoot,
|
||||||
workspace_id: workspace, workspace_revision: full.workspace_revision,
|
workspace_id: workspace, workspace_revision: full.workspace_revision,
|
||||||
config_hex: Buffer.from(configBytes).toString("hex"), manifest_base: base });
|
config_hex: Buffer.from(configBytes).toString("hex"), manifest_base: base });
|
||||||
const code = `import json,multiprocessing,sys
|
const code = `import json,multiprocessing,sys
|
||||||
@@ -538,10 +538,10 @@ print(json.dumps([q.exitcode for q in p]))`
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s swap", (kind) => {
|
test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s swap", async (kind) => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const lease = f.factory.acquireSession(f.snapshotPath);
|
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const harnessBin = join(process.cwd(), "..", "harness", ".venv", "bin", "tht");
|
const harnessBin = join(process.cwd(), "..", "harness", ".venv", "bin", "tht");
|
||||||
const harnessCwd = join(process.cwd(), "..", "harness");
|
const harnessCwd = join(process.cwd(), "..", "harness");
|
||||||
const env = { ...process.env, THT_RUNTIME_CONFIG_MANIFEST_SHA256: lease.manifestSha256 };
|
const env = { ...process.env, THT_RUNTIME_CONFIG_MANIFEST_SHA256: lease.manifestSha256 };
|
||||||
@@ -562,10 +562,10 @@ test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s sw
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test("actual harness rejects a workspace chain swap between config and manifest traversal", () => {
|
test("actual harness rejects a workspace chain swap between config and manifest traversal", async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const lease = f.factory.acquireSession(f.snapshotPath);
|
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||||
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
||||||
const helper = join(process.cwd(), "..", "harness", "tht");
|
const helper = join(process.cwd(), "..", "harness", "tht");
|
||||||
const workspaceRoot = join(f.factoryInput.dataRoot, "sessions", workspace);
|
const workspaceRoot = join(f.factoryInput.dataRoot, "sessions", workspace);
|
||||||
|
|||||||
@@ -169,8 +169,8 @@ test("real schema-v3 registry revision loads through ThtRunner and the harness c
|
|||||||
test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => {
|
test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const runner = runnerFor(f);
|
const runner = runnerFor(f);
|
||||||
const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
const first = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||||
const second = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
const second = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||||
const expectedRoot = join(
|
const expectedRoot = join(
|
||||||
f.registryConfig.root,
|
f.registryConfig.root,
|
||||||
"snapshots",
|
"snapshots",
|
||||||
@@ -226,7 +226,7 @@ test("separate runtime leases hand off byte-identical revision Evidence configs
|
|||||||
test("real Evidence-content-only commit changes runtime identity and root with identical descriptor YAML", async () => {
|
test("real Evidence-content-only commit changes runtime identity and root with identical descriptor YAML", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const runner = runnerFor(f);
|
const runner = runnerFor(f);
|
||||||
const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
const first = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||||
const descriptorBefore = readFileSync(f.revision.snapshotPath, "utf8");
|
const descriptorBefore = readFileSync(f.revision.snapshotPath, "utf8");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(f.source, "workspace-content", "psd-clinical", "evidence", "guide.md"),
|
join(f.source, "workspace-content", "psd-clinical", "evidence", "guide.md"),
|
||||||
@@ -237,7 +237,7 @@ test("real Evidence-content-only commit changes runtime identity and root with i
|
|||||||
await git(f.source, ["push", "origin", "main"]);
|
await git(f.source, ["push", "origin", "main"]);
|
||||||
await f.registry.pull();
|
await f.registry.pull();
|
||||||
const current = (await f.registry.list())[0];
|
const current = (await f.registry.list())[0];
|
||||||
const second = runner.acquireWorkspaceRuntime(current.snapshotPath);
|
const second = await runner.acquireWorkspaceRuntime(current.snapshotPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
expect(current.commit).not.toBe(f.revision.commit);
|
expect(current.commit).not.toBe(f.revision.commit);
|
||||||
@@ -281,7 +281,7 @@ test("signed HTTP Evidence resolves its file binding and config check never capt
|
|||||||
max_cache_bytes: 67890
|
max_cache_bytes: 67890
|
||||||
`));
|
`));
|
||||||
const runner = runnerFor(f);
|
const runner = runnerFor(f);
|
||||||
const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
const lease = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||||
try {
|
try {
|
||||||
const yaml = readFileSync(lease.path, "utf8");
|
const yaml = readFileSync(lease.path, "utf8");
|
||||||
expect(parse(yaml).evidence.sources).toEqual([{
|
expect(parse(yaml).evidence.sources).toEqual([{
|
||||||
@@ -325,7 +325,7 @@ test("static S3 Evidence resolves only configured secret-root file paths", async
|
|||||||
retain_published_generations: 7
|
retain_published_generations: 7
|
||||||
`));
|
`));
|
||||||
const runner = runnerFor(f);
|
const runner = runnerFor(f);
|
||||||
const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
const lease = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||||
try {
|
try {
|
||||||
const yaml = readFileSync(lease.path, "utf8");
|
const yaml = readFileSync(lease.path, "utf8");
|
||||||
expect(parse(yaml).evidence.sources).toEqual([{
|
expect(parse(yaml).evidence.sources).toEqual([{
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Focused unit coverage for the privileged runtime publication seam."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tht import runtime_config_lease_io as lease_io
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest() -> dict:
|
||||||
|
return {
|
||||||
|
"version": 1, "workspace_id": "abc", "workspace_revision": "a" * 40,
|
||||||
|
"descriptor_git_blob": "b" * 40, "descriptor_sha256": "c" * 64,
|
||||||
|
"descriptor_dev": "1", "descriptor_ino": "2", "config_sha256": "d" * 64,
|
||||||
|
"config_dwh_binding": {
|
||||||
|
"workspace_id": "abc", "config_fingerprint": "e", "input_fingerprint": "f"
|
||||||
|
},
|
||||||
|
"config_dev": "1", "config_ino": "3", "config_size": "4",
|
||||||
|
"config_mode": "400", "config_uid": str(os.getuid()), "config_nlink": "1",
|
||||||
|
"directory_identities": [{"path": "/", "dev": "1", "ino": "1", "mode": "755", "uid": "0"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_manifest_rejects_unknown_or_missing_fields():
|
||||||
|
value = _manifest()
|
||||||
|
assert lease_io.strict_manifest(value) is value
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
lease_io.strict_manifest({**value, "unexpected": True})
|
||||||
|
missing = dict(value)
|
||||||
|
del missing["config_sha256"]
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
lease_io.strict_manifest(missing)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_alias_is_darwin_only(monkeypatch):
|
||||||
|
monkeypatch.setattr(lease_io.sys, "platform", "linux")
|
||||||
|
assert lease_io._canonical_root("/tmp/runtime") == "/tmp/runtime"
|
||||||
|
assert lease_io._canonical_root("/var/lib/runtime") == "/var/lib/runtime"
|
||||||
|
monkeypatch.setattr(lease_io.sys, "platform", "darwin")
|
||||||
|
assert lease_io._canonical_root("/tmp/runtime") == "/private/tmp/runtime"
|
||||||
|
assert lease_io._canonical_root("/var/lib/runtime") == "/private/var/lib/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_all_enforces_bound(tmp_path):
|
||||||
|
path = tmp_path / "large"
|
||||||
|
path.write_bytes(b"0123456789")
|
||||||
|
fd = os.open(path, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
with pytest.raises(RuntimeError, match="too large"):
|
||||||
|
lease_io.read_all(fd, limit=4)
|
||||||
|
with pytest.raises(RuntimeError, match="too large"):
|
||||||
|
lease_io.read_all(fd, limit=0)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure_stage", ["config-parent", "manifest-parent"])
|
||||||
|
def test_retry_reasserts_parent_durability_before_success(tmp_path, monkeypatch, failure_stage):
|
||||||
|
events: list[str] = []
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
def fsync(fd: int, stage: str) -> None:
|
||||||
|
nonlocal failed
|
||||||
|
events.append(stage)
|
||||||
|
if stage == failure_stage and not failed:
|
||||||
|
failed = True
|
||||||
|
raise RuntimeError("injected fsync failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(lease_io, "publication_fsync", fsync)
|
||||||
|
inp = {
|
||||||
|
"data_root": str(tmp_path / "data"), "workspace_id": "abc",
|
||||||
|
"workspace_revision": "a" * 40, "config_hex": b"config".hex(),
|
||||||
|
"manifest_base": {
|
||||||
|
"workspace_id": "abc", "workspace_revision": "a" * 40,
|
||||||
|
"descriptor_git_blob": "b" * 40, "descriptor_sha256": "c" * 64,
|
||||||
|
"descriptor_dev": "1", "descriptor_ino": "2",
|
||||||
|
"config_dwh_binding": {"workspace_id": "abc", "config_fingerprint": "e", "input_fingerprint": "f"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with pytest.raises(RuntimeError, match="injected"):
|
||||||
|
lease_io.publish(inp)
|
||||||
|
events.clear()
|
||||||
|
lease_io.publish(inp)
|
||||||
|
assert events.index("config-parent") < events.index("manifest-parent")
|
||||||
@@ -638,10 +638,11 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]:
|
|||||||
|
|
||||||
def _canonical_runtime_path(path: Path) -> Path:
|
def _canonical_runtime_path(path: Path) -> Path:
|
||||||
value = str(path)
|
value = str(path)
|
||||||
if value == "/tmp" or value.startswith("/tmp/"):
|
if sys.platform == "darwin":
|
||||||
return Path("/private" + value)
|
if value == "/tmp" or value.startswith("/tmp/"):
|
||||||
if value == "/var" or value.startswith("/var/"):
|
return Path("/private" + value)
|
||||||
return Path("/private" + value)
|
if value == "/var" or value.startswith("/var/"):
|
||||||
|
return Path("/private" + value)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import stat
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -20,12 +21,13 @@ def fail(msg: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _canonical_root(root: str) -> str:
|
def _canonical_root(root: str) -> str:
|
||||||
# Darwin exposes /tmp and /var as symlink aliases. The trusted path boundary
|
# Darwin exposes /tmp and /var as symlink aliases. Linux does not: rewriting
|
||||||
# records the real OS-owned prefix so a manifest never contains a symlink.
|
# these paths there would redirect valid installations to a different root.
|
||||||
if root == "/tmp" or root.startswith("/tmp/"):
|
if sys.platform == "darwin":
|
||||||
return "/private" + root
|
if root == "/tmp" or root.startswith("/tmp/"):
|
||||||
if root == "/var" or root.startswith("/var/"):
|
return "/private" + root
|
||||||
return "/private" + root
|
if root == "/var" or root.startswith("/var/"):
|
||||||
|
return "/private" + root
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +182,7 @@ def walk(root: str, comps: list[str], create: bool = True) -> int:
|
|||||||
# macOS exposes temporary directories through the conventional /var and
|
# macOS exposes temporary directories through the conventional /var and
|
||||||
# /tmp symlinks. Resolve only these OS-owned aliases; workspace-owned
|
# /tmp symlinks. Resolve only these OS-owned aliases; workspace-owned
|
||||||
# ancestors remain component checked and are never realpath-followed.
|
# ancestors remain component checked and are never realpath-followed.
|
||||||
if root == "/var" or root == "/tmp" or root.startswith(("/var/", "/tmp/")):
|
if sys.platform == "darwin" and (root == "/var" or root == "/tmp" or root.startswith(("/var/", "/tmp/"))):
|
||||||
root = "/private" + root
|
root = "/private" + root
|
||||||
parts = [part for part in Path(root).parts if part not in ("", "/")]
|
parts = [part for part in Path(root).parts if part not in ("", "/")]
|
||||||
if any(part in (".", "..") or "/" in part for part in parts + comps):
|
if any(part in (".", "..") or "/" in part for part in parts + comps):
|
||||||
@@ -249,7 +251,13 @@ def publication_fsync(fd: int, stage: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def read_all(fd: int, limit: int = 16 * 1024 * 1024) -> bytes:
|
def read_all(fd: int, limit: int = 16 * 1024 * 1024) -> bytes:
|
||||||
|
if limit < 0:
|
||||||
|
fail("runtime config file is too large")
|
||||||
os.lseek(fd, 0, os.SEEK_SET)
|
os.lseek(fd, 0, os.SEEK_SET)
|
||||||
|
if limit == 0:
|
||||||
|
if os.read(fd, 1):
|
||||||
|
fail("runtime config file is too large")
|
||||||
|
return b""
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
total = 0
|
total = 0
|
||||||
while True:
|
while True:
|
||||||
@@ -258,8 +266,12 @@ def read_all(fd: int, limit: int = 16 * 1024 * 1024) -> bytes:
|
|||||||
return b"".join(chunks)
|
return b"".join(chunks)
|
||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
total += len(chunk)
|
total += len(chunk)
|
||||||
if total > limit:
|
if total >= limit:
|
||||||
fail("runtime config file is too large")
|
# The bounded read above cannot observe an additional byte when it
|
||||||
|
# lands exactly on the ceiling; probe once before accepting it.
|
||||||
|
if os.read(fd, 1):
|
||||||
|
fail("runtime config file is too large")
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
def strict_manifest(value: object) -> dict:
|
def strict_manifest(value: object) -> dict:
|
||||||
@@ -341,7 +353,17 @@ def publish(inp: dict) -> dict:
|
|||||||
])
|
])
|
||||||
# The retained preprocessing directory is the single cross-process lock seam.
|
# The retained preprocessing directory is the single cross-process lock seam.
|
||||||
# No pathname lock file is created in the workspace layout.
|
# No pathname lock file is created in the workspace layout.
|
||||||
fcntl.flock(prep, fcntl.LOCK_EX)
|
deadline = time.monotonic() + 2.0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
fcntl.flock(prep, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
break
|
||||||
|
except BlockingIOError:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
# Never let a wedged publisher block its caller indefinitely. The
|
||||||
|
# Node boundary turns this stable conflict into a bounded failure.
|
||||||
|
fail("runtime config publication is busy")
|
||||||
|
time.sleep(0.01)
|
||||||
try:
|
try:
|
||||||
name = f"{rev}.yaml"
|
name = f"{rev}.yaml"
|
||||||
mname = f"{rev}.json"
|
mname = f"{rev}.json"
|
||||||
@@ -397,6 +419,10 @@ def publish(inp: dict) -> dict:
|
|||||||
finally:
|
finally:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
publication_fsync(cfgdir, "config-parent")
|
publication_fsync(cfgdir, "config-parent")
|
||||||
|
# A prior invocation may have reported success after publishing the
|
||||||
|
# entry but before its parent fsync. Re-establish that durability
|
||||||
|
# boundary before making the manifest durable.
|
||||||
|
publication_fsync(cfgdir, "config-parent")
|
||||||
# Identity is deliberately recorded after final no-replace publication.
|
# Identity is deliberately recorded after final no-replace publication.
|
||||||
got = current(cfgdir, name, 0o400)
|
got = current(cfgdir, name, 0o400)
|
||||||
assert got
|
assert got
|
||||||
@@ -462,9 +488,12 @@ def publish(inp: dict) -> dict:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
publication_fsync(mandir, "manifest-parent")
|
publication_fsync(mandir, "manifest-parent")
|
||||||
|
# As with the config directory, retries must repair a boundary that
|
||||||
|
# failed after the no-replace publication on an earlier invocation.
|
||||||
|
publication_fsync(mandir, "manifest-parent")
|
||||||
return {
|
return {
|
||||||
"path": f"{root}/sessions/{wid}/preprocessing/runtime-config/{name}",
|
"path": f"{canonical}/sessions/{wid}/preprocessing/runtime-config/{name}",
|
||||||
"manifestPath": f"{root}/sessions/{wid}/preprocessing/runtime-config-manifests/{mname}",
|
"manifestPath": f"{canonical}/sessions/{wid}/preprocessing/runtime-config-manifests/{mname}",
|
||||||
"manifest": mb.decode(),
|
"manifest": mb.decode(),
|
||||||
"manifest_sha256": hashlib.sha256(mb).hexdigest(),
|
"manifest_sha256": hashlib.sha256(mb).hexdigest(),
|
||||||
"dev": s.st_dev,
|
"dev": s.st_dev,
|
||||||
@@ -570,6 +599,20 @@ def verified_snapshot(inp: dict) -> dict:
|
|||||||
git_env.update({"GIT_NO_REPLACE_OBJECTS": "1", "GIT_CONFIG_NOSYSTEM": "1",
|
git_env.update({"GIT_NO_REPLACE_OBJECTS": "1", "GIT_CONFIG_NOSYSTEM": "1",
|
||||||
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull})
|
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull})
|
||||||
try:
|
try:
|
||||||
|
# A 40-hex object name is not necessarily a commit (trees and blobs are
|
||||||
|
# valid Git objects and also accept the <object>:path syntax). Require
|
||||||
|
# the raw object itself to be a commit, with replacement/config controls
|
||||||
|
# disabled, before reading any descriptor bytes.
|
||||||
|
object_type = subprocess.check_output(
|
||||||
|
["git", "--no-replace-objects", "-C", repo, "cat-file", "-t", rev],
|
||||||
|
stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
|
||||||
|
).strip()
|
||||||
|
resolved_commit = subprocess.check_output(
|
||||||
|
["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}^{{commit}}"],
|
||||||
|
stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
|
||||||
|
).strip()
|
||||||
|
if object_type != "commit" or resolved_commit != rev:
|
||||||
|
fail("workspace Git revision is not an exact commit")
|
||||||
blob = subprocess.check_output(
|
blob = subprocess.check_output(
|
||||||
["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"],
|
["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"],
|
||||||
stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
|
stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
|
||||||
@@ -600,6 +643,11 @@ def verified_snapshot(inp: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def binding(inp: dict) -> dict:
|
def binding(inp: dict) -> dict:
|
||||||
|
# Runtime handoff variables are capabilities, never helper input. Remove
|
||||||
|
# inherited values before load_config can inspect its environment.
|
||||||
|
for key in tuple(os.environ):
|
||||||
|
if key.startswith(("THT_RUNTIME_CONFIG_", "THT_CONFIG_")):
|
||||||
|
os.environ.pop(key, None)
|
||||||
try:
|
try:
|
||||||
raw = bytes.fromhex(inp["config_hex"])
|
raw = bytes.fromhex(inp["config_hex"])
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -626,15 +674,22 @@ def binding(inp: dict) -> dict:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
try:
|
try:
|
||||||
inp = json.load(sys.stdin)
|
inp = json.load(sys.stdin)
|
||||||
|
if not isinstance(inp, dict) or inp.get("protocol_version") != 1:
|
||||||
|
fail("unsupported runtime config protocol")
|
||||||
action = inp.get("action")
|
action = inp.get("action")
|
||||||
|
request_keys = {
|
||||||
|
"publish": {"protocol_version", "action", "data_root", "workspace_id", "workspace_revision", "config_hex", "manifest_base"},
|
||||||
|
"verified-snapshot": {"protocol_version", "action", "snapshots_root", "repository_root", "workspace_revision", "workspace_id"},
|
||||||
|
"binding": {"protocol_version", "action", "config_hex"},
|
||||||
|
}
|
||||||
|
if action not in request_keys or set(inp) != request_keys[action]:
|
||||||
|
fail("invalid runtime config request")
|
||||||
if action == "publish":
|
if action == "publish":
|
||||||
result = publish(inp)
|
result = publish(inp)
|
||||||
elif action == "verified-snapshot":
|
elif action == "verified-snapshot":
|
||||||
result = verified_snapshot(inp)
|
result = verified_snapshot(inp)
|
||||||
elif action == "binding":
|
|
||||||
result = binding(inp)
|
|
||||||
else:
|
else:
|
||||||
fail("unsupported runtime config action")
|
result = binding(inp)
|
||||||
print(json.dumps(result))
|
print(json.dumps(result))
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(json.dumps({"error": str(e)}))
|
print(json.dumps({"error": str(e)}))
|
||||||
|
|||||||
Reference in New Issue
Block a user