fix: harden runtime config lease identity publication
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user