strix/tests/test_install_script.py
mohammad junayd a91b390b44 fix(install): delay PATH cleanup until after verified install
Do not remove pipx or other strix copies before checksum and
provenance succeed, so a failed verify cannot leave the user
with no working binary (#1267).
2026-09-14 18:09:49 +04:00

448 lines
14 KiB
Python

from __future__ import annotations
import stat
import subprocess
import sys
import tarfile
from pathlib import Path
import pytest
RELEASE_VERSION = "9.9.9"
RELEASE_TARGET = "linux-arm64"
pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="scripts/install.sh is a POSIX shell installer",
)
def _write_executable(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR)
def _create_release_archive(tmp_path: Path) -> Path:
binary_name = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}"
binary_path = tmp_path / binary_name
_write_executable(binary_path, f"#!/bin/sh\nprintf 'strix {RELEASE_VERSION}\\n'\n")
archive_path = tmp_path / f"{binary_name}.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(binary_path, arcname=binary_name)
return archive_path
def _create_mock_commands(tmp_path: Path, machine: str) -> Path:
mock_bin = tmp_path / "mock-bin"
mock_bin.mkdir()
_write_executable(
mock_bin / "uname",
f"""#!/bin/sh
case "$1" in
-s) echo Linux ;;
-m) echo {machine} ;;
*) echo "unexpected uname argument: $*" >&2; exit 1 ;;
esac
""",
)
_write_executable(mock_bin / "docker", "#!/bin/sh\nexit 0\n")
# Shadow any real gh/cosign on PATH. Behaviour is driven by env flags.
_write_executable(
mock_bin / "gh",
"""#!/bin/sh
if [ -n "${STRIX_TEST_NO_VERIFIER:-}" ]; then
exit 1
fi
if [ "$1" = "attestation" ] && [ "$2" = "verify" ]; then
if [ "$3" = "--help" ]; then
exit 0
fi
printf '%s\\n' "$*" >> "$STRIX_TEST_GH_LOG"
if [ -n "${STRIX_TEST_GH_FAIL_VERIFY:-}" ]; then
exit 1
fi
exit 0
fi
exit 1
""",
)
_write_executable(
mock_bin / "cosign",
"""#!/bin/sh
if [ -n "${STRIX_TEST_NO_VERIFIER:-}" ]; then
exit 1
fi
if [ "$1" = "verify-blob-attestation" ]; then
if [ "$2" = "--help" ]; then
[ -n "${STRIX_TEST_USE_COSIGN:-}" ] && exit 0
exit 1
fi
printf '%s\\n' "$*" >> "$STRIX_TEST_COSIGN_LOG"
[ -n "${STRIX_TEST_USE_COSIGN:-}" ] && exit 0
exit 1
fi
exit 1
""",
)
_write_executable(
mock_bin / "curl",
"""#!/bin/sh
output=""
url=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "-o" ]; then
output="$2"
shift 2
continue
fi
case "$1" in
http://*|https://*) url="$1" ;;
esac
printf '%s\\n' "$1" >> "$STRIX_TEST_CURL_LOG"
shift
done
if [ -n "${STRIX_TEST_CURL_FAIL:-}" ]; then
case "$url" in
*"$STRIX_TEST_CURL_FAIL"*) exit 22 ;;
esac
fi
case "$url" in
*/SHA256SUMS)
name=$(basename "$STRIX_TEST_ARCHIVE")
if [ -n "${STRIX_TEST_BAD_CHECKSUM:-}" ]; then
printf '%s %s\\n' "0" "$name" > "$output"
elif [ -n "${STRIX_TEST_SUMS_WRONG_NAME:-}" ]; then
hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
printf '%s %s\\n' "$hash" "other-file.tar.gz" > "$output"
else
hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
printf '%s %s\\n' "$hash" "$name" > "$output"
fi
;;
*.intoto.jsonl)
printf '{"test":true}\\n' > "$output"
;;
*)
cp "$STRIX_TEST_ARCHIVE" "$output"
;;
esac
""",
)
return mock_bin
def _create_installer_environment(
tmp_path: Path,
archive_path: Path,
mock_bin: Path,
) -> tuple[dict[str, str], Path, Path]:
"""Build the installer environment explicitly.
Every variable the installer reads is listed here, so no inherited value
(`XDG_CONFIG_HOME`, `GITHUB_ACTIONS`, `TMPDIR`, ...) can send a write
outside the sandbox or change the code path under test.
"""
home_path = tmp_path / "home"
home_path.mkdir()
download_path = tmp_path / "downloads"
download_path.mkdir()
curl_log_path = tmp_path / "curl.log"
gh_log_path = tmp_path / "gh.log"
cosign_log_path = tmp_path / "cosign.log"
environment = {
"HOME": str(home_path),
"XDG_CONFIG_HOME": str(home_path / ".config"),
"PATH": f"{mock_bin}:/usr/bin:/bin",
"SHELL": "/bin/bash",
"TMPDIR": str(download_path),
"STRIX_TEST_ARCHIVE": str(archive_path),
"STRIX_TEST_CURL_LOG": str(curl_log_path),
"STRIX_TEST_GH_LOG": str(gh_log_path),
"STRIX_TEST_COSIGN_LOG": str(cosign_log_path),
"VERSION": RELEASE_VERSION,
}
return environment, home_path, curl_log_path
def _run_installer(
repository_root: Path,
environment: dict[str, str],
) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603
["/bin/bash", str(repository_root / "scripts/install.sh")],
cwd=repository_root,
env=environment,
capture_output=True,
text=True,
check=False,
)
def test_installer_downloads_and_runs_linux_arm64_release(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
result = _run_installer(repository_root, environment)
assert result.returncode == 0, result.stderr
expected_filename = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}.tar.gz"
curl_log = curl_log_path.read_text(encoding="utf-8")
assert expected_filename in curl_log
assert f"releases/download/v{RELEASE_VERSION}/SHA256SUMS" in curl_log
assert f"strix-{RELEASE_TARGET}.intoto.jsonl" in curl_log
assert "Checksum verified" in result.stdout
assert "Provenance verified" in result.stdout
gh_log = (tmp_path / "gh.log").read_text(encoding="utf-8")
assert "--repo usestrix/strix" in gh_log
assert "--bundle strix-linux-arm64.intoto.jsonl" in gh_log
assert "--signer-workflow usestrix/strix/.github/workflows/build-release.yml" in gh_log
assert "--deny-self-hosted-runners" in gh_log
assert not (tmp_path / "cosign.log").exists()
installed_binary = home_path / ".strix/bin/strix"
installed_result = subprocess.run( # noqa: S603
[str(installed_binary), "--version"],
capture_output=True,
text=True,
check=True,
)
assert installed_result.stdout.strip() == f"strix {RELEASE_VERSION}"
def test_installer_rejects_unsupported_architecture(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="riscv64")
environment, home_path, curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
result = _run_installer(repository_root, environment)
assert result.returncode != 0
assert "Unsupported OS/Arch: linux/riscv64" in result.stdout
assert not curl_log_path.exists()
assert not (home_path / ".strix").exists()
def test_installer_leaves_external_strix_on_checksum_failure(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, _curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_TEST_BAD_CHECKSUM"] = "1"
other_bin = tmp_path / "other-bin"
other_bin.mkdir()
external = other_bin / "strix"
_write_executable(external, "#!/bin/sh\nprintf 'strix 1.0.0\\n'\n")
environment["PATH"] = f"{other_bin}:{environment['PATH']}"
before = external.read_bytes()
result = _run_installer(repository_root, environment)
assert result.returncode != 0
assert "Checksum mismatch" in result.stdout
assert external.exists()
assert external.read_bytes() == before
assert not (home_path / ".strix/bin/strix").exists()
installed_result = subprocess.run( # noqa: S603
[str(external), "--version"],
capture_output=True,
text=True,
check=True,
)
assert installed_result.stdout.strip() == "strix 1.0.0"
def test_installer_leaves_existing_install_on_checksum_failure(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, _curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_TEST_BAD_CHECKSUM"] = "1"
install_dir = home_path / ".strix" / "bin"
install_dir.mkdir(parents=True)
existing = install_dir / "strix"
_write_executable(existing, "#!/bin/sh\nprintf 'strix 1.0.0\\n'\n")
before = existing.read_bytes()
result = _run_installer(repository_root, environment)
assert result.returncode != 0
assert "Checksum mismatch" in result.stdout
assert "Existing Strix installation left unchanged" in result.stdout
assert existing.read_bytes() == before
assert not (install_dir / "strix.new").exists()
installed_result = subprocess.run( # noqa: S603
[str(existing), "--version"],
capture_output=True,
text=True,
check=True,
)
assert installed_result.stdout.strip() == "strix 1.0.0"
def test_installer_rejects_missing_checksum_manifest(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, _curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_TEST_CURL_FAIL"] = "SHA256SUMS"
result = _run_installer(repository_root, environment)
assert result.returncode != 0
assert "Failed to download checksum manifest" in result.stdout
assert not (home_path / ".strix/bin/strix").exists()
def test_installer_rejects_checksum_manifest_without_archive_entry(
tmp_path: Path,
) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, _curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_TEST_SUMS_WRONG_NAME"] = "1"
result = _run_installer(repository_root, environment)
assert result.returncode != 0
assert "No SHA256SUMS entry" in result.stdout
assert not (home_path / ".strix/bin/strix").exists()
def test_installer_rejects_missing_provenance_bundle(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, _curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_TEST_CURL_FAIL"] = "intoto.jsonl"
result = _run_installer(repository_root, environment)
assert result.returncode != 0
assert "Failed to download provenance bundle" in result.stdout
assert "Checksum verified" in result.stdout
assert not (home_path / ".strix/bin/strix").exists()
def test_installer_rejects_failed_gh_attestation_verify(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, _curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_TEST_GH_FAIL_VERIFY"] = "1"
result = _run_installer(repository_root, environment)
assert result.returncode != 0
assert "gh attestation verify failed" in result.stdout
assert not (home_path / ".strix/bin/strix").exists()
assert not (tmp_path / "cosign.log").exists()
def test_installer_uses_cosign_when_gh_cannot_verify(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, _curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_TEST_USE_COSIGN"] = "1"
_write_executable(
mock_bin / "gh",
"#!/bin/sh\nexit 1\n",
)
result = _run_installer(repository_root, environment)
assert result.returncode == 0, result.stderr
assert "Provenance verified" in result.stdout
assert "(cosign)" in result.stdout
cosign_log = (tmp_path / "cosign.log").read_text(encoding="utf-8")
assert "--new-bundle-format" in cosign_log
assert "--certificate-oidc-issuer https://token.actions.githubusercontent.com" in (cosign_log)
assert "build-release.yml" in cosign_log
assert "--type slsaprovenance1" in cosign_log
assert (home_path / ".strix/bin/strix").exists()
def test_installer_rejects_when_no_provenance_verifier_is_available(
tmp_path: Path,
) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, _curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_TEST_NO_VERIFIER"] = "1"
result = _run_installer(repository_root, environment)
assert result.returncode != 0
assert "Neither a usable 'gh' nor 'cosign'" in result.stdout
assert not (home_path / ".strix/bin/strix").exists()
def test_installer_skip_verify_does_not_download_manifests(tmp_path: Path) -> None:
repository_root = Path(__file__).resolve().parents[1]
archive_path = _create_release_archive(tmp_path)
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
environment, home_path, curl_log_path = _create_installer_environment(
tmp_path,
archive_path,
mock_bin,
)
environment["STRIX_INSTALL_SKIP_VERIFY"] = "1"
result = _run_installer(repository_root, environment)
assert result.returncode == 0, result.stderr
curl_log = curl_log_path.read_text(encoding="utf-8")
assert "SHA256SUMS" not in curl_log
assert "intoto.jsonl" not in curl_log
assert "skipping checksum and provenance checks" in result.stdout
assert (home_path / ".strix/bin/strix").exists()
assert not (tmp_path / "gh.log").exists()