fix: harden runtime config lease identity publication

This commit is contained in:
2026-08-11 11:31:15 +02:00
parent e9613767c5
commit 4c9c849fcd
3 changed files with 329 additions and 18 deletions
@@ -1,5 +1,5 @@
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, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
@@ -32,7 +32,7 @@ llm_policy:
allowed: [zai/glm-5.2] allowed: [zai/glm-5.2]
`; `;
function fixture() { function fixture(extraEnv: Record<string, string> = {}) {
const root = mkdtempSync(join(tmpdir(), "runtime-config-lease-")); const root = mkdtempSync(join(tmpdir(), "runtime-config-lease-"));
const canonicalDescriptor = serializeWorkspaceYaml(parseWorkspaceYaml(descriptor)); const canonicalDescriptor = serializeWorkspaceYaml(parseWorkspaceYaml(descriptor));
const snapshots = join(root, "snapshots"); const snapshots = join(root, "snapshots");
@@ -73,13 +73,13 @@ function fixture() {
env: { env: {
THT_WS_ABC_DWH_TRANSPORT: "postgres_direct", THT_WS_ABC_DWH_HOST: "dwh", THT_WS_ABC_DWH_TRANSPORT: "postgres_direct", THT_WS_ABC_DWH_HOST: "dwh",
THT_WS_ABC_DWH_PORT: "5432", THT_WS_ABC_DWH_USER: "reader", THT_WS_ABC_DWH_PORT: "5432", THT_WS_ABC_DWH_USER: "reader",
THT_WS_ABC_DWH_PASSWORD_FILE: secret, THT_WS_ABC_DWH_PASSWORD_FILE: secret, ...extraEnv,
}, secretRoots: [root], semanticRuntime: { }, secretRoots: [root], semanticRuntime: {
internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434",
internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024, internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024,
}, },
}); });
return { root, snapshotPath, factory, canonicalDescriptor }; return { root, repo, snapshotPath, factory, canonicalDescriptor, snapshotManifest: join(snapshotsDir, "snapshot.json") };
} }
test("session and maintenance share deterministic bytes and path", () => { test("session and maintenance share deterministic bytes and path", () => {
@@ -141,3 +141,113 @@ test("same-byte replacement of the registry descriptor is refused", () => {
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", () => {
const f = fixture();
try {
const lease = f.factory.acquireSession(f.snapshotPath);
const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8"));
expect(manifest.directory_identities.length).toBeGreaterThan(5);
expect(manifest.directory_identities.map((entry: { path: string }) => entry.path)).toContain(
`${process.platform === "darwin" ? "/private" : ""}${join(f.root, "data", "sessions", workspace, "preprocessing")}`,
);
expect(manifest.directory_identities.every((entry: Record<string, string>) =>
["path", "dev", "ino", "mode", "uid"].every((key) => typeof entry[key] === "string"),
)).toBe(true);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("raw Git identity ignores replacement refs", () => {
const f = fixture();
try {
const evil = f.canonicalDescriptor.replace("database: analytics", "database: evil");
chmodSync(join(f.repo, "workspaces", `${workspace}.yaml`), 0o600);
writeFileSync(join(f.repo, "workspaces", `${workspace}.yaml`), evil);
execFileSync("git", ["add", "."], { cwd: f.repo });
execFileSync("git", ["commit", "-m", "evil"], { cwd: f.repo });
const evilCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.repo, encoding: "utf8" }).trim();
const oldCommit = JSON.parse(readFileSync(f.snapshotManifest, "utf8")).head;
execFileSync("git", ["replace", oldCommit, evilCommit], { cwd: f.repo });
chmodSync(f.snapshotPath, 0o600);
writeFileSync(f.snapshotPath, evil);
chmodSync(f.snapshotPath, 0o400);
const snapshot = JSON.parse(readFileSync(f.snapshotManifest, "utf8"));
snapshot.files[`${workspace}.yaml`] = createHash("sha256").update(evil).digest("hex");
chmodSync(f.snapshotManifest, 0o600);
writeFileSync(f.snapshotManifest, JSON.stringify(snapshot));
chmodSync(f.snapshotManifest, 0o400);
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/Git descriptor|integrity|identity/i);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("replacement of canonical destination directories is refused", () => {
const f = fixture();
try {
const lease = f.factory.acquireSession(f.snapshotPath);
const original = join(f.root, "data", "sessions", workspace);
const moved = `${original}.moved`;
renameSync(original, moved);
mkdirSync(join(original, "preprocessing", "runtime-config"), { 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-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);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("rename faults fail closed and remove staging files", () => {
const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: "1" });
try {
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/rename|failed/i);
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
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);
}
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("session and operator outputs retain normalized private-host policy and binding", () => {
const f = fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: "internal.example,warehouse.example" });
try {
const session = f.factory.acquireSession(f.snapshotPath);
const maintenance = f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath });
const output = readFileSync(session.path, "utf8");
expect(output).toContain("http_private_host_allowlist");
expect(output).toContain("- internal.example");
expect(output).toContain("- warehouse.example");
expect(output).toBe(readFileSync(maintenance.path, "utf8"));
const manifest = JSON.parse(readFileSync(session.manifestPath, "utf8"));
expect(manifest.config_dwh_binding).toEqual({
workspace_id: expect.any(String), config_fingerprint: expect.any(String), input_fingerprint: expect.any(String),
});
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("unexpected manifest fields are refused before handoff", () => {
const f = fixture();
try {
const lease = f.factory.acquireSession(f.snapshotPath);
const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8"));
manifest.unexpected = true;
chmodSync(lease.manifestPath, 0o600);
writeFileSync(lease.manifestPath, JSON.stringify(manifest));
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/manifest|invalid|changed/i);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("runtime config symlink replacement is refused", () => {
const f = fixture();
try {
const lease = f.factory.acquireSession(f.snapshotPath);
const replacement = `${lease.path}.real`;
writeFileSync(replacement, readFileSync(lease.path), { mode: 0o400 });
chmodSync(lease.path, 0o600);
rmSync(lease.path);
// A no-follow handoff must never consume this pathname.
execFileSync("ln", ["-s", replacement, lease.path]);
expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|changed|configuration|symbolic/i);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
+83 -1
View File
@@ -599,10 +599,14 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]:
"version", "workspace_id", "workspace_revision", "descriptor_git_blob", "version", "workspace_id", "workspace_revision", "descriptor_git_blob",
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256", "descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256",
"config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode", "config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode",
"config_uid", "config_nlink", "config_uid", "config_nlink", "directory_identities",
} }
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")
if (not isinstance(raw.get("workspace_id"), str) or not re.fullmatch(r"[a-z][a-z0-9-]{2,62}", raw["workspace_id"])
or not isinstance(raw.get("workspace_revision"), str) or not re.fullmatch(r"[0-9a-f]{40}", raw["workspace_revision"])
or not isinstance(raw.get("descriptor_git_blob"), str) or not re.fullmatch(r"[0-9a-f]{40}", raw["descriptor_git_blob"])):
raise ConfigError("Manifest runtime non valido")
if not isinstance(raw.get("config_dwh_binding"), dict): if not isinstance(raw.get("config_dwh_binding"), dict):
raise ConfigError("Manifest runtime non valido") raise ConfigError("Manifest runtime non valido")
binding = raw["config_dwh_binding"] binding = raw["config_dwh_binding"]
@@ -614,12 +618,88 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]:
for key in ("descriptor_dev", "descriptor_ino", "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")
identities = raw.get("directory_identities")
if (not isinstance(identities, list) or not identities or
any(not isinstance(item, dict) or set(item) != {"path", "dev", "ino", "mode", "uid"}
or not isinstance(item["path"], str) or not item["path"].startswith("/")
or any(not isinstance(item[key], str) or not item[key].isdigit()
for key in ("dev", "ino", "mode", "uid"))
or item["mode"] == "0"
for item in identities)):
raise ConfigError("Manifest runtime non valido")
if len({item["path"] for item in identities}) != len(identities):
raise ConfigError("Manifest runtime non valido")
if raw["config_mode"] != "400": if raw["config_mode"] != "400":
raise ConfigError("Manifest runtime non valido") raise ConfigError("Manifest runtime non valido")
return raw return raw
def _canonical_runtime_path(path: Path) -> Path:
value = str(path)
if value == "/tmp" or value.startswith("/tmp/"):
return Path("/private" + value)
if value == "/var" or value.startswith("/var/"):
return Path("/private" + value)
return path
def _runtime_directory_identities(paths: list[Path]) -> list[dict[str, str]]:
"""Open/stat every directory component and return its current identity chain."""
out: list[dict[str, str]] = []
seen: set[str] = set()
for path in paths:
canonical = _canonical_runtime_path(path)
parts = list(canonical.parts)
if not parts or parts[0] != "/":
raise OSError("runtime config path is invalid")
current = "/"
components = ["/"] + parts[1:]
for component in components:
if component != "/":
current = current.rstrip("/") + "/" + component
# Re-open shared prefixes for each destination branch too: a
# replacement between config-parent and manifest-parent traversal
# must not be hidden by de-duplication.
# lstat before and fstat after open closes the stat/open replacement
# window for each component, including canonical destination parents.
parent = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
for name in [part for part in Path(current).parts[1:-1]]:
nxt = _open_runtime_component(parent, name)
os.close(parent); parent = nxt
if current == "/":
fd = os.dup(parent)
else:
name = Path(current).name
fd = _open_runtime_component(parent, name)
try:
info = os.fstat(fd)
if not stat.S_ISDIR(info.st_mode) or info.st_nlink < 1:
raise OSError("unsafe runtime directory")
identity = {"path": current, "dev": str(info.st_dev), "ino": str(info.st_ino),
"mode": format(stat.S_IMODE(info.st_mode), "o"),
"uid": str(info.st_uid)}
if current in seen:
previous = next(item for item in out if item["path"] == current)
if identity != previous:
raise OSError("runtime config directory changed")
else:
out.append(identity)
seen.add(current)
finally:
os.close(fd)
finally:
os.close(parent)
return out
def _verify_runtime_directory_identities(config_path: Path, manifest_path: Path, expected: object) -> None:
current = _runtime_directory_identities([config_path.parent, manifest_path.parent])
if current != expected:
raise ConfigError("Destinazione config runtime modificata")
def _open_runtime_component(parent: int, name: str) -> int: def _open_runtime_component(parent: int, name: str) -> int:
before = os.stat(name, dir_fd=parent, follow_symlinks=False) before = os.stat(name, dir_fd=parent, follow_symlinks=False)
if stat.S_ISLNK(before.st_mode): if stat.S_ISLNK(before.st_mode):
@@ -687,6 +767,7 @@ def load_config(path: Path) -> Config:
if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest: if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
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")))
_verify_runtime_directory_identities(path, _runtime_manifest_path(path), runtime_manifest["directory_identities"])
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest() 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_dev"]) != config_info.st_dev
or int(runtime_manifest["config_ino"]) != config_info.st_ino or int(runtime_manifest["config_ino"]) != config_info.st_ino
@@ -715,6 +796,7 @@ def load_config(path: Path) -> Config:
if hashlib.sha256(manifest_bytes).hexdigest() != legacy_expected: 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")))
_verify_runtime_directory_identities(path, _runtime_manifest_path(path), runtime_manifest["directory_identities"])
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest() 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_dev"]) != config_info.st_dev
or int(runtime_manifest["config_ino"]) != config_info.st_ino or int(runtime_manifest["config_ino"]) != config_info.st_ino
+132 -13
View File
@@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
import ctypes
import fcntl import fcntl
import hashlib import hashlib
import json import json
import os import os
import platform
import stat import stat
import subprocess import subprocess
import sys import sys
@@ -17,6 +19,99 @@ def fail(msg: str) -> None:
raise RuntimeError(msg) raise RuntimeError(msg)
def _canonical_root(root: str) -> str:
# Darwin exposes /tmp and /var as symlink aliases. The trusted path boundary
# records the real OS-owned prefix so a manifest never contains a symlink.
if root == "/tmp" or root.startswith("/tmp/"):
return "/private" + root
if root == "/var" or root.startswith("/var/"):
return "/private" + root
return root
def _identity(st: os.stat_result, path: str) -> dict[str, str]:
return {"path": path, "dev": str(st.st_dev), "ino": str(st.st_ino),
"mode": format(stat.S_IMODE(st.st_mode), "o"), "uid": str(st.st_uid)}
def _rename_noreplace(src: str, dst: str, directory_fd: int, kind: str) -> None:
"""Atomically rename *src* to *dst* without replacing an existing entry.
``link`` is deliberately not used here: the state-file protocol requires a
rename, and a hard-link publication leaves a second name visible during a
crash. Unsupported platforms fail closed rather than silently weakening the
protocol. The env seam is intentionally narrow so fault tests can exercise
every publication rename without monkey-patching the privileged process.
"""
if os.environ.get("THT_RUNTIME_CONFIG_RENAME_FAIL") in {"1", kind}:
fail("runtime config rename failed")
libc = ctypes.CDLL(None, use_errno=True)
src_b = os.fsencode(src)
dst_b = os.fsencode(dst)
if sys.platform == "darwin":
fn = getattr(libc, "renameatx_np", None)
if fn is None:
fail("runtime config no-replace rename is unavailable")
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
fn.restype = ctypes.c_int
# RENAME_EXCL is the Darwin no-overwrite operation.
rc = fn(directory_fd, src_b, directory_fd, dst_b, 0x00000004)
elif sys.platform.startswith("linux"):
# renameat2(2), RENAME_NOREPLACE. syscall numbers are stable for the
# supported Linux architectures; an unavailable syscall fails closed.
number = {"x86_64": 316, "aarch64": 276, "arm64": 276}.get(platform.machine())
if number is None or not hasattr(libc, "syscall"):
fail("runtime config no-replace rename is unavailable")
libc.syscall.argtypes = [ctypes.c_long, ctypes.c_int, ctypes.c_char_p,
ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
libc.syscall.restype = ctypes.c_long
rc = libc.syscall(number, directory_fd, src_b, directory_fd, dst_b, 1)
else:
fail("runtime config no-replace rename is unavailable")
if rc != 0:
err = ctypes.get_errno()
if err == 17:
raise FileExistsError(err, os.strerror(err), dst)
raise OSError(err, os.strerror(err), dst)
def directory_identities(root: str, paths: list[tuple[str, int]]) -> list[dict[str, str]]:
"""Return the ordered, no-follow identity chain bound by a publication.
``paths`` contains canonical absolute directory paths paired with already-open
descriptors. Prefix components are stat'ed without following symlinks; the
terminal workspace-owned components are additionally represented by fstat on
the descriptors opened by ``walk``.
"""
canonical = _canonical_root(root)
root_parts = [part for part in Path(canonical).parts if part not in ("", "/")]
entries: list[dict[str, str]] = []
current = "/"
st = os.stat("/", follow_symlinks=False)
entries.append(_identity(st, current))
for part in root_parts:
current = (current.rstrip("/") + "/" + part) if current != "/" else "/" + part
st = os.stat(current, follow_symlinks=False)
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
fail("runtime config directory is not trusted")
entries.append(_identity(st, current))
for path, fd in paths:
cpath = _canonical_root(path)
st = os.fstat(fd)
if not stat.S_ISDIR(st.st_mode) or stat.S_IMODE(st.st_mode) != 0o700 or st.st_uid != os.getuid():
fail("runtime config directory is not trusted")
# Keep one ordered entry per path. Existing prefixes are left in place.
if not any(item["path"] == cpath for item in entries):
entries.append(_identity(st, cpath))
else:
for item in entries:
if item["path"] == cpath:
if item["dev"] != str(st.st_dev) or item["ino"] != str(st.st_ino):
fail("runtime config directory changed")
break
return entries
def safe_id(v: str) -> bool: def safe_id(v: str) -> bool:
return bool(__import__("re").fullmatch(r"[a-z][a-z0-9-]{2,62}", v)) return bool(__import__("re").fullmatch(r"[a-z][a-z0-9-]{2,62}", v))
@@ -162,7 +257,7 @@ def strict_manifest(value: object) -> dict:
"version", "workspace_id", "workspace_revision", "descriptor_git_blob", "version", "workspace_id", "workspace_revision", "descriptor_git_blob",
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256", "descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256",
"config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode", "config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode",
"config_uid", "config_nlink", "config_uid", "config_nlink", "directory_identities",
} }
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")
@@ -179,6 +274,17 @@ def strict_manifest(value: object) -> dict:
for key in ("descriptor_dev", "descriptor_ino", "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")
identities = value.get("directory_identities")
if (not isinstance(identities, list) or not identities or
any(not isinstance(item, dict) or set(item) != {"path", "dev", "ino", "mode", "uid"}
or not isinstance(item["path"], str) or not os.path.isabs(item["path"])
or any(not isinstance(item[key], str) or not item[key].isdigit()
for key in ("dev", "ino", "mode", "uid"))
or item["mode"] == "0"
for item in identities)):
fail("runtime config manifest is invalid")
if len({item["path"] for item in identities}) != len(identities):
fail("runtime config manifest is invalid")
if value["config_mode"] != "400": if value["config_mode"] != "400":
fail("runtime config manifest is invalid") fail("runtime config manifest is invalid")
return value return value
@@ -213,6 +319,14 @@ 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)
canonical = _canonical_root(root)
directory_manifest = directory_identities(root, [
(f"{canonical}/sessions", sessions),
(f"{canonical}/sessions/{wid}", ws),
(f"{canonical}/sessions/{wid}/preprocessing", prep),
(f"{canonical}/sessions/{wid}/preprocessing/runtime-config", cfgdir),
(f"{canonical}/sessions/{wid}/preprocessing/runtime-config-manifests", mandir),
])
# 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) fcntl.flock(prep, fcntl.LOCK_EX)
@@ -249,15 +363,11 @@ def publish(inp: dict) -> dict:
os.fchmod(fd, 0o400) os.fchmod(fd, 0o400)
os.fsync(fd) os.fsync(fd)
try: try:
os.link( _rename_noreplace(stage, name, cfgdir, "config")
stage, name, src_dir_fd=cfgdir, dst_dir_fd=cfgdir, follow_symlinks=False
)
except FileExistsError: except FileExistsError:
# A concurrent equal publisher may already have won. It is
# accepted only after reopening and comparing its bytes below.
pass pass
# Keep metadata ordering explicit even on filesystems where a
# hardlink publication does not retain fchmod as expected.
os.fchmod(fd, 0o400)
os.fsync(fd)
finally: finally:
os.close(fd) os.close(fd)
try: try:
@@ -290,6 +400,7 @@ def publish(inp: dict) -> dict:
"config_mode": format(stat.S_IMODE(s.st_mode), "o"), "config_mode": format(stat.S_IMODE(s.st_mode), "o"),
"config_uid": str(s.st_uid), "config_uid": str(s.st_uid),
"config_nlink": str(s.st_nlink), "config_nlink": str(s.st_nlink),
"directory_identities": directory_manifest,
} }
) )
strict_manifest(manifest) strict_manifest(manifest)
@@ -314,7 +425,7 @@ def publish(inp: dict) -> dict:
os.fchmod(fd, 0o600) os.fchmod(fd, 0o600)
os.fsync(fd) os.fsync(fd)
try: try:
os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False) _rename_noreplace(stage, mname, mandir, "manifest")
except FileExistsError: except FileExistsError:
# A no-replace loser is successful only after validating the # A no-replace loser is successful only after validating the
# durable winner byte-for-byte and against the strict schema. # durable winner byte-for-byte and against the strict schema.
@@ -438,14 +549,22 @@ def verified_snapshot(inp: dict) -> dict:
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):
fail("invalid repository root") fail("invalid repository root")
# Git replacement refs and ambient repository/config variables are attacker
# controlled process state. Snapshot identity must be the raw object named by
# the commit, with fixed Git configuration and repository boundaries.
# Keep no inherited GIT_* controls at all (including GIT_CONFIG_PARAMETERS,
# alternates, and repository path overrides), then add only fixed semantics.
git_env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
git_env.update({"GIT_NO_REPLACE_OBJECTS": "1", "GIT_CONFIG_NOSYSTEM": "1",
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull})
try: try:
blob = subprocess.check_output( blob = subprocess.check_output(
["git", "-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, stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
).strip() ).strip()
git_source = subprocess.check_output( git_source = subprocess.check_output(
["git", "-C", repo, "show", f"{rev}:workspaces/{wid}.yaml"], ["git", "--no-replace-objects", "-C", repo, "show", f"{rev}:workspaces/{wid}.yaml"],
stderr=subprocess.DEVNULL, timeout=5, stderr=subprocess.DEVNULL, timeout=5, env=git_env,
) )
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
fail("workspace Git revision is unavailable") fail("workspace Git revision is unavailable")