97 lines
3.8 KiB
Python
97 lines
3.8 KiB
Python
"""Private durable journal and fail-closed cold-copy verification primitives."""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import tempfile
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def private_write(path, data):
|
|
"""Unique temporary files let explicit recovery proceed after process crashes."""
|
|
path = Path(path)
|
|
if path.is_symlink():
|
|
raise ValueError("Refusing a symlink journal/configuration target")
|
|
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
|
|
with os.fdopen(descriptor, "wb") as stream:
|
|
stream.write(data)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
descriptor = os.open(path.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
def private_json(path, value):
|
|
private_write(path, json.dumps(value, sort_keys=True).encode())
|
|
|
|
|
|
def dotenv(entries):
|
|
"""Do not guess escaping for unsupported environment values; never log them."""
|
|
import re
|
|
result = {}
|
|
for entry in entries:
|
|
key, separator, value = entry.partition("=")
|
|
if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
raise ValueError("Unsupported runtime environment entry")
|
|
if key in result or any(character in value for character in "\x00\n\r'\\"):
|
|
raise ValueError("Runtime environment needs reviewed dotenv escaping")
|
|
result[key] = value
|
|
return "".join(f"{key}='{value}'\n" for key, value in sorted(result.items())).encode()
|
|
|
|
|
|
def tree_manifest(root):
|
|
"""Hash content plus ownership/modes/xattrs; reject symlinks and special files.
|
|
|
|
Preserving an unknown symlink could let the candidate write original data.
|
|
Hardlink equivalence groups are compared without depending on inode numbers.
|
|
"""
|
|
root = Path(root)
|
|
if root.is_symlink() or not root.is_dir():
|
|
raise ValueError("Cold-copy root must be a real directory")
|
|
records = {}
|
|
inodes = {}
|
|
for path in [root, *sorted(root.rglob("*"))]:
|
|
info = path.lstat()
|
|
relative = str(path.relative_to(root))
|
|
if not stat.S_ISREG(info.st_mode) and not stat.S_ISDIR(info.st_mode):
|
|
raise ValueError("Cold-copy tree contains an unreviewed symlink/special file")
|
|
record = {"mode": stat.S_IMODE(info.st_mode), "uid": info.st_uid, "gid": info.st_gid}
|
|
if stat.S_ISREG(info.st_mode):
|
|
hasher = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
hasher.update(block)
|
|
record.update(kind="file", size=info.st_size, sha256=hasher.hexdigest())
|
|
group = (info.st_dev, info.st_ino)
|
|
record["hardlink"] = inodes.setdefault(group, relative)
|
|
else:
|
|
record["kind"] = "directory"
|
|
if hasattr(os, "listxattr"):
|
|
record["xattrs"] = {name: digest(os.getxattr(path, name)) for name in sorted(os.listxattr(path))}
|
|
records[relative] = record
|
|
return records
|
|
|
|
|
|
def clone_cold(source, target, runner):
|
|
source, target = Path(source), Path(target)
|
|
if target.exists() or target.is_symlink():
|
|
raise ValueError("Cold-copy target already exists")
|
|
before = tree_manifest(source)
|
|
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
# Destination does not exist: cp -a copies root metadata as well as children.
|
|
runner(["cp", "-a", "--reflink=auto", "--", str(source), str(target)], timeout=3600)
|
|
runner(["sync", "-f", "--", str(target)], timeout=3600)
|
|
copied = tree_manifest(target)
|
|
if before != copied or before != tree_manifest(source):
|
|
raise ValueError("Cold copy content/metadata does not match unchanged original")
|
|
return digest(json.dumps(before, sort_keys=True).encode())
|