| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent c04c7da commit 807d326
1 file changed
| Original file line number | Diff line number | Diff 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() | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments