diff --git a/scripts/stage-package-repository.py b/scripts/stage-package-repository.py index 85090b3..740cf48 100755 --- a/scripts/stage-package-repository.py +++ b/scripts/stage-package-repository.py @@ -374,6 +374,23 @@ def _load_locked_public_key( return public_key_data +def _resolve_executable(path: Path, context: str) -> Path: + candidate = path + if not candidate.is_absolute() and len(candidate.parts) == 1: + located = shutil.which(str(candidate)) + if located is None: + fail(f"unable to locate {context}") + candidate = Path(located) + try: + resolved = candidate.resolve(strict=True) + except OSError as error: + fail(f"unable to canonicalize {context}: {error}") + _regular_file(resolved, context) + if not os.access(resolved, os.X_OK): + fail(f"{context} is not executable") + return resolved + + def _verify_signed_index( apk_executable: Path, repository: Path, @@ -425,6 +442,7 @@ def _archive_repository(source: Path, archive: Path, repository_directory: str) def command_build(args: argparse.Namespace) -> None: config = repository_config(args.lock) + apk_executable = _resolve_executable(args.apk_executable, "apk executable") input_dir = _real_directory(args.input_dir, "APK input directory") output_dir = _safe_existing_parent(args.output_dir, "repository output directory") archive = _safe_existing_parent(args.archive, "repository archive") @@ -460,7 +478,7 @@ def command_build(args: argparse.Namespace) -> None: digest, size = copy_regular(source, temporary / source.name, config["max_member_bytes"]) package_records.append({"filename": source.name, "sha256": digest, "size": size}) command = [ - str(args.apk_executable), + str(apk_executable), "mkndx", "--allow-untrusted", "--sign", @@ -491,7 +509,7 @@ def command_build(args: argparse.Namespace) -> None: index_path = temporary / "packages.adb" _regular_file(index_path, "packages.adb", config["max_member_bytes"]) _verify_signed_index( - args.apk_executable, + apk_executable, temporary, public_key_data, args.apk_timeout, diff --git a/tests/test_package_repository.py b/tests/test_package_repository.py index c9ba544..fed5c71 100644 --- a/tests/test_package_repository.py +++ b/tests/test_package_repository.py @@ -115,6 +115,7 @@ def run_script( *arguments: object, expected: int = 0, extra_env: dict[str, str] | None = None, + cwd: Path | None = None, ) -> subprocess.CompletedProcess[str]: command = [sys.executable, str(SCRIPT), "--lock", str(self.lock), *(str(item) for item in arguments)] environment = os.environ.copy() @@ -126,7 +127,7 @@ def run_script( ) if extra_env: environment.update(extra_env) - result = subprocess.run(command, text=True, capture_output=True, check=False, env=environment) + result = subprocess.run(command, text=True, capture_output=True, check=False, env=environment, cwd=cwd) self.assertEqual( result.returncode, expected, @@ -140,8 +141,10 @@ def build( self, *, public_key: Path | None = None, + apk_executable: Path | None = None, expected: int = 0, extra_env: dict[str, str] | None = None, + cwd: Path | None = None, ) -> tuple[Path, Path, subprocess.CompletedProcess[str]]: output = self.root / "repository" archive = self.root / self.asset_name @@ -158,9 +161,10 @@ def build( "--public-key", public_key or self.public_key, "--apk-executable", - self.fake_apk, + apk_executable or self.fake_apk, expected=expected, extra_env=extra_env, + cwd=cwd, ) return output, archive, result @@ -252,6 +256,40 @@ def test_successful_build_verifies_signature_and_pages_stage(self) -> None: staged = self.root / "site/packages" / self.directory self.assertTrue((staged / "packages.adb").is_file()) + def test_build_accepts_relative_apk_executable_when_subprocess_changes_directory(self) -> None: + relative_apk = Path(os.path.relpath(self.fake_apk, Path.cwd())) + self.assertFalse(relative_apk.is_absolute()) + output, archive, _ = self.build(apk_executable=relative_apk) + self.assertTrue((output / "packages.adb").is_file()) + self.assertTrue(archive.is_file()) + self.assertEqual(len(self.fake_apk_calls()), 2) + + def test_build_resolves_bare_apk_executable_from_path(self) -> None: + bin_dir = self.root / "bin" + bin_dir.mkdir() + path_apk = bin_dir / "apk" + path_apk.write_bytes(self.fake_apk.read_bytes()) + path_apk.chmod(0o755) + caller = self.root / "caller" + caller.mkdir() + (caller / "apk").write_text("not executable\n", encoding="ascii") + output, archive, _ = self.build( + apk_executable=Path("apk"), + extra_env={"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}, + cwd=caller, + ) + self.assertTrue((output / "packages.adb").is_file()) + self.assertTrue(archive.is_file()) + self.assertEqual(len(self.fake_apk_calls()), 2) + + def test_build_rejects_non_executable_apk_path_before_staging(self) -> None: + non_executable = self.root / "not-executable-apk" + non_executable.write_text("#!/bin/sh\nexit 0\n", encoding="ascii") + output, archive, result = self.build(apk_executable=non_executable, expected=1) + self.assertIn("apk executable is not executable", result.stderr.lower()) + self.assertFalse(output.exists()) + self.assertFalse(archive.exists()) + def test_build_rejects_wrong_locked_public_key(self) -> None: wrong_private = self.root / "wrong-private.pem" wrong_public = self.root / "wrong-public.pem"