|
1 | 1 | """Exercise the shipped shell installer with a large GitHub commit response.""" |
2 | 2 |
|
| 3 | +import hashlib |
| 4 | +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
3 | 5 | import json |
4 | 6 | import os |
5 | 7 | from pathlib import Path |
6 | 8 | import shlex |
7 | 9 | import subprocess |
8 | 10 | import sys |
9 | 11 | import tarfile |
| 12 | +import threading |
| 13 | +import time |
10 | 14 |
|
11 | 15 | import pytest |
12 | 16 |
|
13 | 17 |
|
| 18 | +def _start_partial_archive_server(payload, *, complete_resume): |
| 19 | + requests = [] |
| 20 | + partial_size = max(1, len(payload) // 3) |
| 21 | + |
| 22 | + class Handler(BaseHTTPRequestHandler): |
| 23 | + def do_GET(self): |
| 24 | + range_header = self.headers.get("Range") |
| 25 | + requests.append(range_header) |
| 26 | + if len(requests) == 1: |
| 27 | + self.send_response(200) |
| 28 | + self.send_header("Content-Length", str(len(payload))) |
| 29 | + self.end_headers() |
| 30 | + self.wfile.write(payload[:partial_size]) |
| 31 | + self.wfile.flush() |
| 32 | + time.sleep(3) |
| 33 | + return |
| 34 | + if not complete_resume: |
| 35 | + self.send_response(503) |
| 36 | + self.send_header("Content-Length", "0") |
| 37 | + self.end_headers() |
| 38 | + return |
| 39 | + expected_range = f"bytes={partial_size}-" |
| 40 | + if range_header != expected_range: |
| 41 | + self.send_response(400) |
| 42 | + self.send_header("Content-Length", "0") |
| 43 | + self.end_headers() |
| 44 | + return |
| 45 | + self.send_response(206) |
| 46 | + self.send_header("Content-Length", str(len(payload) - partial_size)) |
| 47 | + self.send_header( |
| 48 | + "Content-Range", |
| 49 | + f"bytes {partial_size}-{len(payload) - 1}/{len(payload)}", |
| 50 | + ) |
| 51 | + self.end_headers() |
| 52 | + self.wfile.write(payload[partial_size:]) |
| 53 | + |
| 54 | + def log_message(self, _format, *_args): |
| 55 | + pass |
| 56 | + |
| 57 | + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) |
| 58 | + server.daemon_threads = True |
| 59 | + thread = threading.Thread(target=server.serve_forever, daemon=True) |
| 60 | + thread.start() |
| 61 | + return server, thread, requests, partial_size |
| 62 | + |
| 63 | + |
14 | 64 | @pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer") |
15 | 65 | def test_invalid_commit_override_precedes_temp_directory_failure(tmp_path): |
16 | 66 | source = Path(__file__).resolve().parents[1] |
@@ -67,10 +117,11 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_ |
67 | 117 | + shlex.quote(sys.executable) |
68 | 118 | + " -c " |
69 | 119 | + shlex.quote( |
70 | | - "import os,sys,shutil; " |
| 120 | + "import json,os,sys,shutil; " |
71 | 121 | "args=sys.argv[1:]; " |
72 | 122 | "is_api=any('api.github.com' in a for a in args); " |
73 | 123 | "open(os.environ['TEST_CALLS'],'a').write('api\\n' if is_api else 'archive\\n'); " |
| 124 | + "open(os.environ['TEST_ARCHIVE_ARGS'],'w').write(json.dumps(args)) if not is_api else None; " |
74 | 125 | "sys.exit(22) if is_api and (os.environ['TEST_REF_KIND']=='sha' or os.environ['TEST_API_MODE']!='public') else None; " |
75 | 126 | "source=os.environ['TEST_RESPONSE'] if any('api.github.com' in a for a in args) " |
76 | 127 | "else os.environ['TEST_ARCHIVE']; " |
@@ -98,8 +149,10 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_ |
98 | 149 | TMPDIR=str(scratch), |
99 | 150 | LOOPX_PYTHON=sys.executable, |
100 | 151 | LOOPX_REF=sha if ref_kind == "sha" else "stable", |
| 152 | + LOOPX_INSTALLER_TIMEOUT_SECONDS="480", |
101 | 153 | TEST_REF_KIND=ref_kind, |
102 | 154 | TEST_API_MODE=api_mode, |
| 155 | + TEST_ARCHIVE_ARGS=str(tmp_path / "archive-args.json"), |
103 | 156 | TEST_CALLS=str(tmp_path / "calls"), |
104 | 157 | TEST_RESPONSE=str(fixture), |
105 | 158 | TEST_ARCHIVE=str(archive), |
@@ -130,3 +183,102 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_ |
130 | 183 | else: |
131 | 184 | assert calls[0] == "api" |
132 | 185 | assert ("authenticated" in calls) == (api_mode != "public") |
| 186 | + if "archive" in calls: |
| 187 | + archive_args = json.loads((tmp_path / "archive-args.json").read_text()) |
| 188 | + assert archive_args[archive_args.index("--max-time") + 1] == "120" |
| 189 | + assert "--retry" not in archive_args |
| 190 | + assert archive_args[archive_args.index("--continue-at") + 1] == "-" |
| 191 | + |
| 192 | + |
| 193 | +@pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer") |
| 194 | +def test_archive_download_resumes_after_attempt_timeout(tmp_path): |
| 195 | + source = Path(__file__).resolve().parents[1] |
| 196 | + package = tmp_path / "package" |
| 197 | + scripts = package / "scripts" |
| 198 | + scripts.mkdir(parents=True) |
| 199 | + installer = scripts / "install-local.sh" |
| 200 | + installer.write_text( |
| 201 | + '#!/bin/sh\nprintf "%s\\n" "$LOOPX_ARCHIVE_SHA256" > "$TEST_RECEIPT"\n' |
| 202 | + ) |
| 203 | + installer.chmod(0o755) |
| 204 | + (package / "payload.bin").write_bytes(bytes(range(256)) * 4096) |
| 205 | + archive = tmp_path / "package.tar.gz" |
| 206 | + with tarfile.open(archive, "w:gz") as handle: |
| 207 | + handle.add(package, arcname="package") |
| 208 | + payload = archive.read_bytes() |
| 209 | + server, thread, requests, partial_size = _start_partial_archive_server( |
| 210 | + payload, complete_resume=True |
| 211 | + ) |
| 212 | + scratch = tmp_path / "scratch" |
| 213 | + scratch.mkdir() |
| 214 | + receipt = tmp_path / "receipt" |
| 215 | + env = {k: v for k, v in os.environ.items() if not k.startswith("LOOPX_")} |
| 216 | + env.update( |
| 217 | + TMPDIR=str(scratch), |
| 218 | + LOOPX_PYTHON=sys.executable, |
| 219 | + LOOPX_ARCHIVE_URL=f"http://127.0.0.1:{server.server_port}/archive.tar.gz", |
| 220 | + LOOPX_INSTALLER_TIMEOUT_SECONDS="4", |
| 221 | + TEST_RECEIPT=str(receipt), |
| 222 | + ) |
| 223 | + try: |
| 224 | + result = subprocess.run( |
| 225 | + ["bash", str(source / "scripts/install-from-github.sh")], |
| 226 | + env=env, |
| 227 | + capture_output=True, |
| 228 | + text=True, |
| 229 | + timeout=15, |
| 230 | + ) |
| 231 | + finally: |
| 232 | + server.shutdown() |
| 233 | + server.server_close() |
| 234 | + thread.join(timeout=5) |
| 235 | + |
| 236 | + assert result.returncode == 0, result.stderr |
| 237 | + assert requests[:2] == [None, f"bytes={partial_size}-"] |
| 238 | + assert receipt.read_text().strip() == hashlib.sha256(payload).hexdigest() |
| 239 | + assert list(scratch.iterdir()) == [] |
| 240 | + |
| 241 | + |
| 242 | +@pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer") |
| 243 | +def test_archive_timeout_failure_never_extracts_partial_file(tmp_path): |
| 244 | + source = Path(__file__).resolve().parents[1] |
| 245 | + payload = bytes(range(256)) * 4096 |
| 246 | + server, thread, requests, partial_size = _start_partial_archive_server( |
| 247 | + payload, complete_resume=False |
| 248 | + ) |
| 249 | + binary = tmp_path / "bin" |
| 250 | + binary.mkdir() |
| 251 | + tar_marker = tmp_path / "tar-called" |
| 252 | + tar = binary / "tar" |
| 253 | + tar.write_text( |
| 254 | + '#!/bin/sh\nprintf called > "$TEST_TAR_MARKER"\nexit 99\n' |
| 255 | + ) |
| 256 | + tar.chmod(0o755) |
| 257 | + scratch = tmp_path / "scratch" |
| 258 | + scratch.mkdir() |
| 259 | + env = {k: v for k, v in os.environ.items() if not k.startswith("LOOPX_")} |
| 260 | + env.update( |
| 261 | + PATH=f"{binary}{os.pathsep}{env['PATH']}", |
| 262 | + TMPDIR=str(scratch), |
| 263 | + LOOPX_PYTHON=sys.executable, |
| 264 | + LOOPX_ARCHIVE_URL=f"http://127.0.0.1:{server.server_port}/archive.tar.gz", |
| 265 | + LOOPX_INSTALLER_TIMEOUT_SECONDS="4", |
| 266 | + TEST_TAR_MARKER=str(tar_marker), |
| 267 | + ) |
| 268 | + try: |
| 269 | + result = subprocess.run( |
| 270 | + ["bash", str(source / "scripts/install-from-github.sh")], |
| 271 | + env=env, |
| 272 | + capture_output=True, |
| 273 | + text=True, |
| 274 | + timeout=15, |
| 275 | + ) |
| 276 | + finally: |
| 277 | + server.shutdown() |
| 278 | + server.server_close() |
| 279 | + thread.join(timeout=5) |
| 280 | + |
| 281 | + assert result.returncode != 0 |
| 282 | + assert requests[:2] == [None, f"bytes={partial_size}-"] |
| 283 | + assert not tar_marker.exists() |
| 284 | + assert list(scratch.iterdir()) == [] |
0 commit comments