mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
368b7bb0f9
commit
9540312034
5 files changed
+51
-8
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user