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.
182 lines
6.7 KiB
Python
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"
|