FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

test(project): cover runtime_assets checksum and extraction helpers · pythonnative/pythonnative@807d326 · GitHub

Commit 807d326

Browse files
test(project): cover runtime_assets checksum and extraction helpers
1 parent c04c7da commit 807d326

1 file changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
"""Unit tests for the iOS runtime asset helpers.
2+
3+
These are network-free. The autouse fixture below blocks socket creation
4+
for this module only; it deliberately does not live in tests/conftest.py,
5+
because tests/test_net.py opens a real socket to find a free port.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import hashlib
11+
import io
12+
import socket
13+
import tarfile
14+
from pathlib import Path
15+
from typing import Any, List, Tuple
16+
17+
import pytest
18+
19+
from pythonnative.project import config, runtime_assets
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def _no_network(monkeypatch: pytest.MonkeyPatch) -> None:
24+
"""Fail loudly if anything in this module reaches for the network.
25+
26+
Blocking at the socket layer proves no network, where stubbing a
27+
single ``urlopen`` would only prove that one call site was covered.
28+
"""
29+
30+
def _blocked(*args: Any, **kwargs: Any) -> Any:
31+
raise AssertionError("network access attempted in a network-free test")
32+
33+
monkeypatch.setattr(socket, "socket", _blocked)
34+
monkeypatch.setattr(socket, "create_connection", _blocked)
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# _sha256
39+
# ---------------------------------------------------------------------------
40+
41+
42+
def test_sha256_matches_hashlib(tmp_path: Path) -> None:
43+
payload = b"pythonnative embedded runtime asset\n"
44+
path = tmp_path / "asset.bin"
45+
path.write_bytes(payload)
46+
47+
assert runtime_assets._sha256(path) == hashlib.sha256(payload).hexdigest()
48+
49+
50+
def test_sha256_reads_across_chunk_boundaries(tmp_path: Path) -> None:
51+
# _sha256 reads in 1 MiB chunks. A payload larger than one chunk makes
52+
# the read loop iterate more than once, which a short string never does.
53+
payload = b"pn" * 1_500_000 # ~2.9 MiB, spanning three chunks
54+
path = tmp_path / "big.bin"
55+
path.write_bytes(payload)
56+
57+
assert len(payload) > 2 * 1024 * 1024
58+
assert runtime_assets._sha256(path) == hashlib.sha256(payload).hexdigest()
59+
60+
61+
# ---------------------------------------------------------------------------
62+
# _safe_extract
63+
# ---------------------------------------------------------------------------
64+
65+
66+
class _NoFilterTar:
67+
"""Wrap a TarFile so ``extractall(filter=...)`` raises TypeError.
68+
69+
``filter="data"`` landed in 3.12 and was backported to 3.10.12 and
70+
3.11.4, so CI (which runs current patch releases) always takes the
71+
filter path. Without this shim the manual checks in ``_safe_extract``
72+
are never exercised, and they are the only protection an older
73+
interpreter in the supported range has.
74+
"""
75+
76+
def __init__(self, inner: tarfile.TarFile) -> None:
77+
self._inner = inner
78+
79+
def __enter__(self) -> "_NoFilterTar":
80+
self._inner.__enter__()
81+
return self
82+
83+
def __exit__(self, *exc: Any) -> Any:
84+
return self._inner.__exit__(*exc)
85+
86+
def getmembers(self) -> List[tarfile.TarInfo]:
87+
return self._inner.getmembers()
88+
89+
def extractall(self, *args: Any, **kwargs: Any) -> Any:
90+
if "filter" in kwargs:
91+
raise TypeError("extractall() got an unexpected keyword argument 'filter'")
92+
return self._inner.extractall(*args, **kwargs)
93+
94+
95+
@pytest.fixture
96+
def force_no_filter(monkeypatch: pytest.MonkeyPatch) -> None:
97+
"""Make _safe_extract take its pre-3.10.12 fallback path."""
98+
real_open = tarfile.open
99+
100+
def _open(*args: Any, **kwargs: Any) -> Any:
101+
# runtime_assets.tarfile is the shared module, so this patch is
102+
# visible to the tests' own tarball writing too. Wrap read mode
103+
# only, which is the single call _safe_extract makes.
104+
mode = kwargs.get("mode", args[1] if len(args) > 1 else "r")
105+
opened = real_open(*args, **kwargs)
106+
return _NoFilterTar(opened) if str(mode).startswith("r") else opened
107+
108+
monkeypatch.setattr(runtime_assets.tarfile, "open", _open)
109+
110+
111+
def _workspace(tmp_path: Path) -> Tuple[Path, Path]:
112+
"""Return (root, dest). The dest basename is deliberately ``out``.
113+
114+
Sibling paths like ``out-evil`` and ``outsider.txt`` share that string
115+
prefix, which is what a ``startswith`` containment test fails to catch.
116+
"""
117+
root = tmp_path / "work"
118+
dest = root / "out"
119+
dest.mkdir(parents=True)
120+
return root, dest
121+
122+
123+
def _add_file(tar: tarfile.TarFile, name: str, data: bytes = b"payload\n") -> None:
124+
info = tarfile.TarInfo(name)
125+
info.size = len(data)
126+
tar.addfile(info, io.BytesIO(data))
127+
128+
129+
def _add_link(tar: tarfile.TarFile, name: str, linkname: str, *, hard: bool = False) -> None:
130+
info = tarfile.TarInfo(name)
131+
info.type = tarfile.LNKTYPE if hard else tarfile.SYMTYPE
132+
info.linkname = linkname
133+
tar.addfile(info)
134+
135+
136+
def _add_special(tar: tarfile.TarFile, name: str, kind: str) -> None:
137+
info = tarfile.TarInfo(name)
138+
info.type = {"fifo": tarfile.FIFOTYPE, "chr": tarfile.CHRTYPE, "blk": tarfile.BLKTYPE}[kind]
139+
info.mode = 0o644
140+
if kind != "fifo":
141+
info.devmajor, info.devminor = (1, 3) if kind == "chr" else (8, 0)
142+
tar.addfile(info)
143+
144+
145+
def _files_outside(root: Path, dest: Path) -> List[str]:
146+
"""Every regular file under root that is not inside dest, tarballs aside."""
147+
return sorted(
148+
str(path.relative_to(root))
149+
for path in root.rglob("*")
150+
if path.is_file() and not path.is_relative_to(dest) and path.suffix != ".gz"
151+
)
152+
153+
154+
ESCAPING_MEMBERS = [
155+
pytest.param("../escape.txt", id="parent-dir"),
156+
pytest.param("../outsider.txt", id="sibling-file-sharing-prefix"),
157+
pytest.param("../out-evil/x.txt", id="sibling-dir-sharing-prefix"),
158+
]
159+
160+
161+
def test_safe_extract_extracts_a_normal_member(tmp_path: Path) -> None:
162+
root, dest = _workspace(tmp_path)
163+
tar_path = root / "a.tar.gz"
164+
with tarfile.open(tar_path, "w:gz") as tar:
165+
_add_file(tar, "nested/dir/normal.txt", b"hello\n")
166+
167+
runtime_assets._safe_extract(tar_path, dest)
168+
169+
assert (dest / "nested" / "dir" / "normal.txt").read_text(encoding="utf-8") == "hello\n"
170+
assert _files_outside(root, dest) == []
171+
172+
173+
def _assert_member_refused(tmp_path: Path, member: str) -> None:
174+
"""The member is refused by the manual check, and nothing escapes dest.
175+
176+
Matching on RuntimeError is deliberate: it is what the manual check
177+
raises. If that check stopped catching a member, the ``filter="data"``
178+
layer would still block it on a current interpreter, but as
179+
``tarfile.OutsideDestinationError``, so this assertion pins which layer
180+
did the work.
181+
"""
182+
root, dest = _workspace(tmp_path)
183+
tar_path = root / "a.tar.gz"
184+
with tarfile.open(tar_path, "w:gz") as tar:
185+
_add_file(tar, member)
186+
187+
with pytest.raises(RuntimeError, match="Refusing to extract unsafe path"):
188+
runtime_assets._safe_extract(tar_path, dest)
189+
assert _files_outside(root, dest) == []
190+
191+
192+
@pytest.mark.parametrize("member", ESCAPING_MEMBERS)
193+
def test_safe_extract_refuses_escaping_members(tmp_path: Path, member: str) -> None:
194+
_assert_member_refused(tmp_path, member)
195+
196+
197+
@pytest.mark.parametrize("member", ESCAPING_MEMBERS)
198+
def test_safe_extract_refuses_escaping_members_without_the_filter(
199+
tmp_path: Path, member: str, force_no_filter: None
200+
) -> None:
201+
# Same members with extractall's filter unavailable, so the manual
202+
# checks are the only thing between the archive and the disk.
203+
_assert_member_refused(tmp_path, member)
204+
205+
206+
def test_safe_extract_refuses_a_symlink_that_escapes_via_linkname(tmp_path: Path, force_no_filter: None) -> None:
207+
# Both member names resolve inside dest; only the link target escapes.
208+
# Extracting the pair writes through the symlink, outside dest.
209+
root, dest = _workspace(tmp_path)
210+
(root / "outside_target").mkdir()
211+
tar_path = root / "a.tar.gz"
212+
with tarfile.open(tar_path, "w:gz") as tar:
213+
_add_link(tar, "escape_dir", "../outside_target")
214+
_add_file(tar, "escape_dir/payload.txt", b"pwned\n")
215+
216+
with pytest.raises(RuntimeError, match="Refusing to extract unsafe link"):
217+
runtime_assets._safe_extract(tar_path, dest)
218+
assert _files_outside(root, dest) == []
219+
assert list((root / "outside_target").iterdir()) == []
220+
221+
222+
def test_safe_extract_refuses_a_hardlink_that_escapes_via_linkname(tmp_path: Path, force_no_filter: None) -> None:
223+
root, dest = _workspace(tmp_path)
224+
tar_path = root / "a.tar.gz"
225+
with tarfile.open(tar_path, "w:gz") as tar:
226+
_add_link(tar, "hard", "../../secret.txt", hard=True)
227+
228+
with pytest.raises(RuntimeError, match="Refusing to extract unsafe link"):
229+
runtime_assets._safe_extract(tar_path, dest)
230+
231+
232+
def test_safe_extract_allows_a_symlink_that_stays_inside(tmp_path: Path, force_no_filter: None) -> None:
233+
# Guards against over-blocking: a link pointing within dest is fine.
234+
root, dest = _workspace(tmp_path)
235+
tar_path = root / "a.tar.gz"
236+
with tarfile.open(tar_path, "w:gz") as tar:
237+
_add_file(tar, "subdir/real.txt", b"ok\n")
238+
_add_link(tar, "subdir/alias.txt", "real.txt")
239+
240+
runtime_assets._safe_extract(tar_path, dest)
241+
242+
assert (dest / "subdir" / "alias.txt").is_symlink()
243+
assert (dest / "subdir" / "alias.txt").read_text(encoding="utf-8") == "ok\n"
244+
245+
246+
@pytest.mark.parametrize(
247+
"kind",
248+
[
249+
pytest.param("fifo", id="fifo"),
250+
pytest.param("chr", id="character-device"),
251+
pytest.param("blk", id="block-device"),
252+
],
253+
)
254+
def test_safe_extract_refuses_special_files(tmp_path: Path, kind: str, force_no_filter: None) -> None:
255+
# filter="data" raises SpecialFileError for these. Without it the
256+
# fallback creates the FIFO outright and reaches mknod for the device
257+
# nodes, which fail only for lack of privilege.
258+
root, dest = _workspace(tmp_path)
259+
tar_path = root / "a.tar.gz"
260+
with tarfile.open(tar_path, "w:gz") as tar:
261+
_add_special(tar, "special_member", kind)
262+
263+
with pytest.raises(RuntimeError, match="Refusing to extract special file"):
264+
runtime_assets._safe_extract(tar_path, dest)
265+
assert list(dest.iterdir()) == []
266+
267+
268+
# ---------------------------------------------------------------------------
269+
# _locate_runtime
270+
# ---------------------------------------------------------------------------
271+
272+
273+
def _xcframework(root: Path, *, with_utils: bool = True) -> Path:
274+
xcframework = root / "Python.xcframework"
275+
(xcframework / "build").mkdir(parents=True)
276+
if with_utils:
277+
(xcframework / "build" / "utils.sh").write_text("install_python() { :; }\n", encoding="utf-8")
278+
return xcframework
279+
280+
281+
def test_locate_runtime_raises_without_the_xcframework(tmp_path: Path) -> None:
282+
with pytest.raises(RuntimeError, match="Python.xcframework not found"):
283+
runtime_assets._locate_runtime(tmp_path, "3.12")
284+
285+
286+
def test_locate_runtime_raises_without_utils_sh(tmp_path: Path) -> None:
287+
_xcframework(tmp_path, with_utils=False)
288+
289+
with pytest.raises(RuntimeError, match="missing build/utils.sh"):
290+
runtime_assets._locate_runtime(tmp_path, "3.12")
291+
292+
293+
def test_locate_runtime_returns_a_runtime_when_both_exist(tmp_path: Path) -> None:
294+
xcframework = _xcframework(tmp_path)
295+
296+
runtime = runtime_assets._locate_runtime(tmp_path, "3.11")
297+
298+
assert runtime.python_version == "3.11"
299+
assert runtime.xcframework_dir == xcframework
300+
assert runtime.install_script == xcframework / "build" / "utils.sh"
301+
assert runtime.install_script.is_file()
302+
303+
304+
# ---------------------------------------------------------------------------
305+
# PINNED_ASSETS
306+
# ---------------------------------------------------------------------------
307+
308+
309+
def test_pinned_assets_and_supported_versions_agree() -> None:
310+
# Both modules document this invariant in prose but nothing enforced it.
311+
# The drift this catches: adding a version to SUPPORTED_PYTHON_VERSIONS
312+
# without pinning an asset makes `pn run ios` accept the config, then
313+
# fail deep in prepare_ios_runtime with "No pinned iOS runtime".
314+
assert set(runtime_assets.PINNED_ASSETS) == set(config.SUPPORTED_PYTHON_VERSIONS)
315+
316+
317+
def test_pinned_assets_entries_are_well_formed() -> None:
318+
for version, entry in runtime_assets.PINNED_ASSETS.items():
319+
tag, asset_name, expected_sha = entry
320+
assert version in tag, f"{version}: tag {tag!r} should name the version"
321+
assert asset_name.endswith(".tar.gz"), asset_name
322+
assert len(expected_sha) == 64 and set(expected_sha) <= set("0123456789abcdef"), expected_sha
323+
324+
325+
# ---------------------------------------------------------------------------
326+
# prepare_ios_runtime
327+
# ---------------------------------------------------------------------------
328+
329+
330+
def test_prepare_ios_runtime_rejects_an_unpinned_version(tmp_path: Path) -> None:
331+
cache = tmp_path / "ios_runtime"
332+
333+
with pytest.raises(RuntimeError) as excinfo:
334+
runtime_assets.prepare_ios_runtime(cache, "2.7")
335+
336+
message = str(excinfo.value)
337+
assert "No pinned iOS runtime for Python 2.7" in message
338+
for version in config.SUPPORTED_PYTHON_VERSIONS:
339+
assert version in message
340+
# cache_dir.mkdir runs before the version check, so the directory exists
341+
# even on the failure path. Asserted so a future reorder is deliberate.
342+
assert cache.is_dir()
343+
344+
345+
def test_prepare_ios_runtime_returns_the_cached_extraction(tmp_path: Path) -> None:
346+
cache = tmp_path / "ios_runtime"
347+
extract_root = cache / "python-3.12"
348+
extract_root.mkdir(parents=True)
349+
xcframework = _xcframework(extract_root)
350+
messages: List[str] = []
351+
352+
runtime = runtime_assets.prepare_ios_runtime(cache, "3.12", log=messages.append)
353+
354+
assert runtime.python_version == "3.12"
355+
assert runtime.xcframework_dir == xcframework
356+
assert runtime.install_script.is_file()
357+
# Both the download and the extraction branches emit; silence proves
358+
# neither ran, which is what "cached" has to mean.
359+
assert messages == []
360+
assert sorted(path.name for path in cache.iterdir()) == ["python-3.12"]
361+
362+
363+
def test_prepare_ios_runtime_refetches_a_stale_extraction(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
364+
# Python.xcframework exists but build/utils.sh does not, so _locate_runtime
365+
# raises and the cached branch falls through to the download.
366+
cache = tmp_path / "ios_runtime"
367+
extract_root = cache / "python-3.12"
368+
extract_root.mkdir(parents=True)
369+
_xcframework(extract_root, with_utils=False)
370+
371+
attempted: List[str] = []
372+
373+
def _fake_urlopen(request: Any, *args: Any, **kwargs: Any) -> Any:
374+
attempted.append(request.full_url)
375+
raise OSError("no network in tests")
376+
377+
monkeypatch.setattr(runtime_assets.urllib.request, "urlopen", _fake_urlopen)
378+
379+
with pytest.raises(RuntimeError, match="Could not download the iOS Python runtime"):
380+
runtime_assets.prepare_ios_runtime(cache, "3.12")
381+
382+
assert len(attempted) == 1
383+
assert attempted[0].endswith(runtime_assets.PINNED_ASSETS["3.12"][1])
384+
385+
386+
def test_no_network_fixture_actually_blocks(tmp_path: Path) -> None:
387+
# Proves the guard above is live, rather than trusting it.
388+
with pytest.raises(AssertionError, match="network access attempted"):
389+
socket.socket()

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL