fix: harden runtime config snapshot publication
This commit is contained in:
@@ -306,6 +306,12 @@ export class ThtRunner {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||||
delete env.THT_DATA_ROOT;
|
delete env.THT_DATA_ROOT;
|
||||||
|
// Handoff variables are backend-owned capabilities, never inherited from an
|
||||||
|
// operator shell or forwarded request environment.
|
||||||
|
delete env.THT_RUNTIME_CONFIG_MANIFEST_SHA256;
|
||||||
|
delete env.THT_CONFIG_FD;
|
||||||
|
delete env.THT_CONFIG_MANIFEST_FD;
|
||||||
|
delete env.THT_CONFIG_MANIFEST_SHA256;
|
||||||
clearPrincipalEnvironment(env);
|
clearPrincipalEnvironment(env);
|
||||||
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
|
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
|
||||||
if (this.principal) Object.assign(env, principalEnvironment(this.principal));
|
if (this.principal) Object.assign(env, principalEnvironment(this.principal));
|
||||||
@@ -321,41 +327,27 @@ export class ThtRunner {
|
|||||||
env.THT_SSL_CA = ca;
|
env.THT_SSL_CA = ca;
|
||||||
}
|
}
|
||||||
let snapshotFd: number | undefined;
|
let snapshotFd: number | undefined;
|
||||||
let canonicalFd: number | undefined;
|
|
||||||
let manifestFd: number | undefined;
|
|
||||||
let ch;
|
let ch;
|
||||||
try {
|
try {
|
||||||
snapshotFd = workspaceConfigPath && this.runtimeSnapshots.has(workspaceConfigPath)
|
snapshotFd = workspaceConfigPath && this.runtimeSnapshots.has(workspaceConfigPath)
|
||||||
? this.openTrustedRuntimeSnapshot(workspaceConfigPath) : undefined;
|
? this.openTrustedRuntimeSnapshot(workspaceConfigPath) : undefined;
|
||||||
const lease = workspaceConfigPath ? this.runtimeLeases.get(workspaceConfigPath) : undefined;
|
const lease = workspaceConfigPath ? this.runtimeLeases.get(workspaceConfigPath) : undefined;
|
||||||
// Registry leases retain the verified config bytes in fd 3 and the separately
|
// Runtime leases use the canonical path plus an authenticated manifest digest.
|
||||||
// published manifest in fd 4. The argv remains the canonical -c pathname for
|
// FD 3/4 remain reserved for the maintenance writer/root ABI.
|
||||||
// diagnostics/compatibility; the harness never trusts that pathname for bytes.
|
if (lease) env.THT_RUNTIME_CONFIG_MANIFEST_SHA256 = lease.manifestSha256;
|
||||||
canonicalFd = snapshotFd === undefined && lease
|
|
||||||
? openSync(lease.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) : undefined;
|
|
||||||
manifestFd = lease
|
|
||||||
? openSync(lease.manifestPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) : undefined;
|
|
||||||
if (lease) {
|
|
||||||
env.THT_CONFIG_FD = "3";
|
|
||||||
env.THT_CONFIG_MANIFEST_FD = "4";
|
|
||||||
env.THT_CONFIG_MANIFEST_SHA256 = lease.manifestSha256;
|
|
||||||
}
|
|
||||||
const handoffFd = snapshotFd ?? canonicalFd;
|
|
||||||
ch = spawn(
|
ch = spawn(
|
||||||
this.cfg.thtBin,
|
this.cfg.thtBin,
|
||||||
snapshotFd === undefined ? this.buildArgv(args, workspaceConfigPath) : [...args, "-c", "/dev/fd/3"],
|
snapshotFd === undefined ? this.buildArgv(args, workspaceConfigPath) : [...args, "-c", "/dev/fd/3"],
|
||||||
{
|
{
|
||||||
cwd: this.cfg.harnessDir,
|
cwd: this.cfg.harnessDir,
|
||||||
env,
|
env,
|
||||||
...(handoffFd === undefined ? {} : {
|
...(snapshotFd === undefined ? {} : {
|
||||||
stdio: ["ignore", "pipe", "pipe", handoffFd, ...(manifestFd === undefined ? [] : [manifestFd])],
|
stdio: ["ignore", "pipe", "pipe", snapshotFd],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (snapshotFd !== undefined) closeSync(snapshotFd);
|
if (snapshotFd !== undefined) closeSync(snapshotFd);
|
||||||
if (canonicalFd !== undefined) closeSync(canonicalFd);
|
|
||||||
if (manifestFd !== undefined) closeSync(manifestFd);
|
|
||||||
}
|
}
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type RuntimePaths,
|
type RuntimePaths,
|
||||||
type SemanticRuntimeConfig,
|
type SemanticRuntimeConfig,
|
||||||
} from "./runtime-renderer.js";
|
} from "./runtime-renderer.js";
|
||||||
import { parseWorkspaceYaml, validateOperationalWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
import { parseWorkspaceYaml, serializeWorkspaceYaml, validateOperationalWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
||||||
|
|
||||||
export interface RuntimeConfigLease {
|
export interface RuntimeConfigLease {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -50,6 +50,8 @@ interface SnapshotIdentity {
|
|||||||
revisionContentRoot: string;
|
revisionContentRoot: string;
|
||||||
digest: string;
|
digest: string;
|
||||||
descriptorBlob?: string;
|
descriptorBlob?: string;
|
||||||
|
descriptorDev: string;
|
||||||
|
descriptorIno: string;
|
||||||
}
|
}
|
||||||
interface PublishedIdentity {
|
interface PublishedIdentity {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -154,6 +156,8 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
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,
|
||||||
config_sha256: renderedDigest,
|
config_sha256: renderedDigest,
|
||||||
config_dwh_binding: this.computeBinding(rendered),
|
config_dwh_binding: this.computeBinding(rendered),
|
||||||
};
|
};
|
||||||
@@ -168,10 +172,14 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
|
|
||||||
private helper(action: string, extra: Record<string, unknown>): any {
|
private helper(action: string, extra: Record<string, unknown>): any {
|
||||||
const python = join(this.input.harnessDir, ".venv", "bin", "python");
|
const python = join(this.input.harnessDir, ".venv", "bin", "python");
|
||||||
const executable = existsSync(python) ? python : (process.env.PYTHON ?? "python3");
|
|
||||||
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 executable = existsSync(python) ? python
|
||||||
|
: 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 result = spawnSync(executable, helperArgs, { cwd: this.input.harnessDir,
|
||||||
input: JSON.stringify({ action, ...extra }), encoding: "utf8",
|
input: JSON.stringify({ action, ...extra }), encoding: "utf8",
|
||||||
@@ -188,12 +196,10 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
try {
|
try {
|
||||||
const value = 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.workspace_id === "string" && typeof value.config_fingerprint === "string" && typeof value.input_fingerprint === "string") return value;
|
if (value && typeof value.workspace_id === "string" && typeof value.config_fingerprint === "string" && typeof value.input_fingerprint === "string") return value;
|
||||||
|
throw new Error("runtime config binding helper returned malformed output");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Development fixtures may intentionally omit the harness virtualenv. Production
|
throw error instanceof Error ? error : new Error("runtime config binding failed");
|
||||||
// deployments always execute the real helper through harness/.venv/bin/python.
|
|
||||||
if (existsSync(join(this.input.harnessDir, ".venv", "bin", "python"))) throw error;
|
|
||||||
}
|
}
|
||||||
return { workspace_id: "unknown", config_fingerprint: `sha256:${digest(content)}`, input_fingerprint: `sha256:${digest(content)}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record<string, unknown>): {path:string; manifestPath:string; manifest:string; manifest_sha256:string} {
|
private publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record<string, unknown>): {path:string; manifestPath:string; manifest:string; manifest_sha256:string} {
|
||||||
@@ -231,11 +237,25 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
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],
|
||||||
});
|
});
|
||||||
if (!verified || typeof verified.source !== "string"
|
if (!verified || 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))) {
|
||||||
throw new Error("workspace snapshot integrity check failed");
|
throw new Error("workspace snapshot integrity check failed");
|
||||||
}
|
}
|
||||||
const workspace = validateOperationalWorkspace(parseWorkspaceYaml(verified.source));
|
// The registry's production canonicalizer is the sole descriptor equivalence
|
||||||
|
// rule. Raw token containment is not identity: it permits changed values.
|
||||||
|
let workspace: WorkspaceDescriptor;
|
||||||
|
let gitWorkspace: WorkspaceDescriptor;
|
||||||
|
try {
|
||||||
|
workspace = validateOperationalWorkspace(parseWorkspaceYaml(verified.source));
|
||||||
|
gitWorkspace = validateOperationalWorkspace(parseWorkspaceYaml(verified.git_source));
|
||||||
|
if (serializeWorkspaceYaml(workspace) !== serializeWorkspaceYaml(gitWorkspace)
|
||||||
|
|| serializeWorkspaceYaml(workspace) !== verified.source) {
|
||||||
|
throw new Error("canonical descriptor differs from Git");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`workspace snapshot integrity check failed: ${error instanceof Error ? error.message : "invalid descriptor"}`);
|
||||||
|
}
|
||||||
if (workspace.workspace.id !== match[2] || typeof verified.descriptor_git_blob !== "string") {
|
if (workspace.workspace.id !== match[2] || typeof verified.descriptor_git_blob !== "string") {
|
||||||
throw new Error("workspace snapshot integrity check failed");
|
throw new Error("workspace snapshot integrity check failed");
|
||||||
}
|
}
|
||||||
@@ -243,6 +263,7 @@ export class WorkspaceRuntimeConfigLeaseFactory {
|
|||||||
workspace, workspaceId: match[2], workspaceRevision: match[1],
|
workspace, workspaceId: match[2], workspaceRevision: match[1],
|
||||||
revisionContentRoot: join(root, match[1]), digest: verified.sha256,
|
revisionContentRoot: join(root, match[1]), digest: verified.sha256,
|
||||||
descriptorBlob: verified.descriptor_git_blob,
|
descriptorBlob: verified.descriptor_git_blob,
|
||||||
|
descriptorDev: String(verified.descriptor_dev), descriptorIno: String(verified.descriptor_ino),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { test, expect } from "vitest";
|
import { test, expect } from "vitest";
|
||||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { WorkspaceRuntimeConfigLeaseFactory } from "../src/workspaces/runtime-config-lease.js";
|
import { WorkspaceRuntimeConfigLeaseFactory } from "../src/workspaces/runtime-config-lease.js";
|
||||||
|
import { parseWorkspaceYaml, serializeWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||||
|
|
||||||
const workspace = "abc";
|
const workspace = "abc";
|
||||||
const descriptor = `workspace:
|
const descriptor = `workspace:
|
||||||
@@ -33,13 +34,14 @@ llm_policy:
|
|||||||
|
|
||||||
function fixture() {
|
function fixture() {
|
||||||
const root = mkdtempSync(join(tmpdir(), "runtime-config-lease-"));
|
const root = mkdtempSync(join(tmpdir(), "runtime-config-lease-"));
|
||||||
|
const canonicalDescriptor = serializeWorkspaceYaml(parseWorkspaceYaml(descriptor));
|
||||||
const snapshots = join(root, "snapshots");
|
const snapshots = join(root, "snapshots");
|
||||||
const repo = join(root, "repo");
|
const repo = join(root, "repo");
|
||||||
mkdirSync(join(repo, "workspaces"), { recursive: true });
|
mkdirSync(join(repo, "workspaces"), { recursive: true });
|
||||||
execFileSync("git", ["init", "--initial-branch=main"], { cwd: repo });
|
execFileSync("git", ["init", "--initial-branch=main"], { cwd: repo });
|
||||||
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
|
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
|
||||||
execFileSync("git", ["config", "user.email", "fixture@example.invalid"], { cwd: repo });
|
execFileSync("git", ["config", "user.email", "fixture@example.invalid"], { cwd: repo });
|
||||||
writeFileSync(join(repo, "workspaces", `${workspace}.yaml`), descriptor);
|
writeFileSync(join(repo, "workspaces", `${workspace}.yaml`), canonicalDescriptor);
|
||||||
execFileSync("git", ["add", "."], { cwd: repo });
|
execFileSync("git", ["add", "."], { cwd: repo });
|
||||||
execFileSync("git", ["commit", "-m", "fixture"], { cwd: repo });
|
execFileSync("git", ["commit", "-m", "fixture"], { cwd: repo });
|
||||||
const actualCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8" }).trim();
|
const actualCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8" }).trim();
|
||||||
@@ -50,17 +52,22 @@ function fixture() {
|
|||||||
const harness = join(root, "harness");
|
const harness = join(root, "harness");
|
||||||
mkdirSync(snapshotsDir, { recursive: true, mode: 0o700 });
|
mkdirSync(snapshotsDir, { recursive: true, mode: 0o700 });
|
||||||
chmodSync(snapshots, 0o700);
|
chmodSync(snapshots, 0o700);
|
||||||
|
const publicFiles = {
|
||||||
|
[`${workspace}.yaml`]: canonicalDescriptor,
|
||||||
|
[`${workspace}.env.example`]: "# fixture\n",
|
||||||
|
[`${workspace}.md`]: "# Lease\n",
|
||||||
|
};
|
||||||
|
for (const [name, contents] of Object.entries(publicFiles)) writeFileSync(join(snapshotsDir, name), contents, { mode: 0o400 });
|
||||||
writeFileSync(join(snapshotsDir, "snapshot.json"), JSON.stringify({
|
writeFileSync(join(snapshotsDir, "snapshot.json"), JSON.stringify({
|
||||||
head: actualCommit,
|
head: actualCommit,
|
||||||
revisions: [{ id: workspace, commit: actualCommit, blob, snapshotPath }],
|
revisions: [{ id: workspace, commit: actualCommit, blob, snapshotPath }],
|
||||||
files: { [`${workspace}.yaml`]: createHash("sha256").update(descriptor).digest("hex") },
|
files: Object.fromEntries(Object.entries(publicFiles).map(([name, contents]) => [name, createHash("sha256").update(contents).digest("hex")])),
|
||||||
}), { mode: 0o400 });
|
}), { mode: 0o400 });
|
||||||
mkdirSync(harness);
|
mkdirSync(harness);
|
||||||
writeFileSync(snapshotPath, descriptor, { mode: 0o400 });
|
|
||||||
const secret = join(root, "password");
|
const secret = join(root, "password");
|
||||||
writeFileSync(secret, "secret", { mode: 0o600 });
|
writeFileSync(secret, "secret", { mode: 0o600 });
|
||||||
const configPath = join(harness, "config.yaml");
|
const configPath = join(harness, "config.yaml");
|
||||||
writeFileSync(configPath, "profile: test\n");
|
writeFileSync(configPath, "profile: workstation\n");
|
||||||
const factory = new WorkspaceRuntimeConfigLeaseFactory({
|
const factory = new WorkspaceRuntimeConfigLeaseFactory({
|
||||||
dataRoot, runtimeSnapshotRoot: snapshots, harnessDir: harness, configPath,
|
dataRoot, runtimeSnapshotRoot: snapshots, harnessDir: harness, configPath,
|
||||||
env: {
|
env: {
|
||||||
@@ -72,7 +79,7 @@ function fixture() {
|
|||||||
internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024,
|
internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { root, snapshotPath, factory };
|
return { root, snapshotPath, factory, canonicalDescriptor };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session and maintenance share deterministic bytes and path", () => {
|
test("session and maintenance share deterministic bytes and path", () => {
|
||||||
@@ -84,6 +91,9 @@ test("session and maintenance share deterministic bytes and 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);
|
||||||
expect(existsSync(maintenance.manifestPath)).toBe(true);
|
expect(existsSync(maintenance.manifestPath)).toBe(true);
|
||||||
|
const manifest = JSON.parse(readFileSync(maintenance.manifestPath, "utf8"));
|
||||||
|
expect(manifest).toMatchObject({ version: 1, descriptor_dev: expect.any(String), descriptor_ino: expect.any(String) });
|
||||||
|
expect(existsSync(join(dirname(dirname(maintenance.path)), "runtime-config.lock"))).toBe(false);
|
||||||
session.release(); maintenance.release();
|
session.release(); maintenance.release();
|
||||||
expect(existsSync(session.path)).toBe(true);
|
expect(existsSync(session.path)).toBe(true);
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
@@ -100,3 +110,34 @@ test("same revision changed bytes are refused", () => {
|
|||||||
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", () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const mutated = f.canonicalDescriptor.replace("database: analytics", "database: evil").replace("name: Lease", "name: Lease analytics");
|
||||||
|
chmodSync(f.snapshotPath, 0o600);
|
||||||
|
writeFileSync(f.snapshotPath, mutated, { mode: 0o600 });
|
||||||
|
chmodSync(f.snapshotPath, 0o400);
|
||||||
|
const manifestPath = join(dirname(f.snapshotPath), "snapshot.json");
|
||||||
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||||
|
manifest.files[`${workspace}.yaml`] = createHash("sha256").update(mutated).digest("hex");
|
||||||
|
chmodSync(manifestPath, 0o600);
|
||||||
|
writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 });
|
||||||
|
chmodSync(manifestPath, 0o400);
|
||||||
|
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/Git descriptor|integrity|identity/i);
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-byte replacement of the registry descriptor is refused", () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const first = f.factory.acquireSession(f.snapshotPath);
|
||||||
|
const replacement = `${f.snapshotPath}.replacement`;
|
||||||
|
writeFileSync(replacement, readFileSync(f.snapshotPath), { mode: 0o400 });
|
||||||
|
chmodSync(f.snapshotPath, 0o600);
|
||||||
|
rmSync(f.snapshotPath);
|
||||||
|
writeFileSync(f.snapshotPath, readFileSync(replacement), { mode: 0o400 });
|
||||||
|
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/identity|changed|mismatch|trusted/i);
|
||||||
|
first.release();
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|||||||
+92
-11
@@ -3,6 +3,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -596,8 +597,9 @@ def _read_runtime_fd(fd: int, label: str, expected_mode: int = 0o400) -> tuple[b
|
|||||||
def _strict_runtime_manifest(raw: object) -> dict[str, object]:
|
def _strict_runtime_manifest(raw: object) -> dict[str, object]:
|
||||||
required = {
|
required = {
|
||||||
"version", "workspace_id", "workspace_revision", "descriptor_git_blob",
|
"version", "workspace_id", "workspace_revision", "descriptor_git_blob",
|
||||||
"descriptor_sha256", "config_sha256", "config_dwh_binding", "config_dev",
|
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256",
|
||||||
"config_ino", "config_size", "config_mode", "config_uid", "config_nlink",
|
"config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode",
|
||||||
|
"config_uid", "config_nlink",
|
||||||
}
|
}
|
||||||
if not isinstance(raw, dict) or set(raw) != required or raw.get("version") != 1:
|
if not isinstance(raw, dict) or set(raw) != required or raw.get("version") != 1:
|
||||||
raise ConfigError("Manifest runtime non valido")
|
raise ConfigError("Manifest runtime non valido")
|
||||||
@@ -609,7 +611,7 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]:
|
|||||||
for key in ("descriptor_sha256", "config_sha256"):
|
for key in ("descriptor_sha256", "config_sha256"):
|
||||||
if not isinstance(raw[key], str) or not re.fullmatch(r"[0-9a-f]{64}", raw[key]):
|
if not isinstance(raw[key], str) or not re.fullmatch(r"[0-9a-f]{64}", raw[key]):
|
||||||
raise ConfigError("Manifest runtime non valido")
|
raise ConfigError("Manifest runtime non valido")
|
||||||
for key in ("config_dev", "config_ino", "config_size", "config_uid", "config_nlink"):
|
for key in ("descriptor_dev", "descriptor_ino", "config_dev", "config_ino", "config_size", "config_uid", "config_nlink"):
|
||||||
if not isinstance(raw[key], str) or not raw[key].isdigit():
|
if not isinstance(raw[key], str) or not raw[key].isdigit():
|
||||||
raise ConfigError("Manifest runtime non valido")
|
raise ConfigError("Manifest runtime non valido")
|
||||||
if raw["config_mode"] != "400":
|
if raw["config_mode"] != "400":
|
||||||
@@ -617,22 +619,100 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]:
|
|||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _open_runtime_component(parent: int, name: str) -> int:
|
||||||
|
before = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||||
|
if stat.S_ISLNK(before.st_mode):
|
||||||
|
raise OSError("runtime config path contains a symlink")
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW
|
||||||
|
fd = os.open(name, flags, dir_fd=parent)
|
||||||
|
after = os.fstat(fd)
|
||||||
|
if (before.st_dev != after.st_dev or before.st_ino != after.st_ino
|
||||||
|
or not stat.S_ISDIR(after.st_mode)):
|
||||||
|
os.close(fd)
|
||||||
|
raise OSError("runtime config path changed during open")
|
||||||
|
return fd
|
||||||
|
|
||||||
|
|
||||||
|
def _open_runtime_file(path: Path, expected_mode: int) -> int:
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise OSError("runtime config path must be absolute")
|
||||||
|
parts = list(path.parts)
|
||||||
|
if sys.platform == "darwin" and len(parts) > 1 and parts[1] in ("var", "tmp"):
|
||||||
|
parts = ["/", "private", *parts[1:]]
|
||||||
|
if not parts or parts[0] != "/" or any(part in ("", ".", "..") or "/" in part for part in parts[1:]):
|
||||||
|
raise OSError("runtime config path is invalid")
|
||||||
|
current = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||||
|
try:
|
||||||
|
for component in parts[1:-1]:
|
||||||
|
nxt = _open_runtime_component(current, component)
|
||||||
|
os.close(current)
|
||||||
|
current = nxt
|
||||||
|
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=current)
|
||||||
|
info = os.fstat(fd)
|
||||||
|
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1
|
||||||
|
or stat.S_IMODE(info.st_mode) != expected_mode or info.st_uid != os.getuid()):
|
||||||
|
os.close(fd)
|
||||||
|
raise OSError("unsafe runtime file")
|
||||||
|
return fd
|
||||||
|
finally:
|
||||||
|
os.close(current)
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_manifest_path(config_path: Path) -> Path:
|
||||||
|
if config_path.name == "" or config_path.suffix != ".yaml" or config_path.parent.name != "runtime-config":
|
||||||
|
raise OSError("runtime config path is not canonical")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{40}", config_path.stem):
|
||||||
|
raise OSError("runtime config path is not canonical")
|
||||||
|
return config_path.parent.parent / "runtime-config-manifests" / f"{config_path.stem}.json"
|
||||||
|
|
||||||
def load_config(path: Path) -> Config:
|
def load_config(path: Path) -> Config:
|
||||||
# Backend runtime leases pass the verified canonical config as fd 3 while retaining
|
# Registry leases authenticate the canonical pathname through a durable manifest
|
||||||
# the ordinary absolute -c argument for diagnostics and source identity. Never reopen
|
# digest. The config and manifest are opened component-by-component; FD 3/4 are
|
||||||
# that pathname: an ancestor or leaf replacement after spawn must not alter bytes used
|
# reserved for the maintenance writer/root ABI.
|
||||||
# by the harness.
|
|
||||||
runtime_manifest: dict[str, object] | None = None
|
runtime_manifest: dict[str, object] | None = None
|
||||||
|
expected_manifest = os.environ.get("THT_RUNTIME_CONFIG_MANIFEST_SHA256")
|
||||||
runtime_fd = os.environ.get("THT_CONFIG_FD")
|
runtime_fd = os.environ.get("THT_CONFIG_FD")
|
||||||
manifest_fd = os.environ.get("THT_CONFIG_MANIFEST_FD")
|
manifest_fd = os.environ.get("THT_CONFIG_MANIFEST_FD")
|
||||||
expected_manifest = os.environ.get("THT_CONFIG_MANIFEST_SHA256")
|
legacy_expected = os.environ.get("THT_CONFIG_MANIFEST_SHA256")
|
||||||
if runtime_fd is not None or manifest_fd is not None or expected_manifest is not None:
|
if expected_manifest is not None:
|
||||||
if runtime_fd is None or manifest_fd is None or expected_manifest is None or not re.fullmatch(r"[0-9a-f]{64}", expected_manifest):
|
if not re.fullmatch(r"[0-9a-f]{64}", expected_manifest):
|
||||||
|
raise ConfigError("Handoff runtime incompleto")
|
||||||
|
config_fd = manifest_fd_local = None
|
||||||
|
try:
|
||||||
|
config_fd = _open_runtime_file(path, 0o400)
|
||||||
|
manifest_fd_local = _open_runtime_file(_runtime_manifest_path(path), 0o600)
|
||||||
|
config_bytes, config_info = _read_runtime_fd(config_fd, "config")
|
||||||
|
manifest_bytes, manifest_info = _read_runtime_fd(manifest_fd_local, "manifest", 0o600)
|
||||||
|
if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
|
||||||
|
raise ConfigError("Manifest runtime modificato")
|
||||||
|
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
||||||
|
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
||||||
|
or int(runtime_manifest["config_dev"]) != config_info.st_dev
|
||||||
|
or int(runtime_manifest["config_ino"]) != config_info.st_ino
|
||||||
|
or int(runtime_manifest["config_size"]) != config_info.st_size
|
||||||
|
or int(runtime_manifest["config_uid"]) != config_info.st_uid
|
||||||
|
or int(runtime_manifest["config_nlink"]) != config_info.st_nlink
|
||||||
|
or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino):
|
||||||
|
raise ConfigError("Identità config runtime non valida")
|
||||||
|
source_text = config_bytes.decode("utf-8")
|
||||||
|
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
if isinstance(exc, ConfigError):
|
||||||
|
raise
|
||||||
|
raise ConfigError("File di configurazione runtime non attendibile") from exc
|
||||||
|
finally:
|
||||||
|
if config_fd is not None:
|
||||||
|
os.close(config_fd)
|
||||||
|
if manifest_fd_local is not None:
|
||||||
|
os.close(manifest_fd_local)
|
||||||
|
elif runtime_fd is not None or manifest_fd is not None or legacy_expected is not None:
|
||||||
|
# Compatibility for direct /dev/fd callers. New backend leases never use it.
|
||||||
|
if runtime_fd is None or manifest_fd is None or legacy_expected is None or not re.fullmatch(r"[0-9a-f]{64}", legacy_expected):
|
||||||
raise ConfigError("Handoff runtime incompleto")
|
raise ConfigError("Handoff runtime incompleto")
|
||||||
try:
|
try:
|
||||||
config_bytes, config_info = _read_runtime_fd(int(runtime_fd), "config")
|
config_bytes, config_info = _read_runtime_fd(int(runtime_fd), "config")
|
||||||
manifest_bytes, manifest_info = _read_runtime_fd(int(manifest_fd), "manifest", 0o600)
|
manifest_bytes, manifest_info = _read_runtime_fd(int(manifest_fd), "manifest", 0o600)
|
||||||
if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
|
if hashlib.sha256(manifest_bytes).hexdigest() != legacy_expected:
|
||||||
raise ConfigError("Manifest runtime modificato")
|
raise ConfigError("Manifest runtime modificato")
|
||||||
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
||||||
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
||||||
@@ -655,6 +735,7 @@ def load_config(path: Path) -> Config:
|
|||||||
source_text = path.read_text()
|
source_text = path.read_text()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise ConfigError(f"File di configurazione non trovato: {path}") from exc
|
raise ConfigError(f"File di configurazione non trovato: {path}") from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = yaml.safe_load(source_text)
|
raw = yaml.safe_load(source_text)
|
||||||
except yaml.YAMLError as exc:
|
except yaml.YAMLError as exc:
|
||||||
|
|||||||
@@ -26,22 +26,41 @@ def safe_rev(v: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def open_dir(parent: int | None, name: str, create: bool = False) -> int:
|
def open_dir(parent: int | None, name: str, create: bool = False) -> int:
|
||||||
# Darwin rejects O_NOFOLLOW|openat for directories (ELOOP); lstat the
|
"""Open one directory component without following a replaced entry.
|
||||||
# component before opening and verify the resulting descriptor below. Linux
|
|
||||||
# uses the stronger flag where available.
|
The pre-open lstat and post-open fstat identity check is required on Darwin,
|
||||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
where O_NOFOLLOW has historically been unavailable for directory openat.
|
||||||
if sys.platform != "darwin":
|
mkdir races are resolved by opening and validating the winner.
|
||||||
flags |= os.O_NOFOLLOW
|
"""
|
||||||
try:
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW
|
||||||
entry = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
while True:
|
||||||
if stat.S_ISLNK(entry.st_mode):
|
try:
|
||||||
fail("runtime config directory is not trusted")
|
entry = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||||
return os.open(name, flags, dir_fd=parent)
|
if stat.S_ISLNK(entry.st_mode):
|
||||||
except FileNotFoundError:
|
fail("runtime config directory is not trusted")
|
||||||
if not create:
|
fd = os.open(name, flags, dir_fd=parent)
|
||||||
raise
|
try:
|
||||||
os.mkdir(name, 0o700, dir_fd=parent)
|
opened = os.fstat(fd)
|
||||||
return os.open(name, flags, dir_fd=parent)
|
if (opened.st_dev != entry.st_dev or opened.st_ino != entry.st_ino
|
||||||
|
or not stat.S_ISDIR(opened.st_mode)):
|
||||||
|
fail("runtime config directory changed during open")
|
||||||
|
return fd
|
||||||
|
except BaseException:
|
||||||
|
os.close(fd)
|
||||||
|
raise
|
||||||
|
except FileNotFoundError:
|
||||||
|
if not create:
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
os.mkdir(name, 0o700, dir_fd=parent)
|
||||||
|
except FileExistsError:
|
||||||
|
# Another publisher won creation. Re-enter the identity-checked
|
||||||
|
# open path instead of exposing EEXIST to the caller.
|
||||||
|
continue
|
||||||
|
if parent is not None:
|
||||||
|
os.fsync(parent)
|
||||||
|
# Re-open through the same no-follow and identity checks.
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
def checked_dir(fd: int, expected_mode: int = 0o700) -> None:
|
def checked_dir(fd: int, expected_mode: int = 0o700) -> None:
|
||||||
@@ -141,8 +160,9 @@ def strict_manifest(value: object) -> dict:
|
|||||||
fail("runtime config manifest is invalid")
|
fail("runtime config manifest is invalid")
|
||||||
required = {
|
required = {
|
||||||
"version", "workspace_id", "workspace_revision", "descriptor_git_blob",
|
"version", "workspace_id", "workspace_revision", "descriptor_git_blob",
|
||||||
"descriptor_sha256", "config_sha256", "config_dwh_binding", "config_dev",
|
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256",
|
||||||
"config_ino", "config_size", "config_mode", "config_uid", "config_nlink",
|
"config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode",
|
||||||
|
"config_uid", "config_nlink",
|
||||||
}
|
}
|
||||||
if set(value) != required or value.get("version") != 1:
|
if set(value) != required or value.get("version") != 1:
|
||||||
fail("runtime config manifest is invalid")
|
fail("runtime config manifest is invalid")
|
||||||
@@ -156,7 +176,7 @@ def strict_manifest(value: object) -> dict:
|
|||||||
binding_value = value.get("config_dwh_binding")
|
binding_value = value.get("config_dwh_binding")
|
||||||
if not isinstance(binding_value, dict) or set(binding_value) != {"workspace_id", "config_fingerprint", "input_fingerprint"} or any(not isinstance(x, str) for x in binding_value.values()):
|
if not isinstance(binding_value, dict) or set(binding_value) != {"workspace_id", "config_fingerprint", "input_fingerprint"} or any(not isinstance(x, str) for x in binding_value.values()):
|
||||||
fail("runtime config manifest is invalid")
|
fail("runtime config manifest is invalid")
|
||||||
for key in ("config_dev", "config_ino", "config_size", "config_uid", "config_nlink"):
|
for key in ("descriptor_dev", "descriptor_ino", "config_dev", "config_ino", "config_size", "config_uid", "config_nlink"):
|
||||||
if not isinstance(value[key], str) or not value[key].isdigit():
|
if not isinstance(value[key], str) or not value[key].isdigit():
|
||||||
fail("runtime config manifest is invalid")
|
fail("runtime config manifest is invalid")
|
||||||
if value["config_mode"] != "400":
|
if value["config_mode"] != "400":
|
||||||
@@ -193,19 +213,10 @@ def publish(inp: dict) -> dict:
|
|||||||
checked_dir(cfgdir)
|
checked_dir(cfgdir)
|
||||||
mandir = open_dir(prep, "runtime-config-manifests", True)
|
mandir = open_dir(prep, "runtime-config-manifests", True)
|
||||||
checked_dir(mandir)
|
checked_dir(mandir)
|
||||||
lockfd = os.open(
|
# The retained preprocessing directory is the single cross-process lock seam.
|
||||||
"runtime-config.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600, dir_fd=prep
|
# No pathname lock file is created in the workspace layout.
|
||||||
)
|
fcntl.flock(prep, fcntl.LOCK_EX)
|
||||||
try:
|
try:
|
||||||
ls = os.fstat(lockfd)
|
|
||||||
if (
|
|
||||||
not stat.S_ISREG(ls.st_mode)
|
|
||||||
or ls.st_nlink != 1
|
|
||||||
or stat.S_IMODE(ls.st_mode) != 0o600
|
|
||||||
or ls.st_uid != os.getuid()
|
|
||||||
):
|
|
||||||
fail("runtime config lock is not trusted")
|
|
||||||
fcntl.flock(lockfd, fcntl.LOCK_EX)
|
|
||||||
name = f"{rev}.yaml"
|
name = f"{rev}.yaml"
|
||||||
mname = f"{rev}.json"
|
mname = f"{rev}.json"
|
||||||
|
|
||||||
@@ -302,9 +313,25 @@ def publish(inp: dict) -> dict:
|
|||||||
write_all(fd, mb)
|
write_all(fd, mb)
|
||||||
os.fchmod(fd, 0o600)
|
os.fchmod(fd, 0o600)
|
||||||
os.fsync(fd)
|
os.fsync(fd)
|
||||||
os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False)
|
try:
|
||||||
except FileExistsError:
|
os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False)
|
||||||
pass
|
except FileExistsError:
|
||||||
|
# A no-replace loser is successful only after validating the
|
||||||
|
# durable winner byte-for-byte and against the strict schema.
|
||||||
|
winner = current(mandir, mname, 0o600)
|
||||||
|
if winner is None:
|
||||||
|
fail("runtime config manifest publication raced")
|
||||||
|
wfd, _ = winner
|
||||||
|
try:
|
||||||
|
existing = read_all(wfd)
|
||||||
|
finally:
|
||||||
|
os.close(wfd)
|
||||||
|
try:
|
||||||
|
strict_manifest(json.loads(existing.decode()))
|
||||||
|
except (ValueError, TypeError, UnicodeError, RuntimeError):
|
||||||
|
fail("runtime config manifest is invalid")
|
||||||
|
if existing != mb:
|
||||||
|
fail("same-revision runtime configuration changed")
|
||||||
finally:
|
finally:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
try:
|
try:
|
||||||
@@ -321,7 +348,6 @@ def publish(inp: dict) -> dict:
|
|||||||
"ino": s.st_ino,
|
"ino": s.st_ino,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
os.close(lockfd)
|
|
||||||
os.close(cfgdir)
|
os.close(cfgdir)
|
||||||
os.close(mandir)
|
os.close(mandir)
|
||||||
os.close(prep)
|
os.close(prep)
|
||||||
@@ -346,7 +372,7 @@ def verified_snapshot(inp: dict) -> dict:
|
|||||||
checked_dir(rdir)
|
checked_dir(rdir)
|
||||||
fd = os.open(f"{wid}.yaml", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
fd = os.open(f"{wid}.yaml", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||||
try:
|
try:
|
||||||
read_regular(fd, 0o400)
|
descriptor_info = read_regular(fd, 0o400)
|
||||||
chunks = []
|
chunks = []
|
||||||
while True:
|
while True:
|
||||||
x = os.read(fd, 1024 * 1024)
|
x = os.read(fd, 1024 * 1024)
|
||||||
@@ -375,16 +401,39 @@ def verified_snapshot(inp: dict) -> dict:
|
|||||||
fail("workspace snapshot integrity check failed")
|
fail("workspace snapshot integrity check failed")
|
||||||
records = manifest.get("revisions")
|
records = manifest.get("revisions")
|
||||||
files = manifest.get("files")
|
files = manifest.get("files")
|
||||||
record = next((r for r in records if isinstance(r, dict) and r.get("id") == wid), None) if isinstance(records, list) else None
|
if not isinstance(records, list) or not isinstance(files, dict) or not records:
|
||||||
|
fail("workspace snapshot integrity check failed")
|
||||||
|
record_by_id: dict[str, dict] = {}
|
||||||
|
for item in records:
|
||||||
|
if not isinstance(item, dict) or set(item) != {"id", "commit", "blob", "snapshotPath"}:
|
||||||
|
fail("workspace snapshot integrity check failed")
|
||||||
|
item_id = item.get("id")
|
||||||
|
if not isinstance(item_id, str) or not safe_id(item_id) or item_id in record_by_id:
|
||||||
|
fail("workspace snapshot integrity check failed")
|
||||||
|
if item.get("commit") != rev or item.get("snapshotPath") != f"{root}/{rev}/{item_id}.yaml":
|
||||||
|
fail("workspace snapshot integrity check failed")
|
||||||
|
if not isinstance(item.get("blob"), str) or not safe_rev(item["blob"]):
|
||||||
|
fail("workspace snapshot integrity check failed")
|
||||||
|
record_by_id[item_id] = item
|
||||||
|
expected_names = {name for item_id in record_by_id for name in (f"{item_id}.yaml", f"{item_id}.env.example", f"{item_id}.md")}
|
||||||
|
if set(files) != expected_names or any(not isinstance(v, str) or not __import__("re").fullmatch(r"[0-9a-f]{64}", v) for v in files.values()):
|
||||||
|
fail("workspace snapshot integrity check failed")
|
||||||
|
# Verify every immutable file declared by snapshot.json, not just the selected
|
||||||
|
# descriptor. This prevents extra records/files from smuggling a second state.
|
||||||
|
for filename in sorted(expected_names):
|
||||||
|
f = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||||
|
try:
|
||||||
|
read_regular(f, 0o400)
|
||||||
|
actual = hashlib.sha256(read_all(f)).hexdigest()
|
||||||
|
finally:
|
||||||
|
os.close(f)
|
||||||
|
if actual != files[filename]:
|
||||||
|
fail("workspace snapshot integrity check failed")
|
||||||
|
record = record_by_id.get(wid)
|
||||||
expected_path = f"{root}/{rev}/{wid}.yaml"
|
expected_path = f"{root}/{rev}/{wid}.yaml"
|
||||||
if (
|
if record is None or manifest.get("head") != rev or record.get("snapshotPath") != expected_path:
|
||||||
manifest.get("head") != rev or not isinstance(records, list) or not record
|
fail("workspace snapshot integrity check failed")
|
||||||
or set(record) != {"id", "commit", "blob", "snapshotPath"}
|
if files.get(f"{wid}.yaml") != hashlib.sha256(source).hexdigest():
|
||||||
or record.get("commit") != rev or record.get("snapshotPath") != expected_path
|
|
||||||
or not isinstance(record.get("blob"), str) or not safe_rev(record.get("blob"))
|
|
||||||
or not isinstance(files, dict)
|
|
||||||
or files.get(f"{wid}.yaml") != hashlib.sha256(source).hexdigest()
|
|
||||||
):
|
|
||||||
fail("workspace snapshot integrity check failed")
|
fail("workspace snapshot integrity check failed")
|
||||||
repo = inp.get("repository_root")
|
repo = inp.get("repository_root")
|
||||||
if not isinstance(repo, str) or not os.path.isabs(repo):
|
if not isinstance(repo, str) or not os.path.isabs(repo):
|
||||||
@@ -401,25 +450,20 @@ def verified_snapshot(inp: dict) -> dict:
|
|||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
fail("workspace Git revision is unavailable")
|
fail("workspace Git revision is unavailable")
|
||||||
try:
|
try:
|
||||||
import re
|
git_text = git_source.decode("utf-8")
|
||||||
from collections import Counter
|
|
||||||
normalize = lambda value: re.findall(r"[A-Za-z0-9_.:/@+-]+", value)
|
|
||||||
git_tokens = Counter(normalize(git_source.decode("utf-8")))
|
|
||||||
snapshot_tokens = Counter(normalize(source.decode("utf-8")))
|
|
||||||
# The registry canonicalizer may add schema defaults/reorder mappings.
|
|
||||||
# Every token from the exact Git descriptor must nevertheless survive;
|
|
||||||
# replacements (including non-rendered workspace.name) are rejected.
|
|
||||||
equivalent = all(snapshot_tokens[k] >= count for k, count in git_tokens.items())
|
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
equivalent = False
|
fail("workspace Git descriptor identity mismatch")
|
||||||
if blob != record.get("blob") or not equivalent:
|
if blob != record.get("blob"):
|
||||||
fail("workspace Git descriptor identity mismatch")
|
fail("workspace Git descriptor identity mismatch")
|
||||||
return {
|
return {
|
||||||
"workspace_id": wid,
|
"workspace_id": wid,
|
||||||
"workspace_revision": rev,
|
"workspace_revision": rev,
|
||||||
"source": source.decode(),
|
"source": source.decode(),
|
||||||
|
"git_source": git_text,
|
||||||
"sha256": hashlib.sha256(source).hexdigest(),
|
"sha256": hashlib.sha256(source).hexdigest(),
|
||||||
"descriptor_git_blob": record.get("blob"),
|
"descriptor_git_blob": record.get("blob"),
|
||||||
|
"descriptor_dev": descriptor_info.st_dev,
|
||||||
|
"descriptor_ino": descriptor_info.st_ino,
|
||||||
"snapshot_path": f"{root}/{rev}/{wid}.yaml",
|
"snapshot_path": f"{root}/{rev}/{wid}.yaml",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user