fix(pm): accept digests recorded before metadata was skipped

Facts written by earlier releases hashed desktop metadata as payload. Verification now also accepts the include-everything digest, so an unchanged installed tree is not rejected after upgrading.
This commit is contained in:
unsupportedpastels authored and Mark S. committed 2026-11-02 19:05:44 -05:00
1 parent 368b7bb0f9
commit 9540312034
5 files changed
+51 -8

No files matched your search

+2 -2
View File
@@ -158,7 +158,7 @@ def verified_tools(names: Sequence[str], *, source_store: Path, target: str,
from pm.lock import Facts
from pm.package import StatePackage
from pm.registry import walk
from pm.store import tree_digest
from pm.store import digest_matches
if isinstance(names, str):
raise TypeError("names must be a sequence, not a string")
@@ -186,7 +186,7 @@ def verified_tools(names: Sequence[str], *, source_store: Path, target: str,
if (not fact or fact.get("entry") != expected
or not entry.is_dir() or not entry.resolve().is_relative_to(source_store)
or not facts.installed(package.name, version, source_store, identity)
or tree_digest(entry) != fact.get("digest")):
or not digest_matches(entry, fact.get("digest"))):
raise InstallError(package.name, "tool source failed verification", "run preparation again")
_copy_links(package, entry)
binary = package.binary(entry, target)
+2 -2
View File
@@ -425,7 +425,7 @@ def cmd_env(args) -> int:
def cmd_doctor(args) -> int:
from pm.install import _identity, _installed_location
from pm.store import tree_digest
from pm.store import digest_matches
lockfile = _lockfile()
facts = _facts()
@@ -464,7 +464,7 @@ def cmd_doctor(args) -> int:
bad += 1
continue
recorded = fact.get("digest")
if recorded is not None and tree_digest(entry) != recorded:
if recorded is not None and not digest_matches(entry, recorded):
# Doctor is the expensive-path tool: re-hash the realized
# bytes. Boot checks stay O(1) json compares.
print(f"✗ {name}: realized bytes do not match recorded digest")
+3 -3
View File
@@ -20,7 +20,7 @@ from pm.lock import Facts, Lockfile
from pm.package import InstallError, Package, Runner, StatePackage, compose_env
from pm.plugin_inputs import Candidates, Members, PluginInput, Selection, StagedUpdate
from pm.registry import get_package, walk
from pm.store import Store, current_target, merge_tree, tree_digest
from pm.store import Store, current_target, digest_matches, merge_tree, tree_digest
LOG = logging.getLogger(__name__)
@@ -300,7 +300,7 @@ def _entry_verified(package: Package, fact: dict, store: Store, target: str) ->
"""Explicit installs re-check realized bytes; startup keeps its cheap facts check."""
entry = store.entry(fact["entry"])
try:
return not package.verify(entry, target) and fact.get("digest") == tree_digest(entry)
return not package.verify(entry, target) and digest_matches(entry, fact.get("digest"))
except OSError:
return False
@@ -416,7 +416,7 @@ def _copy_verified_source(package, lockfile, copy_from, staged, version, target)
or not _entry_verified(package, source, source_store, target)):
raise InstallError(package.name, "bundled copy source failed verification")
shutil.copytree(source_store.entry(source["entry"]), staged, symlinks=True)
if tree_digest(staged) != source["digest"]:
if not digest_matches(staged, source["digest"]):
raise InstallError(package.name, "copied bytes do not match the bundled source")
+11 -1
View File
@@ -326,6 +326,16 @@ def merge_tree(src: Path, dst: Path) -> None:
def tree_digest(root: Path) -> str:
return _tree_digest(root, skip_metadata=True)
def digest_matches(root: Path, recorded: str | None) -> bool:
"""Facts recorded before desktop metadata was skipped hashed it as payload."""
return recorded is not None and (
tree_digest(root) == recorded or _tree_digest(root, skip_metadata=False) == recorded)
def _tree_digest(root: Path, *, skip_metadata: bool) -> str:
"""Deterministic sha256 over a directory tree: walk every file, sort
by posix relpath, hash `relpath\\0<content>` per entry. No mtimes, no
mode bits. Symlinks contribute their LINK TARGET TEXT (os.readlink),
@@ -350,7 +360,7 @@ def tree_digest(root: Path) -> str:
dirnames[:] = descend
for fname in filenames:
path = Path(dirpath) / fname
if _is_os_metadata(path):
if skip_metadata and _is_os_metadata(path):
continue
files.append((path.relative_to(root).as_posix(), path))
files.sort(key=lambda item: item[0])
+33
View File
@@ -1,5 +1,6 @@
"""Desktop metadata must not hide package payloads or obstruct extraction (#128588)."""
import hashlib
import io
import tarfile
from pathlib import Path
@@ -65,6 +66,38 @@ def test_metadata_named_directories_links_and_executables_are_hashed(tmp_path: P
assert (wrapper / "bin").is_dir()
@pytest.mark.platforms("any")
def test_facts_recorded_with_metadata_in_the_digest_still_verify(tmp_path: Path):
from pm import build_operations
from pm.lock import Facts, Lockfile
from pm.package import InstallError
from pm.registry import get_package
from pm.store import current_target
target, store = current_target(), tmp_path / "tools"
lock = Lockfile(tmp_path / "lock.json")
lock.set_pin("python", "1.0", {target: {"url": "https://invalid.test/python", "sha256": "a" * 64}})
package = get_package("python")
entry = store / package.store_entry("1.0", target)
binary = package.binary(entry, target)
assert binary is not None
binary.parent.mkdir(parents=True)
binary.write_bytes(b"runtime")
(entry / ".DS_Store").write_bytes(b"desktop metadata")
# Earlier releases hashed every regular file, metadata included.
legacy = hashlib.sha256()
for path in sorted((p for p in entry.rglob("*") if p.is_file()), key=lambda p: p.relative_to(entry).as_posix()):
legacy.update(path.relative_to(entry).as_posix().encode() + b"\0" + path.read_bytes())
Facts(store / "facts.json").record("python", "1.0", entry.name, package.env(entry, target), store,
target=target, artifacts=["a" * 64], digest=legacy.hexdigest())
selected = build_operations.verified_tools(["python"], source_store=store, target=target, lock=lock)
assert selected.entries["python"].binary == binary
binary.write_bytes(b"changed runtime")
with pytest.raises(InstallError, match="source failed verification"):
build_operations.verified_tools(["python"], source_store=store, target=target, lock=lock)
@pytest.mark.platforms("any")
def test_metadata_named_payload_collision_refuses_before_any_move(tmp_path: Path):
tree = tmp_path / "tree"