"""pacman's alpm hooks.
These decide what a plan says will happen around a transaction, so getting them
wrong means telling someone their kernel will not be rebuilt when it will, or
the reverse. Parsed from the real files, never executed.
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from updater import hooks # noqa: E402
SIMPLE = """\
[Trigger]
Operation = Upgrade
Type = Package
Target = linux
[Action]
Description = Updating linux initcpios...
When = PostTransaction
Exec = /usr/bin/mkinitcpio -P
"""
SNAP_PAC = """\
[Trigger]
Operation = Upgrade
Operation = Install
Operation = Remove
Type = Package
Target = *
[Action]
Description = Performing snapper pre snapshots for the following configurations...
Depends = snap-pac
When = PreTransaction
Exec = /usr/share/libalpm/scripts/snap-pac pre
NeedsTargets
AbortOnFail
"""
PATH_TRIGGER = """\
[Trigger]
Type = Path
Operation = Install
Operation = Upgrade
Target = usr/lib/systemd/system/*
[Action]
Description = Reloading system manager configuration...
When = PostTransaction
Exec = /usr/share/libalpm/scripts/systemd-hook daemon-reload
"""
MULTI_TRIGGER = """\
[Trigger]
Operation = Install
Operation = Upgrade
Target = glibc
Type = Package
[Trigger]
Operation = Upgrade
Target = etc/ld.so.conf
Type = Path
[Action]
Description = Configuring dynamic linker run-time bindings...
Exec = /usr/bin/ldconfig -r .
When = PostTransaction
"""
def write(directory: Path, name: str, text: str) -> Path:
path = directory / name
path.write_text(text)
return path
def parse_one(text: str):
with tempfile.TemporaryDirectory() as td:
path = write(Path(td), "x.hook", text)
return hooks._parse(path)
# --------------------------------------------------------------------------
# the things that were wrong
# --------------------------------------------------------------------------
def test_a_bare_flag_does_not_discard_the_whole_hook():
"""`NeedsTargets` has no `=`, and a strict INI reader drops the file."""
hook = parse_one(SNAP_PAC)
assert hook is not None, "a hook with a bare flag was dropped entirely"
assert hook.needs_targets and hook.abort_on_fail
assert hook.exec_.endswith("snap-pac pre")
def test_repeated_operation_keys_are_all_kept():
"""Three `Operation =` lines, not the last one only."""
hook = parse_one(SNAP_PAC)
assert hook is not None
ops = set(hook.triggers[0].operations)
assert ops == {"Upgrade", "Install", "Remove"}, ops
def test_a_hook_can_declare_several_triggers():
hook = parse_one(MULTI_TRIGGER)
assert hook is not None
assert len(hook.triggers) == 2, [t.kind for t in hook.triggers]
assert {t.kind for t in hook.triggers} == {"Package", "Path"}
def test_a_wildcard_trigger_fires_for_any_package():
hook = parse_one(SNAP_PAC)
assert hook.triggers[0].matches_name("anything-at-all")
def test_operations_actually_gate_the_trigger():
"""A Remove-only trigger must not fire for an upgrade."""
hook = parse_one("""\
[Trigger]
Operation = Remove
Type = Package
Target = snap-pac
[Action]
Exec = /bin/true
""")
trigger = hook.triggers[0]
assert trigger.matches_name("snap-pac", "Remove")
assert not trigger.matches_name("snap-pac", "Upgrade")
def test_comments_and_blank_lines_are_ignored():
hook = parse_one("""\
# a leading comment
[Trigger]
# inside
Operation = Upgrade
Type = Package
Target = x
[Action]
# trailing
Exec = /bin/true
""")
assert hook is not None and hook.triggers[0].targets == ("x",)
# --------------------------------------------------------------------------
# matching
# --------------------------------------------------------------------------
def test_a_package_trigger_matches_globs():
hook = parse_one(SIMPLE)
assert hook.triggers[0].matches_name("linux")
assert not hook.triggers[0].matches_name("linux-headers")
def test_a_path_trigger_matches_files_the_package_owns():
hook = parse_one(PATH_TRIGGER)
trigger = hook.triggers[0]
assert trigger.matches_path("/usr/lib/systemd/system/foo.service")
assert trigger.matches_path("usr/lib/systemd/system/foo.service")
assert not trigger.matches_path("/etc/other")
def test_fires_for_consults_both_trigger_kinds():
hook = parse_one(MULTI_TRIGGER)
assert hook.fires_for({"glibc"}, {})
assert hook.fires_for({"unrelated"}, {"unrelated": ["etc/ld.so.conf"]})
assert not hook.fires_for({"unrelated"}, {"unrelated": ["etc/hosts"]})
def test_snapshot_hooks_are_recognised():
assert parse_one(SNAP_PAC).is_snapshot
assert not parse_one(SIMPLE).is_snapshot
def test_a_label_falls_back_to_the_command_then_the_filename():
with_desc = parse_one(SIMPLE)
assert with_desc.label == "Updating linux initcpios"
no_desc = parse_one("""\
[Trigger]
Operation = Upgrade
Type = Package
Target = x
[Action]
Exec = /usr/bin/foo --bar
""")
assert no_desc.label == "foo"
# --------------------------------------------------------------------------
# loading a directory
# --------------------------------------------------------------------------
def test_a_hook_without_a_trigger_is_not_loaded():
assert parse_one("[Action]\nExec = /bin/true\n") is None
def test_a_hook_without_an_action_is_not_loaded():
assert parse_one("[Trigger]\nType = Package\nTarget = x\n") is None
def test_an_unreadable_file_is_skipped_not_fatal():
with tempfile.TemporaryDirectory() as td:
directory = Path(td)
write(directory, "good.hook", SIMPLE)
(directory / "broken.hook").write_bytes(b"\xff\xfe\x00 not utf-8")
loaded = hooks.load((directory,))
assert [h.path.name for h in loaded] == ["good.hook"]
def test_a_later_directory_overrides_by_filename():
"""That is what pacman does: /etc replaces the packaged hook."""
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
write(Path(a), "same.hook", SIMPLE)
write(Path(b), "same.hook", SIMPLE.replace("linux", "other"))
loaded = hooks.load((Path(a), Path(b)))
assert len(loaded) == 1, [h.path for h in loaded]
assert loaded[0].triggers[0].targets == ("other",)
def test_scheduled_splits_before_and_after():
loaded = [parse_one(SNAP_PAC), parse_one(SIMPLE)]
before, after = hooks.scheduled(loaded, {"linux"}, {})
# both are called x.hook, so distinguish them by when they run
assert len(before) == 1 and before[0].when == "PreTransaction"
assert len(after) == 1 and after[0].when == "PostTransaction"
def test_the_real_hooks_on_this_machine_load():
"""A smoke test against whatever is actually installed."""
loaded = hooks.load()
if not loaded:
return # nothing installed; nothing to assert
for hook in loaded:
assert hook.triggers, hook.path
for trigger in hook.triggers:
assert trigger.kind in ("Package", "Path"), trigger.kind
assert trigger.targets
def test_a_linux_upgrade_rebuilds_the_initramfs():
"""The single most useful thing this can get right.
The mkinitcpio hook triggers on the *path* usr/lib/modules/*/vmlinuz, so it
only fires when the file list is supplied. Matching on package names alone
would miss it, which is exactly why the file lists are loaded.
"""
loaded = hooks.load()
if not loaded:
return
paths = {
"linux": [
"usr/lib/modules/6.12.1-arch1-1/vmlinuz",
"usr/lib/modules/6.12.1-arch1-1/modules.dep",
]
}
_, after = hooks.scheduled(loaded, {"linux"}, paths)
labels = " ".join(h.label.lower() for h in after)
assert "initcpio" in labels, f"no initramfs hook fired:\n{labels}"
def test_path_triggers_are_not_matched_on_names_alone():
"""Without file lists the initramfs hook must not be claimed."""
loaded = hooks.load()
if not loaded:
return
_, after = hooks.scheduled(loaded, {"linux"}, {})
labels = " ".join(h.label.lower() for h in after)
assert "initcpio" not in labels, labels
def main() -> int:
tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
failed = 0
for name, fn in tests:
try:
fn()
except Exception as exc: # noqa: BLE001
failed += 1
print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
else:
print(f"ok {name}")
print(f"\n{len(tests) - failed}/{len(tests)} passed")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())