Skip to content
Closed
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
81 changes: 78 additions & 3 deletions tests/tools/test_tirith_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
from pathlib import Path
import subprocess
import time
from unittest.mock import MagicMock, patch
Expand Down Expand Up @@ -618,6 +619,35 @@ def test_install_proceeds_when_cosign_passes(self, mock_target, mock_dl,
assert mock_checksum.called # reached SHA-256 step
assert mock_cosign.called # cosign was invoked

@patch("tools.tirith_security.os.chmod")
@patch("tools.tirith_security.shutil.move")
@patch("tools.tirith_security._verify_checksum", return_value=True)
@patch("tools.tirith_security.shutil.which", return_value=None)
@patch("tools.tirith_security._download_file")
@patch("tools.tirith_security._detect_target", return_value="x86_64-pc-windows-msvc")
def test_install_windows_uses_zip_and_installs_exe(self, mock_target, mock_dl,
mock_which, mock_checksum,
mock_move, mock_chmod):
"""Windows auto-install downloads .zip and installs tirith.exe."""
from tools.tirith_security import _install_tirith

mock_zip = MagicMock()
mock_zip.__enter__ = MagicMock(return_value=mock_zip)
mock_zip.__exit__ = MagicMock(return_value=False)
mock_zip.namelist.return_value = ["nested/path/tirith.exe"]

with patch("zipfile.ZipFile", return_value=mock_zip), \
patch("tools.tirith_security._hermes_bin_dir", return_value="C:\\fake-bin"):
path, reason = _install_tirith()

assert reason == ""
assert path == os.path.join("C:\\fake-bin", "tirith.exe")
assert mock_dl.call_args_list[0].args[0].endswith(".zip")
moved_src, moved_dest = mock_move.call_args.args
assert os.path.normpath(moved_src).endswith(os.path.normpath(os.path.join("nested", "path", "tirith.exe")))
assert moved_dest == os.path.join("C:\\fake-bin", "tirith.exe")
mock_chmod.assert_not_called()


# ---------------------------------------------------------------------------
# Background install / non-blocking startup (P2)
Expand Down Expand Up @@ -692,6 +722,27 @@ def test_resolve_picks_up_background_result(self):

_tirith_mod._resolved_path = None

def test_background_install_picks_up_windows_hermes_bin_exe(self):
"""Background install re-check finds tirith.exe on Windows."""
from tools.tirith_security import _background_install
import tempfile

tmpdir = tempfile.mkdtemp()
hermes_bin = os.path.join(tmpdir, "tirith.exe")
with open(hermes_bin, "w", encoding="utf-8") as f:
f.write("fake exe")

_tirith_mod._resolved_path = None
_tirith_mod._install_failure_reason = ""

with patch("tools.tirith_security.platform.system", return_value="Windows"), \
patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value=tmpdir):
_background_install(log_failures=False)

assert _tirith_mod._resolved_path == hermes_bin
_tirith_mod._resolved_path = None


# ---------------------------------------------------------------------------
# Disk failure marker persistence (P2)
Expand Down Expand Up @@ -849,6 +900,29 @@ def test_install_failed_recovers_from_hermes_bin(self):

_tirith_mod._resolved_path = None

def test_install_failed_recovers_from_windows_hermes_bin_exe(self):
"""Windows installs under HERMES_HOME/bin/tirith.exe are picked up."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
import tempfile

tmpdir = tempfile.mkdtemp()
hermes_bin = os.path.join(tmpdir, "tirith.exe")
with open(hermes_bin, "w", encoding="utf-8") as f:
f.write("fake exe")

_tirith_mod._resolved_path = _INSTALL_FAILED

with patch("tools.tirith_security.platform.system", return_value="Windows"), \
patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value=tmpdir), \
patch("tools.tirith_security._clear_install_failed") as mock_clear:
result = _resolve_tirith_path("tirith")
assert result == hermes_bin
assert _tirith_mod._resolved_path == hermes_bin
mock_clear.assert_called_once()

_tirith_mod._resolved_path = None

def test_install_failed_skips_network_when_local_absent(self):
"""After _INSTALL_FAILED, if local checks fail, network is NOT retried."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
Expand Down Expand Up @@ -988,7 +1062,7 @@ def test_failure_marker_respects_hermes_home(self):
from tools.tirith_security import _failure_marker_path
with patch.dict(os.environ, {"HERMES_HOME": "/custom/hermes"}):
result = _failure_marker_path()
assert result == "/custom/hermes/.tirith-install-failed"
assert os.path.normpath(result) == os.path.normpath(os.path.join("/custom/hermes", ".tirith-install-failed"))

def test_conftest_isolation_prevents_real_home_writes(self):
"""The conftest autouse fixture sets HERMES_HOME; verify it's active."""
Expand All @@ -999,8 +1073,9 @@ def test_conftest_isolation_prevents_real_home_writes(self):
def test_get_hermes_home_fallback(self):
"""Without HERMES_HOME set, falls back to ~/.hermes."""
from tools.tirith_security import _get_hermes_home
with patch.dict(os.environ, {}, clear=True):
with patch.dict(os.environ, {}, clear=True), \
patch("hermes_constants.Path.home", return_value=Path("C:/Users/tester")):
# Remove HERMES_HOME entirely
os.environ.pop("HERMES_HOME", None)
result = _get_hermes_home()
assert result == os.path.join(os.path.expanduser("~"), ".hermes")
assert os.path.normpath(result) == os.path.normpath(os.path.join("C:/Users/tester", ".hermes"))
80 changes: 60 additions & 20 deletions tools/tirith_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,26 @@ def _hermes_bin_dir() -> str:
return d


def _bundled_tirith_candidates() -> list[str]:
"""Return candidate installed binary paths under $HERMES_HOME/bin."""
bin_dir = _hermes_bin_dir()
if platform.system() == "Windows":
return [
os.path.join(bin_dir, "tirith.exe"),
os.path.join(bin_dir, "tirith"),
]
return [os.path.join(bin_dir, "tirith")]


def _find_bundled_tirith() -> str | None:
"""Return an installed tirith path from $HERMES_HOME/bin if present."""
is_windows = platform.system() == "Windows"
for candidate in _bundled_tirith_candidates():
if os.path.isfile(candidate) and (is_windows or os.access(candidate, os.X_OK)):
return candidate
return None


def _detect_target() -> str | None:
"""Return the Rust target triple for the current platform, or None."""
system = platform.system()
Expand All @@ -190,6 +210,8 @@ def _detect_target() -> str | None:
plat = "apple-darwin"
elif system == "Linux":
plat = "unknown-linux-gnu"
elif system == "Windows":
plat = "pc-windows-msvc"
else:
return None

Expand Down Expand Up @@ -295,6 +317,9 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
return None, "unsupported_platform"

archive_name = f"tirith-{target}.tar.gz"
is_windows = target.endswith("-pc-windows-msvc")
if is_windows:
archive_name = f"tirith-{target}.zip"
base_url = f"https://github.com/{_REPO}/releases/latest/download"

tmpdir = tempfile.mkdtemp(prefix="tirith-install-")
Expand Down Expand Up @@ -345,23 +370,38 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
if not _verify_checksum(archive_path, checksums_path, archive_name):
return None, "checksum_failed"

with tarfile.open(archive_path, "r:gz") as tar:
# Extract only the tirith binary (safety: reject paths with ..)
for member in tar.getmembers():
if member.name == "tirith" or member.name.endswith("/tirith"):
if ".." in member.name:
continue
member.name = "tirith"
tar.extract(member, tmpdir)
break
else:
log("tirith binary not found in archive")
return None, "binary_not_in_archive"
if is_windows:
import zipfile
with zipfile.ZipFile(archive_path, "r") as zf:
for member in zf.namelist():
if member == "tirith.exe" or member.endswith("/tirith.exe"):
if ".." in member:
continue
zf.extract(member, tmpdir)
src = os.path.join(tmpdir, member)
break
else:
log("tirith binary not found in archive")
return None, "binary_not_in_archive"
src_base = src # reuse the path computed at extraction time
else:
with tarfile.open(archive_path, "r:gz") as tar:
for member in tar.getmembers():
if member.name == "tirith" or member.name.endswith("/tirith"):
if ".." in member.name:
continue
member.name = "tirith"
tar.extract(member, tmpdir)
break
else:
log("tirith binary not found in archive")
return None, "binary_not_in_archive"
src_base = os.path.join(tmpdir, "tirith")

src = os.path.join(tmpdir, "tirith")
dest = os.path.join(_hermes_bin_dir(), "tirith")
shutil.move(src, dest)
os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
dest = os.path.join(_hermes_bin_dir(), "tirith.exe" if is_windows else "tirith")
shutil.move(src_base, dest)
if not is_windows:
os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)

verification = "cosign + SHA-256" if cosign_verified else "SHA-256 only"
logger.info("tirith installed to %s (%s)", dest, verification)
Expand Down Expand Up @@ -426,8 +466,8 @@ def _resolve_tirith_path(configured_path: str) -> str:
_clear_install_failed()
return found

hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
hermes_bin = _find_bundled_tirith()
if hermes_bin:
_resolved_path = hermes_bin
_install_failure_reason = ""
_clear_install_failed()
Expand Down Expand Up @@ -490,8 +530,8 @@ def _background_install(*, log_failures: bool = True):
_install_failure_reason = ""
return

hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
hermes_bin = _find_bundled_tirith()
if hermes_bin:
_resolved_path = hermes_bin
_install_failure_reason = ""
return
Expand Down