188 lines
8.7 KiB
Python
188 lines
8.7 KiB
Python
"""The shell-staged uv can prepare TLS support before PM downloads Python."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_staged_uv_prepares_pm_before_any_tool_download(tmp_path):
|
|
from pm.packages import Uv
|
|
from pm.store import current_target
|
|
|
|
uv = shutil.which("uv")
|
|
assert uv, "this bootstrap contract requires real uv"
|
|
repo = Path(__file__).resolve().parents[2]
|
|
stage = tmp_path / "source"
|
|
for name in ("pm", "hermes_cli"):
|
|
shutil.copytree(repo / name, stage / name, ignore=shutil.ignore_patterns("__pycache__"))
|
|
shutil.copy2(repo / "hermes_constants.py", stage / "hermes_constants.py")
|
|
home = tmp_path / "home"
|
|
store = home / "tools"
|
|
target = current_target()
|
|
# As in setup: uv has been verified/extracted, but PM has no installed facts.
|
|
entry = store / Uv().store_entry("bootstrap-fixture", target)
|
|
binary = Uv().binary(entry, target)
|
|
assert binary is not None
|
|
binary.parent.mkdir(parents=True)
|
|
shutil.copy2(uv, binary)
|
|
(stage / "pm" / "lock.json").write_text(json.dumps({"schema": 1, "packages": {
|
|
name: {"version": "bootstrap-fixture", "artifacts": {target: {
|
|
"url": f"https://must-not-fetch.invalid/{name}.tar.gz", "sha256": "a" * 64,
|
|
}}} for name in ("uv", "python")
|
|
}}))
|
|
env = {key: value for key, value in os.environ.items()
|
|
if not key.startswith(("PYTHON", "UV_"))}
|
|
env.update(HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(store))
|
|
code = """
|
|
import sys, subprocess
|
|
from pathlib import Path
|
|
sys.path.insert(0, sys.argv[1])
|
|
from pm.runtime import runtime_command, runtime_environment
|
|
result = subprocess.run(runtime_command(Path(sys.argv[2])), env=runtime_environment(),
|
|
capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
print(result.stdout)
|
|
"""
|
|
probe = tmp_path / "probe.py"
|
|
probe.write_text("import json, truststore; print(json.dumps({'tls': truststore.__file__}))")
|
|
command = [sys.executable, "-I", "-S", "-c", code, str(stage), str(probe)]
|
|
result = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=180)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert Path(json.loads(result.stdout)["tls"]).is_relative_to(home)
|
|
assert "Preparing the isolated Hermes runtime" in result.stderr
|
|
assert "must-not-fetch.invalid" not in result.stderr
|
|
warm = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
|
|
assert warm.returncode == 0, warm.stdout + warm.stderr
|
|
assert "Preparing the isolated Hermes runtime" not in warm.stderr
|
|
assert warm.stdout == result.stdout
|
|
assert not (store / "facts.json").exists(), "preparing PM must not realize its tool closure"
|
|
assert not list(store.glob("python-*"))
|
|
assert not list(home.glob("installs/*/environments")), "no app environment during PM bootstrap"
|
|
|
|
|
|
@pytest.mark.platforms("linux")
|
|
def test_pm_cli_verifies_tls_with_platform_trust(tmp_path, monkeypatch):
|
|
from datetime import datetime, timedelta, timezone
|
|
import hashlib
|
|
from http.server import ThreadingHTTPServer
|
|
from ipaddress import ip_address
|
|
import io
|
|
import ssl
|
|
import tarfile
|
|
import threading
|
|
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
from pm.runtime import prepare_runtime, runtime_environment
|
|
from tests.pm._range_server import RangeHandler
|
|
|
|
home = tmp_path / "home"
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools"))
|
|
uv = shutil.which("uv")
|
|
assert uv
|
|
python = prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime")
|
|
source = Path(__file__).resolve().parents[2]
|
|
repo = tmp_path / "source"
|
|
for name in ("pm", "hermes_cli"):
|
|
shutil.copytree(source / name, repo / name, ignore=shutil.ignore_patterns("__pycache__"))
|
|
shutil.copy2(source / "hermes_constants.py", repo / "hermes_constants.py")
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PM test CA")])
|
|
now = datetime.now(timezone.utc)
|
|
cert = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject)
|
|
.public_key(key.public_key()).serial_number(x509.random_serial_number())
|
|
.not_valid_before(now - timedelta(days=1)).not_valid_after(now + timedelta(days=1))
|
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ip_address("127.0.0.1"))]), critical=False)
|
|
.sign(key, hashes.SHA256()))
|
|
bundle, private = tmp_path / "ca.pem", tmp_path / "server.key"
|
|
bundle.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
private.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption()))
|
|
payload = b"verified through platform trust"
|
|
stream = io.BytesIO()
|
|
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
|
|
member = tarfile.TarInfo("payload.txt")
|
|
member.size = len(payload)
|
|
archive.addfile(member, io.BytesIO(payload))
|
|
body = stream.getvalue()
|
|
|
|
class Handler(RangeHandler):
|
|
payloads = {"/tool.tar.gz": body}
|
|
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.load_cert_chain(bundle, private)
|
|
server.socket = context.wrap_socket(server.socket, server_side=True)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
(repo / "pm" / "lock.json").write_text(json.dumps({"schema": 1, "packages": {
|
|
"tls-test": {"version": "1", "artifacts": {"any": {
|
|
"url": f"https://127.0.0.1:{server.server_port}/tool.tar.gz",
|
|
"sha256": hashlib.sha256(body).hexdigest(),
|
|
}}},
|
|
}}))
|
|
# Inject missing OpenSSL defaults, not a fake platform. Only truststore's
|
|
# real Linux CA discovery can find the test CA; urllib alone must reject it.
|
|
package = repo / "pm" / "tls_fixture.py"
|
|
package.write_text("from pm import Package, register\n@register\nclass Tool(Package):\n name = 'tls-test'\n")
|
|
driver = """
|
|
import runpy, ssl, sys
|
|
import truststore._openssl as platform_tls
|
|
defaults = ssl.get_default_verify_paths()
|
|
ssl.get_default_verify_paths = lambda: defaults._replace(cafile=None, capath=None)
|
|
platform_tls._CA_FILE_CANDIDATES = [sys.argv.pop(1)]
|
|
script = sys.argv.pop(1)
|
|
sys.path.insert(0, sys.argv.pop(1))
|
|
sys.argv[0] = script
|
|
# Register only the fixture package; the real entrypoint owns TLS activation.
|
|
module = runpy.run_path(script, run_name='tls_entrypoint_test')
|
|
import pm.tls_fixture
|
|
raise SystemExit(module['main']())
|
|
"""
|
|
env = runtime_environment()
|
|
env.pop("SSL_CERT_FILE", None)
|
|
env.pop("SSL_CERT_DIR", None)
|
|
env["NO_PROXY"] = "127.0.0.1"
|
|
try:
|
|
command = [str(python), "-I", "-B", "-c", driver, str(tmp_path / "missing-ca"),
|
|
str(repo / "pm" / "launch.py"), str(repo), "install", "tls-test"]
|
|
rejected = subprocess.run(command, cwd=tmp_path, env=env,
|
|
capture_output=True, text=True, timeout=60)
|
|
assert rejected.returncode == 1, rejected.stdout + rejected.stderr
|
|
assert "CERTIFICATE_VERIFY_FAILED" in rejected.stdout + rejected.stderr
|
|
assert not list((home / "tools").glob("tls-test-*/payload.txt"))
|
|
command[5] = str(bundle)
|
|
result = subprocess.run(command, cwd=tmp_path, env=env,
|
|
capture_output=True, text=True, timeout=60)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
installed = list((home / "tools").glob("tls-test-*/payload.txt"))
|
|
assert len(installed) == 1, result.stdout + result.stderr
|
|
assert installed[0].read_bytes() == payload
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
def test_importing_launch_does_not_patch_ssl_context():
|
|
import subprocess
|
|
import sys
|
|
|
|
from pm.paths import repo_root
|
|
child = subprocess.run(
|
|
[sys.executable, "-c", "import ssl; original = ssl.SSLContext; import pm.launch; assert ssl.SSLContext is original"],
|
|
cwd=repo_root(), capture_output=True, text=True, timeout=30, check=False,
|
|
)
|
|
assert child.returncode == 0, child.stderr
|