_ _
| |_ ___| |_ _
| | . | | | |
|_|_|___|_|_ |
|___|
git mirror - github.com/owenewans/holy - branch master
file tests/hardlink-updates.py
#!/usr/bin/env python3
import hashlib
import io
import os
import pathlib
import shlex
import subprocess
import sys
import tarfile
import tempfile
binary = str(pathlib.Path(sys.argv[1]).resolve())
with tempfile.TemporaryDirectory(prefix="holy-hardlink-updates-") as scratch:
tmp = pathlib.Path(scratch)
def run(*args, status=0, env=None):
result = subprocess.run([binary, *map(str, args)], capture_output=True, text=True, env=env)
assert result.returncode == status, (args, result.returncode, result.stdout, result.stderr)
return result.stdout
def package(label, payload, mode=0o640):
source = tmp / (label + ".pkg")
dirs = {str(parent) for path in payload for parent in pathlib.PurePosixPath(path).parents if str(parent) != "."}
with tarfile.open(source, "w") as archive:
metadata = ("pkgname = links\npkgver = " + label + "-1\narch = any\n").encode()
for path in [".PKGINFO", *sorted(dirs), *sorted(payload)]:
entry = tarfile.TarInfo(path)
entry.uid, entry.gid = os.getuid(), os.getgid()
entry.mode = mode
data = metadata if path == ".PKGINFO" else payload.get(path)
if path in dirs:
entry.type, entry.mode = tarfile.DIRTYPE, 0o755
elif isinstance(data, str):
entry.type, entry.linkname = tarfile.LNKTYPE, data
data = None
elif isinstance(data, tuple):
entry.type, entry.linkname, entry.mode = tarfile.SYMTYPE, data[1], 0o777
data = None
else:
entry.size = len(data)
archive.addfile(entry, io.BytesIO(data) if data is not None else None)
output = tmp / label
run("import", source, "--source", "fixture", "--format", "pacman", "--output", output)
artifact, = output.glob("*.holy")
return artifact, hashlib.sha256(artifact.read_bytes()).hexdigest(), payload, mode
old = package("1", {"opt/a": "opt/z", "opt/b": "opt/z", "opt/z": b"old\n", "opt/keep": b"keep\n"})
new = package("2", {"opt/a": "opt/z", "opt/b": "opt/z", "opt/z": b"new\n", "opt/keep": b"keep\n"})
added = package("3", {**new[2], "opt/new/d": "opt/z"})
moved = package("4", {"opt/a": "opt/new/q", "opt/z": "opt/new/q", "opt/new/d": "opt/new/q",
"opt/new/q": b"new\n", "opt/keep": b"keep\n"})
split = package("5", {path: b"new\n" if path != "opt/keep" else b"keep\n" for path in moved[2]})
merged = package("6", {"opt/a": b"new\n", "opt/z": "opt/a", "opt/new/d": "opt/a",
"opt/new/q": "opt/a", "opt/keep": b"keep\n"})
linked = package("7", {"opt/a": ("symlink", "keep"), "opt/keep": b"keep\n"})
mode_changed = package("8", new[2], mode=0o600)
versions = [old, new, added, moved, split, merged, linked, mode_changed]
def prepare(label, installed=old):
root = tmp / label
root.mkdir()
run("db", "init", "--root", root)
for artifact, _, _, _ in versions:
run("cache", "stage", "local:" + str(artifact), "--root", root)
plan = run("db", "plan-set", installed[1], "--root", root).split(" sha256 ")[1].split()[0]
run("db", "apply-set", plan, installed[1], "--root", root)
return root
def check(root, current, previous=None):
run("db", "check", "--all", "--root", root)
groups = {}
for path, value in current[2].items():
node = root / path
if isinstance(value, tuple):
assert node.is_symlink() and os.readlink(node) == value[1]
continue
target = value if isinstance(value, str) else path
assert node.read_bytes() == current[2][target]
st = node.stat()
assert st.st_mode & 0o777 == current[3]
identity = st.st_dev, st.st_ino
if target in groups:
assert groups[target] == identity, (path, target)
else:
assert identity not in groups.values(), (path, groups)
groups[target] = identity
if previous:
for path in previous[2].keys() - current[2].keys():
assert not os.path.lexists(root / path)
assert not list(root.rglob(".holy-update-*"))
assert not (root / "var/lib/holypkg/transactions/update").exists()
def plan(root, before, after):
record = run("db", "plan-update", before[1], after[1], "--root", root)
if any(isinstance(value, str) for value in after[2].values()):
assert "group-stage " in record
return record.split(" sha256 ")[1].split()[0]
root = prepare("regular")
outside = tmp / "external"
os.link(root / "opt/z", outside)
previous = old
for following in [new, added, moved, split, merged, linked, mode_changed, old]:
approved = plan(root, previous, following)
run("db", "apply-update", approved, previous[1], following[1], "--root", root)
check(root, following, previous)
assert outside.read_bytes() == b"old\n"
previous = following
run("db", "rm", previous[1], "--root", root)
invalid = prepare("unexpected-sharing", split)
(invalid / "opt/z").unlink()
os.link(invalid / "opt/a", invalid / "opt/z")
run("db", "check", "--all", "--root", invalid, status=4)
run("db", "plan-update", split[1], merged[1], "--root", invalid, status=4)
assert (invalid / "opt/a").stat().st_ino == (invalid / "opt/z").stat().st_ino
print("hardlink content, membership, anchor moves, split/merge, modes and downgrade passed")
dynamic = "interpreter /" in run("elf", binary)
if dynamic or os.environ.get("HOLY_TEST_STATIC_UPDATE_FAULT") == "1":
environment = os.environ.copy()
if dynamic:
library = tmp / "fault.so"
subprocess.run(["gcc", "-shared", "-fPIC", "-o", str(library),
str(pathlib.Path(__file__).with_name("update-fault.c")), "-ldl"], check=True)
environment["LD_PRELOAD"] = str(library)
for phase in ("intent-after", "hardlink-before", "hardlink-after", "payload-before", "payload-after",
"database-before", "database-after", "generation-after", "group-cleanup", "committed", "no-space", "staging-partial"):
root = prepare("fault-" + phase)
approved = plan(root, old, new)
environment.update(HOLY_UPDATE_FAULT=phase, HOLY_UPDATE_NEW=new[1])
run("db", "apply-update", approved, old[1], new[1], "--root", root,
env=environment, status=5 if phase == "no-space" else -9)
run("db", "status", "--root", root, status=5)
if phase == "staging-partial":
partial, = root.rglob(".holy-update-*")
before = partial.read_bytes()
run("db", "recover", "--update", "--root", root, status=5)
assert partial.read_bytes() == before and (root / "opt/a").read_bytes() == b"old\n"
partial.unlink()
run("db", "recover", "--update", "--root", root)
check(root, new, old)
for before, after in ((new, moved), (moved, split), (split, merged), (old, mode_changed)):
preview_root = prepare("count-" + before[1][:8], before)
record = run("db", "plan-update", before[1], after[1], "--root", preview_root)
records = [shlex.split(line) for line in record.splitlines()]
steps = sum(1 for i, row in enumerate(records) if row and row[0] == "change" and row[2] != "retain"
and records[i + 1][1] != "dir" and records[i + 2][1] != "dir")
assert steps
for step in range(1, steps + 1):
root = prepare("step-" + before[1][:8] + "-" + str(step), before)
approved = plan(root, before, after)
environment.update(HOLY_UPDATE_FAULT="steps", HOLY_UPDATE_STEP=str(step), HOLY_UPDATE_NEW=after[1])
run("db", "apply-update", approved, before[1], after[1], "--root", root, env=environment, status=-9)
run("db", "recover", "--update", "--root", root)
check(root, after, before)
environment.pop("HOLY_UPDATE_STEP")
for damaged in ("old-group", "witness", "alias-stage"):
root = prepare("damaged-" + damaged)
approved = plan(root, old, new)
environment.update(HOLY_UPDATE_FAULT="hardlink-after" if damaged == "old-group" else "payload-after",
HOLY_UPDATE_NEW=new[1])
run("db", "apply-update", approved, old[1], new[1], "--root", root, env=environment, status=-9)
witness, = root.rglob(".holy-update-*-group")
if damaged == "old-group":
path, source = root / "opt/b", root / "opt/z"
elif damaged == "witness":
path, source = witness, root / "opt/a"
else:
path = next(path for path in root.rglob(".holy-update-*") if not path.name.endswith("-group"))
source = witness
content, mode = path.read_bytes(), path.stat().st_mode & 0o777
path.unlink()
path.write_bytes(content)
path.chmod(mode)
original = [(root / name).read_bytes() for name in ("opt/a", "opt/b", "opt/z")]
run("db", "recover", "--update", "--root", root, status=5)
assert original == [(root / name).read_bytes() for name in ("opt/a", "opt/b", "opt/z")]
assert path.stat().st_ino != source.stat().st_ino
path.unlink()
os.link(source, path)
run("db", "recover", "--update", "--root", root)
check(root, new, old)
print("hardlink update per-file interruption, topology drift and recovery passed")
else:
print("hardlink update fault injection skipped for uninstrumented static client")