Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions sdk/python/tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
Run: pytest sdk/python/tests -q
"""
from __future__ import annotations
import hashlib
import io
import sys
import tarfile
import zipfile
from pathlib import Path

import pytest
Expand Down Expand Up @@ -148,3 +152,83 @@ def test_channel_resolution():
tf.Fortress(channel="nope"); assert False
except ValueError:
pass


# --------------------------------------------------------------------------- launcher lookup
def _craft_tarball(files: dict[str, bytes]) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as t:
for name, data in files.items():
info = tarfile.TarInfo(name)
info.size = len(data)
info.mode = 0o755 if name.endswith(("tilion", "tilion.cmd")) else 0o644
t.addfile(info, io.BytesIO(data))
return buf.getvalue()


def _craft_zip(files: dict[str, bytes]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w") as z:
for name, data in files.items():
z.writestr(name, data)
return buf.getvalue()


def _serve_bundle(monkeypatch, payload: bytes):
"""Point _download at a fake release serving `payload`; verify its real hash."""
monkeypatch.setattr(tf, "_CACHE", Path("CACHE-NOT-USED"))
digest = hashlib.sha256(payload).hexdigest()

def fake_urlretrieve(url, filename):
Path(filename).write_bytes(payload)

monkeypatch.setattr(tf.urllib.request, "urlretrieve", fake_urlretrieve)
monkeypatch.setattr(tf, "_expected_sha", lambda asset, host: digest)


def test_download_finds_expected_launcher(tmp_path, monkeypatch, capsys):
_serve_bundle(monkeypatch, _craft_tarball({"tilion-fortress/tilion": b"#!/bin/sh\n"}))
monkeypatch.setattr(tf, "_CACHE", tmp_path)
launcher = tf._download("linux-x64", "https://h", "vX")
assert launcher == tmp_path / "vX" / "linux-x64" / "tilion-fortress" / "tilion"
assert "SHA256 verified" in capsys.readouterr().err
# The archive is removed only after the launcher resolved.
assert list((tmp_path / "vX" / "linux-x64").glob("*.tar.gz")) == []


def test_download_recovers_from_renamed_top_dir(tmp_path, monkeypatch):
# Issue #34: a SHA-verified bundle whose top directory is not
# `tilion-fortress/` must still resolve instead of raising.
_serve_bundle(monkeypatch, _craft_tarball({"renamed-dir/tilion": b"#!/bin/sh\n"}))
monkeypatch.setattr(tf, "_CACHE", tmp_path)
launcher = tf._download("linux-x64", "https://h", "vX")
assert launcher == tmp_path / "vX" / "linux-x64" / "renamed-dir" / "tilion"


def test_download_without_launcher_reports_contents(tmp_path, monkeypatch):
_serve_bundle(monkeypatch, _craft_tarball({"something-else/chrome": b"ELF"}))
monkeypatch.setattr(tf, "_CACHE", tmp_path)
with pytest.raises(RuntimeError, match=r"launcher missing.*top level holds: \['something-else'\]"):
tf._download("linux-x64", "https://h", "vX")
# Keep the archive so the layout can be inspected instead of re-downloaded.
assert list((tmp_path / "vX" / "linux-x64").glob("*.tar.gz")) != []


def test_download_zip_launcher(tmp_path, monkeypatch):
_serve_bundle(monkeypatch, _craft_zip({"tilion-fortress/tilion.cmd": b"@echo off\n"}))
monkeypatch.setattr(tf, "_CACHE", tmp_path)
launcher = tf._download("win-x64", "https://h", "vX")
assert launcher == tmp_path / "vX" / "win-x64" / "tilion-fortress" / "tilion.cmd"


def test_download_uses_cached_launcher_without_network(tmp_path, monkeypatch):
monkeypatch.setattr(tf, "_CACHE", tmp_path)
cached = tmp_path / "vX" / "linux-x64" / "tilion-fortress" / "tilion"
cached.parent.mkdir(parents=True)
cached.write_bytes(b"#!/bin/sh\n")

def no_network(*a, **k):
raise AssertionError("must not download when the launcher is cached")

monkeypatch.setattr(tf.urllib.request, "urlretrieve", no_network)
assert tf._download("linux-x64", "https://h", "vX") == cached
39 changes: 35 additions & 4 deletions sdk/python/tilion_fortress/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@ def _expected_sha(asset: str, host: str) -> str | None:
return None


def _resolve_launcher(root: Path, plat: str) -> Path:
"""Return the launcher path for `plat` inside the extracted `root`.

The expected relative path comes from _ASSETS. Release bundles have
arrived with a different top directory before (issue #34: a SHA-verified
download extracts fine but the launcher is not where the SDK looks), so
fall back to searching the extracted tree for the launcher filename
before giving up. When nothing is found the error lists what extraction
actually produced, which is what such a report needs to be actionable.
"""
_, _, launcher_rel = _ASSETS[plat]
expected = root / launcher_rel
if expected.is_file():
if not expected.name.endswith(".cmd"):
expected.chmod(0o755)
return expected
name = Path(launcher_rel).name
found = sorted(p for p in root.rglob(name) if p.is_file())
if len(found) == 1:
if not found[0].name.endswith(".cmd"):
found[0].chmod(0o755)
return found[0]
asset = _ASSETS[plat][0]
if root.is_dir():
top = sorted(p.name for p in root.iterdir() if p.name != asset)
else:
top = []
raise RuntimeError(
f"bundle extracted but launcher missing: expected {expected} "
f"(and no unambiguous {name!r} elsewhere in the bundle); "
f"top level holds: {top or 'nothing'}"
)


def _download(plat: str, host: str, tag: str) -> Path:
"""Ensure the bundle for `plat` is present + verified; return the launcher path."""
asset, kind, launcher_rel = _ASSETS[plat]
Expand Down Expand Up @@ -105,11 +139,8 @@ def _download(plat: str, host: str, tag: str) -> Path:
else:
with zipfile.ZipFile(archive) as z:
z.extractall(root)
launcher = _resolve_launcher(root, plat)
archive.unlink(missing_ok=True)
if launcher.exists() and not launcher.name.endswith(".cmd"):
launcher.chmod(0o755)
if not launcher.exists():
raise RuntimeError(f"bundle extracted but launcher missing: {launcher}")
return launcher


Expand Down