fix: harden durable workspace runtime config leases
This commit is contained in:
+27
-3
@@ -573,10 +573,34 @@ def _validate_raw_config_shape(raw: dict[str, Any], path: Path) -> None:
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
if not path.exists():
|
||||
raise ConfigError(f"File di configurazione non trovato: {path}")
|
||||
# Backend runtime leases pass the verified canonical config as fd 3 while retaining
|
||||
# the ordinary absolute -c argument for diagnostics and source identity. Never reopen
|
||||
# that pathname: an ancestor or leaf replacement after spawn must not alter bytes used
|
||||
# by the harness.
|
||||
runtime_fd = os.environ.get("THT_CONFIG_FD")
|
||||
if runtime_fd is not None:
|
||||
try:
|
||||
fd = int(runtime_fd)
|
||||
info = os.fstat(fd)
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1
|
||||
or stat.S_IMODE(info.st_mode) != 0o400
|
||||
or info.st_uid != os.getuid()):
|
||||
raise OSError("unsafe runtime config descriptor")
|
||||
chunks: list[bytes] = []
|
||||
while chunk := os.read(fd, 1024 * 1024):
|
||||
chunks.append(chunk)
|
||||
source_text = b"".join(chunks).decode("utf-8")
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
raise ConfigError("File di configurazione runtime non attendibile") from exc
|
||||
else:
|
||||
if not path.exists():
|
||||
raise ConfigError(f"File di configurazione non trovato: {path}")
|
||||
try:
|
||||
source_text = path.read_text()
|
||||
except OSError as exc:
|
||||
raise ConfigError(f"File di configurazione non trovato: {path}") from exc
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text())
|
||||
raw = yaml.safe_load(source_text)
|
||||
except yaml.YAMLError as exc:
|
||||
raise ConfigError(f"Configurazione YAML non valida: {path}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Small privileged filesystem seam for durable runtime configuration publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def safe_id(v: str) -> bool:
|
||||
return bool(__import__("re").fullmatch(r"[a-z][a-z0-9-]{2,62}", v))
|
||||
|
||||
|
||||
def safe_rev(v: str) -> bool:
|
||||
return bool(__import__("re").fullmatch(r"[0-9a-f]{40}", v))
|
||||
|
||||
|
||||
def open_dir(parent: int | None, name: str, create: bool = False) -> int:
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW
|
||||
try:
|
||||
return os.open(name, flags, dir_fd=parent)
|
||||
except FileNotFoundError:
|
||||
if not create:
|
||||
raise
|
||||
os.mkdir(name, 0o700, dir_fd=parent)
|
||||
return os.open(name, flags, dir_fd=parent)
|
||||
|
||||
|
||||
def checked_dir(fd: int, expected_mode: int = 0o700) -> None:
|
||||
s = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISDIR(s.st_mode)
|
||||
or s.st_nlink < 1
|
||||
or stat.S_IMODE(s.st_mode) != expected_mode
|
||||
or s.st_uid != os.getuid()
|
||||
):
|
||||
fail("runtime config directory is not trusted")
|
||||
|
||||
|
||||
def walk(root: str, comps: list[str], create: bool = True) -> int:
|
||||
if not os.path.isabs(root):
|
||||
fail("data root must be absolute")
|
||||
if not os.path.lexists(root):
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
fd = os.open(root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW)
|
||||
try:
|
||||
checked_dir(fd)
|
||||
except:
|
||||
os.close(fd)
|
||||
raise
|
||||
for c in comps:
|
||||
if c in ("", ".", "..") or "/" in c:
|
||||
os.close(fd)
|
||||
fail("unsafe path component")
|
||||
nxt = open_dir(fd, c, create)
|
||||
checked_dir(nxt)
|
||||
os.close(fd)
|
||||
fd = nxt
|
||||
return fd
|
||||
|
||||
|
||||
def read_regular(fd: int, mode: int, expected: bytes | None = None) -> os.stat_result:
|
||||
s = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(s.st_mode)
|
||||
or s.st_nlink != 1
|
||||
or stat.S_IMODE(s.st_mode) != mode
|
||||
or s.st_uid != os.getuid()
|
||||
):
|
||||
fail("runtime config file is not trusted")
|
||||
if expected is not None:
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
chunks = []
|
||||
while True:
|
||||
x = os.read(fd, 1024 * 1024)
|
||||
if not x:
|
||||
break
|
||||
chunks.append(x)
|
||||
if b"".join(chunks) != expected:
|
||||
fail("same-revision runtime configuration changed")
|
||||
return s
|
||||
|
||||
|
||||
def write_all(fd: int, data: bytes) -> None:
|
||||
pos = 0
|
||||
while pos < len(data):
|
||||
n = os.write(fd, data[pos:])
|
||||
if n <= 0:
|
||||
fail("short runtime config write")
|
||||
pos += n
|
||||
|
||||
|
||||
def publish(inp: dict) -> dict:
|
||||
root = inp.get("data_root")
|
||||
wid = inp.get("workspace_id")
|
||||
rev = inp.get("workspace_revision")
|
||||
if (
|
||||
not isinstance(root, str)
|
||||
or not os.path.isabs(root)
|
||||
or not safe_id(wid)
|
||||
or not safe_rev(rev)
|
||||
):
|
||||
fail("invalid publication identity")
|
||||
try:
|
||||
content = bytes.fromhex(inp["config_hex"])
|
||||
except (TypeError, ValueError):
|
||||
fail("invalid config bytes")
|
||||
base = inp.get("manifest_base")
|
||||
if not isinstance(base, dict):
|
||||
fail("invalid manifest")
|
||||
if base.get("workspace_id") != wid or base.get("workspace_revision") != rev:
|
||||
fail("manifest identity mismatch")
|
||||
sessions = walk(root, ["sessions"], True)
|
||||
ws = open_dir(sessions, wid, True)
|
||||
checked_dir(ws)
|
||||
prep = open_dir(ws, "preprocessing", True)
|
||||
checked_dir(prep)
|
||||
cfgdir = open_dir(prep, "runtime-config", True)
|
||||
checked_dir(cfgdir)
|
||||
mandir = open_dir(prep, "runtime-config-manifests", True)
|
||||
checked_dir(mandir)
|
||||
lockfd = os.open(
|
||||
"runtime-config.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600, dir_fd=prep
|
||||
)
|
||||
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"
|
||||
mname = f"{rev}.json"
|
||||
|
||||
def current(dfd, n, mode):
|
||||
try:
|
||||
fd = os.open(n, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=dfd)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
return (fd, read_regular(fd, mode))
|
||||
except:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
got = current(cfgdir, name, 0o400)
|
||||
if got:
|
||||
fd, s = got
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
old = os.read(fd, len(content) + 1)
|
||||
os.close(fd)
|
||||
if old != content:
|
||||
fail("same-revision runtime configuration changed")
|
||||
else:
|
||||
stage = f".{name}.staging-{os.getpid()}-{os.urandom(8).hex()}"
|
||||
fd = os.open(
|
||||
stage, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=cfgdir
|
||||
)
|
||||
try:
|
||||
write_all(fd, content)
|
||||
os.fchmod(fd, 0o400)
|
||||
os.fsync(fd)
|
||||
try:
|
||||
os.link(
|
||||
stage, name, src_dir_fd=cfgdir, dst_dir_fd=cfgdir, follow_symlinks=False
|
||||
)
|
||||
except FileExistsError:
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(stage, dir_fd=cfgdir)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
got = current(cfgdir, name, 0o400)
|
||||
if not got:
|
||||
fail("runtime config publication failed")
|
||||
fd, s = got
|
||||
try:
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
if os.read(fd, len(content) + 1) != content:
|
||||
fail("same-revision runtime configuration changed")
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.fsync(cfgdir)
|
||||
# Identity is deliberately recorded after final no-replace publication.
|
||||
got = current(cfgdir, name, 0o400)
|
||||
assert got
|
||||
fd, s = got
|
||||
os.close(fd)
|
||||
manifest = dict(base)
|
||||
manifest.update(
|
||||
{
|
||||
"config_sha256": hashlib.sha256(content).hexdigest(),
|
||||
"config_dev": str(s.st_dev),
|
||||
"config_ino": str(s.st_ino),
|
||||
"config_size": str(s.st_size),
|
||||
"config_mode": format(stat.S_IMODE(s.st_mode), "o"),
|
||||
"config_uid": str(s.st_uid),
|
||||
"config_nlink": str(s.st_nlink),
|
||||
}
|
||||
)
|
||||
mb = (json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
oldm = current(mandir, mname, 0o600)
|
||||
if oldm:
|
||||
mfd, _ = oldm
|
||||
os.lseek(mfd, 0, os.SEEK_SET)
|
||||
existing = os.read(mfd, len(mb) + 1)
|
||||
os.close(mfd)
|
||||
if existing != mb:
|
||||
fail("same-revision runtime configuration changed")
|
||||
else:
|
||||
stage = f".{mname}.staging-{os.getpid()}-{os.urandom(8).hex()}"
|
||||
fd = os.open(
|
||||
stage, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=mandir
|
||||
)
|
||||
try:
|
||||
write_all(fd, mb)
|
||||
os.fchmod(fd, 0o600)
|
||||
os.fsync(fd)
|
||||
os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(stage, dir_fd=mandir)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
os.fsync(mandir)
|
||||
return {
|
||||
"path": f"{root}/sessions/{wid}/preprocessing/runtime-config/{name}",
|
||||
"manifestPath": f"{root}/sessions/{wid}/preprocessing/runtime-config-manifests/{mname}",
|
||||
"manifest": mb.decode(),
|
||||
"dev": s.st_dev,
|
||||
"ino": s.st_ino,
|
||||
}
|
||||
finally:
|
||||
os.close(lockfd)
|
||||
os.close(cfgdir)
|
||||
os.close(mandir)
|
||||
os.close(prep)
|
||||
os.close(ws)
|
||||
os.close(sessions)
|
||||
|
||||
|
||||
def verified_snapshot(inp: dict) -> dict:
|
||||
root = inp.get("snapshots_root")
|
||||
rev = inp.get("workspace_revision")
|
||||
wid = inp.get("workspace_id")
|
||||
if (
|
||||
not isinstance(root, str)
|
||||
or not os.path.isabs(root)
|
||||
or not safe_rev(rev)
|
||||
or not safe_id(wid)
|
||||
):
|
||||
fail("invalid snapshot identity")
|
||||
# Component-relative no-follow traversal all the way to the retained descriptor.
|
||||
sroot = walk(root, [], False)
|
||||
rdir = open_dir(sroot, rev, False)
|
||||
checked_dir(rdir)
|
||||
fd = os.open(f"{wid}.yaml", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||
try:
|
||||
read_regular(fd, 0o400)
|
||||
chunks = []
|
||||
while True:
|
||||
x = os.read(fd, 1024 * 1024)
|
||||
if not x:
|
||||
break
|
||||
chunks.append(x)
|
||||
source = b"".join(chunks)
|
||||
finally:
|
||||
os.close(fd)
|
||||
mf = os.open("snapshot.json", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||
try:
|
||||
read_regular(mf, 0o400)
|
||||
payload = b""
|
||||
while True:
|
||||
x = os.read(mf, 1024 * 1024)
|
||||
if not x:
|
||||
break
|
||||
payload += x
|
||||
finally:
|
||||
os.close(mf)
|
||||
manifest = json.loads(payload.decode())
|
||||
record = next((r for r in manifest.get("revisions", []) if r.get("id") == wid), None)
|
||||
if (
|
||||
manifest.get("head") != rev
|
||||
or not record
|
||||
or record.get("commit") != rev
|
||||
or manifest.get("files", {}).get(f"{wid}.yaml") != hashlib.sha256(source).hexdigest()
|
||||
):
|
||||
fail("workspace snapshot integrity check failed")
|
||||
repo = inp.get("repository_root")
|
||||
if repo:
|
||||
if not isinstance(repo, str) or not os.path.isabs(repo):
|
||||
fail("invalid repository root")
|
||||
try:
|
||||
blob = subprocess.check_output(
|
||||
["git", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=5,
|
||||
).strip()
|
||||
git_source = subprocess.check_output(
|
||||
["git", "-C", repo, "show", f"{rev}:workspaces/{wid}.yaml"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
fail("workspace Git revision is unavailable")
|
||||
if blob != record.get("blob") or git_source != source:
|
||||
fail("workspace Git descriptor identity mismatch")
|
||||
return {
|
||||
"workspace_id": wid,
|
||||
"workspace_revision": rev,
|
||||
"source": source.decode(),
|
||||
"sha256": hashlib.sha256(source).hexdigest(),
|
||||
"descriptor_git_blob": record.get("blob"),
|
||||
"snapshot_path": f"{root}/{rev}/{wid}.yaml",
|
||||
}
|
||||
|
||||
|
||||
def binding(inp: dict) -> dict:
|
||||
try:
|
||||
raw = bytes.fromhex(inp["config_hex"])
|
||||
except (TypeError, ValueError):
|
||||
fail("invalid config bytes")
|
||||
# Use the harness' own Pydantic loader and config_dwh_binding; this is intentionally
|
||||
# not a TypeScript reimplementation of its normalization/fingerprinting rules.
|
||||
from tht.config import load_config
|
||||
from tht.jobs.dwh_pipeline import config_dwh_binding
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="runtime-binding-", suffix=".yaml", delete=False
|
||||
) as stream:
|
||||
stream.write(raw)
|
||||
path = Path(stream.name)
|
||||
try:
|
||||
return config_dwh_binding(load_config(path))
|
||||
finally:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
inp = json.load(sys.stdin)
|
||||
action = inp.get("action")
|
||||
if action == "publish":
|
||||
result = publish(inp)
|
||||
elif action == "verified-snapshot":
|
||||
result = verified_snapshot(inp)
|
||||
elif action == "binding":
|
||||
result = binding(inp)
|
||||
else:
|
||||
fail("unsupported runtime config action")
|
||||
print(json.dumps(result))
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(json.dumps({"error": str(e)}))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user