Files
hermes-agent/tests/pm/test_zip_symlinks.py
ethernet 1c8fae6180 fix(pm): preserve runtime and user state across failure paths
Keep downloads bound to their remote representation and publish through
atomic destination-local staging. Serialize shared partial ownership.

Keep explicit CA trust scoped to provider probes. Preserve checkpoint
history and edited files, validate all profile inputs before dependency
publication, and separate data removal from installed runtime ownership.

Exclude machine-specific PM state from portable transfers. Keep plugin
files and nested skill tools intact. Preserve native test isolation.

Focused native Windows receipts cover the individual repairs and their
integration. This commit does not claim a full-suite or release build.
2026-09-09 15:17:08 -04:00

182 lines
6.7 KiB
Python

"""Zip extraction must preserve symlinks — and refuse hostile ones.
Mac chromium .framework zips carry a layout that is mostly symlinks
(Versions/Current, Resources, ...). zipfile.extract() writes a symlink
entry's TARGET PATH out as a regular file, which destroys the bundle:
codesign then refuses even a fresh re-sign. So pm's _extract_zip
recreates link entries with os.symlink — but only the safe ones, and
only after every regular file has landed.
A zip symlink entry is a file whose content is the target path and
whose external_attr mode says S_ISLNK. The fixtures here build REAL
entries of that shape, the same bytes a hostile server would send.
Ported from restack's provisioner-zip-symlinks suite, rewritten against
pm.store.extract. Containment arms (hostile links skipped, zip-slip
ordering) are host-independent and run on Windows; the arms asserting
links RESOLVE are POSIX-gated; a separate arm pins pm's truthful win32
degradation (symlink if the host allows it, else a text placeholder —
never an escape).
"""
from __future__ import annotations
import io
import stat
import sys
import zipfile
from pathlib import Path
import pytest
from pm.store import extract
def _add_symlink(zf: zipfile.ZipFile, member: str, target: str) -> None:
"""Append a symlink entry the way zip tools actually encode one."""
info = zipfile.ZipInfo(member)
info.external_attr = (stat.S_IFLNK | 0o755) << 16
zf.writestr(info, target)
def _add_file(
zf: zipfile.ZipFile, member: str, data: bytes = b"x", mode: int = 0o644
) -> None:
info = zipfile.ZipInfo(member)
info.external_attr = (stat.S_IFREG | mode) << 16
zf.writestr(info, data)
def _write_zip(path: Path, build) -> Path:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
build(zf)
path.write_bytes(buf.getvalue())
return path
@pytest.mark.platforms("posix")
def test_framework_shaped_links_round_trip(tmp_path: Path) -> None:
"""The CfT .framework shape: links out of the versioned dir, intact."""
def build(zf: zipfile.ZipFile) -> None:
_add_file(zf, "app/F.framework/Versions/A/F", b"machO", mode=0o755)
_add_file(zf, "app/F.framework/Versions/A/Resources/Info.plist", b"<plist/>")
_add_symlink(zf, "app/F.framework/Versions/Current", "A")
_add_symlink(zf, "app/F.framework/Resources", "Versions/Current/Resources")
_add_symlink(zf, "app/F.framework/F", "Versions/Current/F")
dest = tmp_path / "out"
extract(_write_zip(tmp_path / "a.zip", build), dest)
current = dest / "app/F.framework/Versions/Current"
assert current.is_symlink() and current.readlink() == Path("A")
resources = dest / "app/F.framework/Resources"
assert resources.is_symlink()
# The links RESOLVE: the whole point of preserving them.
assert (resources / "Info.plist").read_bytes() == b"<plist/>"
assert (dest / "app/F.framework/F").read_bytes() == b"machO"
# And the binary kept its exec bit through the same pass.
assert (dest / "app/F.framework/Versions/A/F").stat().st_mode & 0o111
@pytest.mark.platforms("posix")
def test_intree_dotdot_target_is_kept(tmp_path: Path) -> None:
"""`..` in a target is fine while it stays under dest — real layouts
(lib/foo -> ../share/foo) depend on it."""
def build(zf: zipfile.ZipFile) -> None:
_add_file(zf, "share/data.txt", b"d")
_add_symlink(zf, "lib/data", "../share/data.txt")
dest = tmp_path / "out"
extract(_write_zip(tmp_path / "a.zip", build), dest)
link = dest / "lib/data"
assert link.is_symlink() and link.read_bytes() == b"d"
@pytest.mark.parametrize(
("member", "target"),
[
("lib/evil", "../../../victim"), # escapes via target walk-up
("lib/evil", "/etc/passwd"), # absolute target
("../evil", "whatever"), # link path itself escapes
],
)
def test_hostile_links_are_skipped_not_written(
tmp_path: Path, member: str, target: str
) -> None:
"""Containment is host-independent: pm decides to skip a hostile
link BEFORE any symlink creation, so this must hold on Windows too."""
def build(zf: zipfile.ZipFile) -> None:
_add_file(zf, "lib/ok.txt", b"ok")
_add_symlink(zf, member, target)
dest = tmp_path / "deep" / "out"
extract(_write_zip(tmp_path / "a.zip", build), dest)
# The good entry landed; the hostile link exists NOWHERE — not under
# dest, not at the escape destination.
assert (dest / "lib/ok.txt").read_bytes() == b"ok"
assert not (dest / "lib/evil").exists() and not (dest / "lib/evil").is_symlink()
assert not (tmp_path / "victim").exists()
assert not (tmp_path / "evil").exists()
def test_file_written_through_earlier_link_entry_cannot_escape(
tmp_path: Path,
) -> None:
"""Zip-slip ordering: a link entry followed by a file entry routed
through it. Links are recreated last, so the file lands as a real
path and nothing is written outside dest. Host-independent."""
def build(zf: zipfile.ZipFile) -> None:
_add_symlink(zf, "outdir", "../..")
_add_file(zf, "outdir/pwned.txt", b"pwned")
dest = tmp_path / "deep" / "out"
extract(_write_zip(tmp_path / "a.zip", build), dest)
assert not (tmp_path / "pwned.txt").exists()
written = dest / "outdir" / "pwned.txt"
assert written.is_file() and not (dest / "outdir").is_symlink()
@pytest.mark.platforms("windows")
def test_win32_safe_link_degrades_without_escaping(tmp_path: Path) -> None:
"""On win32 pm attempts a real symlink (works with Developer Mode /
admin) and otherwise degrades to a text placeholder holding the
target path. Either way the entry stays inside dest — pin exactly
that, without faking the host."""
def build(zf: zipfile.ZipFile) -> None:
_add_file(zf, "share/data.txt", b"d")
_add_symlink(zf, "lib/data", "../share/data.txt")
dest = tmp_path / "out"
extract(_write_zip(tmp_path / "a.zip", build), dest)
link = dest / "lib" / "data"
if link.is_symlink():
assert link.readlink() == Path("../share/data.txt")
else:
assert link.is_file()
assert link.read_text(encoding="utf-8") == "../share/data.txt"
# Nothing anywhere else.
assert set(p.name for p in (dest / "lib").iterdir()) == {"data"}
def test_plain_zip_unchanged(tmp_path: Path) -> None:
"""No links: behavior identical to before, exec bits included."""
def build(zf: zipfile.ZipFile) -> None:
_add_file(zf, "bin/tool", b"#!", mode=0o755)
_add_file(zf, "README", b"r")
dest = tmp_path / "out"
extract(_write_zip(tmp_path / "a.zip", build), dest)
if sys.platform != "win32":
assert (dest / "bin/tool").stat().st_mode & 0o111
assert (dest / "bin/tool").read_bytes() == b"#!"
assert (dest / "README").read_bytes() == b"r"