From 1ee930da56617a37c5124cb0ba625d97ba0358c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 20:43:55 +0000 Subject: [PATCH 01/18] Add docs_url handler to BinProvider/Binary Introduces a new ``docs_url`` handler so BinProviders can render a link to package info pages without requiring the binary to be installed first. - Adds ``docs_url`` to ``HandlerType``/``HandlerDict`` so it participates in the same overrides plumbing as ``install_args`` / ``abspath`` / etc. - ``BinProvider.get_docs_url(bin_name)`` and the new ``ShallowBinary.docs_url()`` method resolve a URL by trying the loaded provider first, then falling back through ``binproviders`` in order. - Implements ``default_docs_url_handler`` for pip/uv (PyPI), npm/pnpm/bun/yarn (npmjs.com), deno (npm/jsr/deno.land), cargo (crates.io), gem (rubygems.org), brew (formulae.brew.sh), nix (search.nixos.org), docker (Docker Hub _/ vs r/), apt (Ubuntu/Debian packages.* with detected codename), and goget (pkg.go.dev). - env/bash/playwright/puppeteer/chromewebstore providers correctly return ``None`` so callers can fall back to the next provider. --- abxpkg/binprovider.py | 142 +++++++++++++++++++++++++++++++++++ abxpkg/binprovider_apt.py | 37 +++++++++ abxpkg/binprovider_brew.py | 10 +++ abxpkg/binprovider_bun.py | 10 +++ abxpkg/binprovider_cargo.py | 10 +++ abxpkg/binprovider_deno.py | 37 +++++++++ abxpkg/binprovider_docker.py | 30 ++++++++ abxpkg/binprovider_gem.py | 10 +++ abxpkg/binprovider_goget.py | 18 +++++ abxpkg/binprovider_nix.py | 13 ++++ abxpkg/binprovider_npm.py | 10 +++ abxpkg/binprovider_pip.py | 10 +++ abxpkg/binprovider_pnpm.py | 10 +++ abxpkg/binprovider_uv.py | 10 +++ abxpkg/binprovider_yarn.py | 10 +++ tests/test_binprovider.py | 29 +++++++ 16 files changed, 396 insertions(+) diff --git a/abxpkg/binprovider.py b/abxpkg/binprovider.py index 0aaec73b..8818ff00 100755 --- a/abxpkg/binprovider.py +++ b/abxpkg/binprovider.py @@ -296,6 +296,37 @@ def bin_dir(self) -> BinDirPath | None: def loaded_respath(self) -> HostBinPath | None: return self.loaded_abspath and self.loaded_abspath.resolve() + @log_method_call(include_result=True) + def docs_url(self, quiet: bool = True) -> str | None: + """Return a human-readable info/docs URL for this binary. + + If this binary has been loaded via a specific provider, that provider + is asked first. Otherwise (and as a fallback) each candidate provider + in ``binproviders`` is tried in order until one returns a non-None URL. + """ + if not self.name: + return None + + tried: set[int] = set() + candidates: list["BinProvider"] = [] + if self.loaded_binprovider is not None: + candidates.append(self.loaded_binprovider) + candidates.extend(self.binproviders) + + for provider in candidates: + if id(provider) in tried: + continue + tried.add(id(provider)) + try: + url = provider.get_docs_url(self.name, quiet=quiet) + except Exception: + if not quiet: + raise + url = None + if url: + return url + return None + # @validate_call @log_method_call(include_result=True) def exec( @@ -384,6 +415,7 @@ class BinProvider(BaseModel): "install": "self.default_install_handler", "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", + "docs_url": "self.default_docs_url_handler", }, }, repr=False, @@ -1228,6 +1260,62 @@ def default_install_args_handler( # ... install command calculation logic here return [bin_name] + # @validate_call + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> "DocsUrlFuncReturnValue": # aka str | None + """Subclasses override this to return a human-readable info URL for the package. + + Providers that don't have a meaningful docs/info URL (env, bash, custom, + etc.) should leave this returning None so callers can fall back to the + next provider in the binproviders list. + """ + return None + + def _docs_url_package_name( + self, + bin_name: BinName, + *, + allow_leading_at: bool = False, + ) -> str | None: + """Pick a canonical package name from install_args for URL building. + + Strips version specifiers (``==1.0``, ``>=2,<3``), extras (``foo[all]``), + and trailing ``@version`` suffixes (npm-style). Falls back to ``bin_name`` + if install_args don't yield a usable candidate. + """ + try: + install_args = self.get_install_args(bin_name, quiet=True) + except Exception: + install_args = [str(bin_name)] + + candidates = list(install_args) or [str(bin_name)] + for arg in candidates: + if not arg or arg.startswith("-"): + continue + if "://" in arg or arg.startswith((".", "/", "~")): + continue + name = arg + if allow_leading_at and name.startswith("@"): + # npm scoped pkg: keep leading @, only trim version after '@' that + # appears after the scope/name boundary. + _, _, after_slash = name[1:].partition("/") + if "@" in after_slash: + pkg, _, _ = after_slash.partition("@") + name = "@" + name[1:].split("/", 1)[0] + "/" + pkg + else: + name = name.split("@", 1)[0] if "@" in name else name + name = name.split("[", 1)[0] + for sep in ("==", ">=", "<=", "!=", "~=", ">", "<", ";", " "): + if sep in name: + name = name.split(sep, 1)[0] + name = name.strip() + if name: + return name + return str(bin_name) or None + def default_packages_handler( self, bin_name: BinName, @@ -1955,6 +2043,36 @@ def get_packages( ) -> InstallArgs: return self.get_install_args(bin_name, quiet=quiet, no_cache=no_cache) + @log_method_call(include_result=True) + def get_docs_url( + self, + bin_name: BinName, + quiet: bool = True, + no_cache: bool = False, + ) -> str | None: + try: + url = cast( + DocsUrlFuncReturnValue, + self._call_handler_for_action( + bin_name=bin_name, + handler_type="docs_url", + ), + ) + except Exception: + if not quiet: + raise + return None + return url or None + + @log_method_call(include_result=True) + def docs_url( + self, + bin_name: BinName, + quiet: bool = True, + no_cache: bool = False, + ) -> str | None: + return self.get_docs_url(bin_name, quiet=quiet, no_cache=no_cache) + @log_method_call() def setup( self, @@ -2529,6 +2647,7 @@ class EnvProvider(BinProvider): "install": "self.install_noop", "update": "self.update_noop", "uninstall": "self.uninstall_noop", + "docs_url": "self.default_docs_url_handler", }, "python": { "abspath": "self.python_abspath_handler", @@ -2874,12 +2993,14 @@ def write_cached_binary( PackagesFuncReturnValue = InstallArgsFuncReturnValue InstallFuncReturnValue = str | None ActionFuncReturnValue = str | bool | None +DocsUrlFuncReturnValue = str | None ProviderFuncReturnValue = ( AbspathFuncReturnValue | VersionFuncReturnValue | InstallArgsFuncReturnValue | InstallFuncReturnValue | ActionFuncReturnValue + | DocsUrlFuncReturnValue ) @@ -2944,12 +3065,23 @@ def __call__( ) -> "ActionFuncReturnValue": ... +@runtime_checkable +class DocsUrlFuncWithArgs(Protocol): + def __call__( + _self, + binprovider: "BinProvider", + bin_name: BinName, + **context, + ) -> "DocsUrlFuncReturnValue": ... + + AbspathFuncWithNoArgs = Callable[[], AbspathFuncReturnValue] VersionFuncWithNoArgs = Callable[[], VersionFuncReturnValue] InstallArgsFuncWithNoArgs = Callable[[], InstallArgsFuncReturnValue] PackagesFuncWithNoArgs = InstallArgsFuncWithNoArgs InstallFuncWithNoArgs = Callable[[], InstallFuncReturnValue] ActionFuncWithNoArgs = Callable[[], ActionFuncReturnValue] +DocsUrlFuncWithNoArgs = Callable[[], DocsUrlFuncReturnValue] AbspathHandlerValue = ( SelfMethodName @@ -2979,6 +3111,12 @@ def __call__( ActionHandlerValue = ( SelfMethodName | ActionFuncWithNoArgs | ActionFuncWithArgs | ActionFuncReturnValue ) +DocsUrlHandlerValue = ( + SelfMethodName + | DocsUrlFuncWithNoArgs + | DocsUrlFuncWithArgs + | DocsUrlFuncReturnValue +) HandlerType = Literal[ "abspath", @@ -2988,6 +3126,7 @@ def __call__( "install", "update", "uninstall", + "docs_url", ] HandlerValue = ( AbspathHandlerValue @@ -2995,6 +3134,7 @@ def __call__( | InstallArgsHandlerValue | InstallHandlerValue | ActionHandlerValue + | DocsUrlHandlerValue ) HandlerReturnValue = ( AbspathFuncReturnValue @@ -3002,6 +3142,7 @@ def __call__( | InstallArgsFuncReturnValue | InstallFuncReturnValue | ActionFuncReturnValue + | DocsUrlFuncReturnValue ) @@ -3023,6 +3164,7 @@ class HandlerDict(TypedDict, total=False): install: InstallHandlerValue update: ActionHandlerValue uninstall: ActionHandlerValue + docs_url: DocsUrlHandlerValue # Binary.overrides map BinProviderName:ProviderFieldOrHandlerPatch diff --git a/abxpkg/binprovider_apt.py b/abxpkg/binprovider_apt.py index be27f27a..ec0667a6 100755 --- a/abxpkg/binprovider_apt.py +++ b/abxpkg/binprovider_apt.py @@ -85,6 +85,43 @@ def setup_PATH(self, no_cache: bool = False) -> None: self.PATH = TypeAdapter(PATHStr).validate_python(PATH) super().setup_PATH(no_cache=no_cache) + @staticmethod + def _detect_distro_codename() -> tuple[str, str]: + """Return (distro_id, codename) parsed from /etc/os-release with fallbacks.""" + os_release: dict[str, str] = {} + try: + with open("/etc/os-release", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or "=" not in line or line.startswith("#"): + continue + key, _, value = line.partition("=") + os_release[key] = value.strip().strip('"').strip("'") + except OSError: + pass + distro_id = (os_release.get("ID") or "").lower() + codename = ( + os_release.get("VERSION_CODENAME") + or os_release.get("UBUNTU_CODENAME") + or "" + ).lower() + if distro_id in ("ubuntu", "debian"): + return distro_id, codename or ("noble" if distro_id == "ubuntu" else "stable") + # default to ubuntu LTS for derivatives that don't set a codename + return "ubuntu", codename or "noble" + + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + distro, codename = self._detect_distro_codename() + host = "packages.debian.org" if distro == "debian" else "packages.ubuntu.com" + return f"https://{host}/{codename}/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_brew.py b/abxpkg/binprovider_brew.py index 89f6900c..12feca8a 100755 --- a/abxpkg/binprovider_brew.py +++ b/abxpkg/binprovider_brew.py @@ -268,6 +268,16 @@ def add_bin_dir(path: Path) -> None: self.PATH = TypeAdapter(PATHStr).validate_python(bin_dirs) super().setup_PATH(no_cache=no_cache) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + return f"https://formulae.brew.sh/formula/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_bun.py b/abxpkg/binprovider_bun.py index 61adb846..893ae9b6 100755 --- a/abxpkg/binprovider_bun.py +++ b/abxpkg/binprovider_bun.py @@ -143,6 +143,16 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name, allow_leading_at=True) + if not package: + return None + return f"https://www.npmjs.com/package/{package}" + @computed_field @property def is_valid(self) -> bool: diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index 1b3a5b11..fb93acb1 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -213,6 +213,16 @@ def _cargo_package_specs( package_specs.append(arg) return package_specs or [bin_name] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + return f"https://crates.io/crates/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_deno.py b/abxpkg/binprovider_deno.py index 268c8417..e9e47031 100755 --- a/abxpkg/binprovider_deno.py +++ b/abxpkg/binprovider_deno.py @@ -110,6 +110,43 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + try: + install_args = self.get_install_args(str(bin_name), quiet=True) + except Exception: + install_args = [str(bin_name)] + for arg in install_args or [str(bin_name)]: + if not arg or arg.startswith("-"): + continue + if arg.startswith("npm:"): + spec = arg[4:] + pkg = ( + "@" + spec[1:].split("/", 1)[0] + "/" + spec[1:].split("/", 1)[1].split("@", 1)[0] + if spec.startswith("@") and "/" in spec + else spec.split("@", 1)[0] + ) + if pkg: + return f"https://www.npmjs.com/package/{pkg}" + if arg.startswith("jsr:"): + spec = arg[4:] + pkg = ( + "@" + spec[1:].split("/", 1)[0] + "/" + spec[1:].split("/", 1)[1].split("@", 1)[0] + if spec.startswith("@") and "/" in spec + else spec.split("@", 1)[0] + ) + if pkg: + return f"https://jsr.io/{pkg}" + if "://" in arg: + return arg + pkg = self._docs_url_package_name(bin_name) + if pkg: + return f"https://deno.land/x/{pkg}" + return None + @computed_field @property def is_valid(self) -> bool: diff --git a/abxpkg/binprovider_docker.py b/abxpkg/binprovider_docker.py index 6e5a9722..c9a2a8b8 100755 --- a/abxpkg/binprovider_docker.py +++ b/abxpkg/binprovider_docker.py @@ -92,6 +92,36 @@ def setup( def default_install_args_handler(self, bin_name: BinName, **context) -> InstallArgs: return [f"{bin_name}:latest"] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + try: + install_args = self.get_install_args(str(bin_name), quiet=True) + except Exception: + install_args = [f"{bin_name}:latest"] + ref = next( + (str(arg) for arg in install_args if arg and not arg.startswith("-")), + f"{bin_name}:latest", + ) + # strip digest and tag + image = ref.split("@", 1)[0] + if ":" in image.rsplit("/", 1)[-1]: + image = image.rsplit(":", 1)[0] + # Docker Hub conventions: + # "redis" -> Official image at /_/redis + # "user/repo" -> Community image at /r/user/repo + # "ghcr.io/user/x" -> Not Docker Hub; link to the registry's web UI + if "/" not in image: + return f"https://hub.docker.com/_/{image}" + first, _, rest = image.partition("/") + if "." in first or ":" in first: # custom registry + if first == "ghcr.io": + return f"https://github.com/{rest}/pkgs/container/{rest.split('/')[-1]}" + return f"https://{first}/{rest}" + return f"https://hub.docker.com/r/{image}" + @remap_kwargs({"packages": "install_args"}) def _main_image_ref( self, diff --git a/abxpkg/binprovider_gem.py b/abxpkg/binprovider_gem.py index d0e3f962..aebecaab 100755 --- a/abxpkg/binprovider_gem.py +++ b/abxpkg/binprovider_gem.py @@ -202,6 +202,16 @@ def _patch_generated_wrappers(self) -> None: wrapper_path.write_text(wrapper_text, encoding="utf-8") + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + return f"https://rubygems.org/gems/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_goget.py b/abxpkg/binprovider_goget.py index 5e0f1181..d3821346 100755 --- a/abxpkg/binprovider_goget.py +++ b/abxpkg/binprovider_goget.py @@ -194,6 +194,24 @@ def default_install_args_handler(self, bin_name: BinName, **context) -> InstallA ) return [f"{bin_name}@latest"] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + try: + install_args = self.get_install_args(str(bin_name), quiet=True) + except Exception: + return None + for arg in install_args or []: + if not arg or arg.startswith("-"): + continue + module_path = str(arg).split("@", 1)[0] + if not module_path or "/" not in module_path: + continue + return f"https://pkg.go.dev/{module_path}" + return None + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_nix.py b/abxpkg/binprovider_nix.py index d6a15c64..ba6f24c2 100755 --- a/abxpkg/binprovider_nix.py +++ b/abxpkg/binprovider_nix.py @@ -199,6 +199,19 @@ def _profile_element_name( def default_install_args_handler(self, bin_name: BinName, **context) -> InstallArgs: return [bin_name] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + # Nix flake refs like "nixpkgs#foo" -> use the attr after # + if "#" in package: + package = package.split("#", 1)[-1].split("^", 1)[0] + return f"https://search.nixos.org/packages?show={package}&query={package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_npm.py b/abxpkg/binprovider_npm.py index 4c7ae341..08410680 100755 --- a/abxpkg/binprovider_npm.py +++ b/abxpkg/binprovider_npm.py @@ -169,6 +169,16 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name, allow_leading_at=True) + if not package: + return None + return f"https://www.npmjs.com/package/{package}" + @computed_field @property def is_valid(self) -> bool: diff --git a/abxpkg/binprovider_pip.py b/abxpkg/binprovider_pip.py index f71d853f..2817c070 100755 --- a/abxpkg/binprovider_pip.py +++ b/abxpkg/binprovider_pip.py @@ -653,6 +653,16 @@ def get_cache_info( cache_info["fingerprint_paths"].append(metadata_files[0]) return cache_info + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._package_name_for_bin(bin_name) or str(bin_name) + if not package: + return None + return f"https://pypi.org/project/{package}" + def default_version_handler( self, bin_name: BinName, diff --git a/abxpkg/binprovider_pnpm.py b/abxpkg/binprovider_pnpm.py index 87f1e150..53ad8508 100755 --- a/abxpkg/binprovider_pnpm.py +++ b/abxpkg/binprovider_pnpm.py @@ -139,6 +139,16 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name, allow_leading_at=True) + if not package: + return None + return f"https://www.npmjs.com/package/{package}" + @computed_field @property def is_valid(self) -> bool: diff --git a/abxpkg/binprovider_uv.py b/abxpkg/binprovider_uv.py index 1d30031b..c157508e 100755 --- a/abxpkg/binprovider_uv.py +++ b/abxpkg/binprovider_uv.py @@ -605,6 +605,16 @@ def default_abspath_handler( return TypeAdapter(HostBinPath).validate_python(candidate) return None + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._package_name_for_bin(str(bin_name), **context) or str(bin_name) + if not package: + return None + return f"https://pypi.org/project/{package}" + def default_version_handler( self, bin_name: BinName, diff --git a/abxpkg/binprovider_yarn.py b/abxpkg/binprovider_yarn.py index 972c6700..02cf73ee 100755 --- a/abxpkg/binprovider_yarn.py +++ b/abxpkg/binprovider_yarn.py @@ -160,6 +160,16 @@ def default_install_args_handler( or [str(bin_name)], ) + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name, allow_leading_at=True) + if not package: + return None + return f"https://www.npmjs.com/package/{package}" + @computed_field @property def is_valid(self) -> bool: diff --git a/tests/test_binprovider.py b/tests/test_binprovider.py index 45bbf880..263969f2 100644 --- a/tests/test_binprovider.py +++ b/tests/test_binprovider.py @@ -306,3 +306,32 @@ def test_exec_timeout_is_enforced_for_real_commands(self): timeout=2, quiet=True, ) + + def test_docs_url_returns_provider_specific_link_for_unloaded_binary(self): + from abxpkg import Binary, ShallowBinary + + assert ( + PipProvider().get_docs_url("pip_search") + == "https://pypi.org/project/pip_search" + ) + assert ( + UvProvider().get_docs_url("black") == "https://pypi.org/project/black" + ) + assert ( + NpmProvider().get_docs_url("@puppeteer/browsers") + == "https://www.npmjs.com/package/@puppeteer/browsers" + ) + assert EnvProvider().get_docs_url("python") is None + + # Binary that's not yet loaded falls back across binproviders. + binary = Binary(name="pip_search", binproviders=[EnvProvider(), PipProvider()]) + assert binary.docs_url() == "https://pypi.org/project/pip_search" + + # Loaded ShallowBinary uses the provider it was loaded from. + loaded = ShallowBinary( + name="pip_search", + binprovider=PipProvider(), + version=SemVer.parse("3.2.1"), + abspath=sys.executable, + ) + assert loaded.docs_url() == "https://pypi.org/project/pip_search" From ae8bf1bfa4f9afe401f31c88e87decd4fbb6aa84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:33:06 +0000 Subject: [PATCH 02/18] Fix precheck: ruff format and pyright/ty type errors - Apply ruff formatting to apt/deno providers - Use ShallowBinary.model_validate() with a dict in the docs_url test so both pyright and ty accept the alias-based field names. --- abxpkg/binprovider_apt.py | 4 +++- abxpkg/binprovider_deno.py | 10 ++++++++-- tests/test_binprovider.py | 16 ++++++++-------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/abxpkg/binprovider_apt.py b/abxpkg/binprovider_apt.py index ec0667a6..daa85ffd 100755 --- a/abxpkg/binprovider_apt.py +++ b/abxpkg/binprovider_apt.py @@ -106,7 +106,9 @@ def _detect_distro_codename() -> tuple[str, str]: or "" ).lower() if distro_id in ("ubuntu", "debian"): - return distro_id, codename or ("noble" if distro_id == "ubuntu" else "stable") + return distro_id, codename or ( + "noble" if distro_id == "ubuntu" else "stable" + ) # default to ubuntu LTS for derivatives that don't set a codename return "ubuntu", codename or "noble" diff --git a/abxpkg/binprovider_deno.py b/abxpkg/binprovider_deno.py index e9e47031..42cbe2a7 100755 --- a/abxpkg/binprovider_deno.py +++ b/abxpkg/binprovider_deno.py @@ -125,7 +125,10 @@ def default_docs_url_handler( if arg.startswith("npm:"): spec = arg[4:] pkg = ( - "@" + spec[1:].split("/", 1)[0] + "/" + spec[1:].split("/", 1)[1].split("@", 1)[0] + "@" + + spec[1:].split("/", 1)[0] + + "/" + + spec[1:].split("/", 1)[1].split("@", 1)[0] if spec.startswith("@") and "/" in spec else spec.split("@", 1)[0] ) @@ -134,7 +137,10 @@ def default_docs_url_handler( if arg.startswith("jsr:"): spec = arg[4:] pkg = ( - "@" + spec[1:].split("/", 1)[0] + "/" + spec[1:].split("/", 1)[1].split("@", 1)[0] + "@" + + spec[1:].split("/", 1)[0] + + "/" + + spec[1:].split("/", 1)[1].split("@", 1)[0] if spec.startswith("@") and "/" in spec else spec.split("@", 1)[0] ) diff --git a/tests/test_binprovider.py b/tests/test_binprovider.py index 263969f2..04da7e0e 100644 --- a/tests/test_binprovider.py +++ b/tests/test_binprovider.py @@ -314,9 +314,7 @@ def test_docs_url_returns_provider_specific_link_for_unloaded_binary(self): PipProvider().get_docs_url("pip_search") == "https://pypi.org/project/pip_search" ) - assert ( - UvProvider().get_docs_url("black") == "https://pypi.org/project/black" - ) + assert UvProvider().get_docs_url("black") == "https://pypi.org/project/black" assert ( NpmProvider().get_docs_url("@puppeteer/browsers") == "https://www.npmjs.com/package/@puppeteer/browsers" @@ -328,10 +326,12 @@ def test_docs_url_returns_provider_specific_link_for_unloaded_binary(self): assert binary.docs_url() == "https://pypi.org/project/pip_search" # Loaded ShallowBinary uses the provider it was loaded from. - loaded = ShallowBinary( - name="pip_search", - binprovider=PipProvider(), - version=SemVer.parse("3.2.1"), - abspath=sys.executable, + loaded = ShallowBinary.model_validate( + { + "name": "pip_search", + "binprovider": PipProvider(), + "version": SemVer.parse("3.2.1"), + "abspath": Path(sys.executable), + }, ) assert loaded.docs_url() == "https://pypi.org/project/pip_search" From cbbb28fd79b2490703fb5e8c6d982fa32ab6faf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:39:01 +0000 Subject: [PATCH 03/18] Address cubic review: docker/deno/goget docs_url edge cases - docker: stop emitting bogus GHCR ``github.com//pkgs/container/`` links for 2-segment ``ghcr.io//`` refs and just fall back to a registry-rooted URL (which is the same handling other custom registries get). - deno: defer the ``deno.land/x`` fallback until after a full pass over install_args so explicit ``npm:`` / ``jsr:`` / ``://`` specs always win, even when they aren't first. - goget: skip local install targets (``./``, ``../``, ``/``) and require a module path whose host segment looks like a domain (contains a ``.``) before emitting a ``pkg.go.dev/{path}`` link. - Add ``docs_url`` to the hardcoded overrides on bash/chromewebstore/goget providers so the lookup falls back to ``default_docs_url_handler`` for any bin (otherwise per-bin install_args overrides hit the missing-handler assert). --- abxpkg/binprovider_bash.py | 1 + abxpkg/binprovider_chromewebstore.py | 1 + abxpkg/binprovider_deno.py | 40 ++++++++++++---------------- abxpkg/binprovider_docker.py | 2 -- abxpkg/binprovider_goget.py | 8 +++++- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/abxpkg/binprovider_bash.py b/abxpkg/binprovider_bash.py index 422fe09c..ad94c52e 100755 --- a/abxpkg/binprovider_bash.py +++ b/abxpkg/binprovider_bash.py @@ -58,6 +58,7 @@ class BashProvider(EnvProvider): "install": "self.default_install_handler", "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", + "docs_url": "self.default_docs_url_handler", }, } diff --git a/abxpkg/binprovider_chromewebstore.py b/abxpkg/binprovider_chromewebstore.py index dcd2ccea..0fe6c385 100755 --- a/abxpkg/binprovider_chromewebstore.py +++ b/abxpkg/binprovider_chromewebstore.py @@ -64,6 +64,7 @@ class ChromeWebstoreProvider(BinProvider): "install": "self.chromewebstore_install_handler", "update": "self.chromewebstore_install_handler", "uninstall": "self.chromewebstore_uninstall_handler", + "docs_url": "self.default_docs_url_handler", }, } diff --git a/abxpkg/binprovider_deno.py b/abxpkg/binprovider_deno.py index 42cbe2a7..030e8b8f 100755 --- a/abxpkg/binprovider_deno.py +++ b/abxpkg/binprovider_deno.py @@ -110,6 +110,14 @@ def default_install_args_handler( or [str(bin_name)], ) + @staticmethod + def _strip_registry_prefix_pkg(spec: str) -> str: + """Strip ``@version`` from an npm/jsr spec, preserving ``@scope/name``.""" + if spec.startswith("@") and "/" in spec: + scope, _, after = spec[1:].partition("/") + return "@" + scope + "/" + after.split("@", 1)[0] + return spec.split("@", 1)[0] + def default_docs_url_handler( self, bin_name: BinName, @@ -119,38 +127,24 @@ def default_docs_url_handler( install_args = self.get_install_args(str(bin_name), quiet=True) except Exception: install_args = [str(bin_name)] + # Prefer explicit registry-prefixed specs first, then any URL, and + # only fall back to deno.land/x if no specific target was given. for arg in install_args or [str(bin_name)]: if not arg or arg.startswith("-"): continue if arg.startswith("npm:"): - spec = arg[4:] - pkg = ( - "@" - + spec[1:].split("/", 1)[0] - + "/" - + spec[1:].split("/", 1)[1].split("@", 1)[0] - if spec.startswith("@") and "/" in spec - else spec.split("@", 1)[0] - ) + pkg = self._strip_registry_prefix_pkg(arg[4:]) if pkg: return f"https://www.npmjs.com/package/{pkg}" - if arg.startswith("jsr:"): - spec = arg[4:] - pkg = ( - "@" - + spec[1:].split("/", 1)[0] - + "/" - + spec[1:].split("/", 1)[1].split("@", 1)[0] - if spec.startswith("@") and "/" in spec - else spec.split("@", 1)[0] - ) + elif arg.startswith("jsr:"): + pkg = self._strip_registry_prefix_pkg(arg[4:]) if pkg: return f"https://jsr.io/{pkg}" - if "://" in arg: + elif "://" in arg: return arg - pkg = self._docs_url_package_name(bin_name) - if pkg: - return f"https://deno.land/x/{pkg}" + fallback = self._docs_url_package_name(bin_name) + if fallback: + return f"https://deno.land/x/{fallback}" return None @computed_field diff --git a/abxpkg/binprovider_docker.py b/abxpkg/binprovider_docker.py index c9a2a8b8..b07db48b 100755 --- a/abxpkg/binprovider_docker.py +++ b/abxpkg/binprovider_docker.py @@ -117,8 +117,6 @@ def default_docs_url_handler( return f"https://hub.docker.com/_/{image}" first, _, rest = image.partition("/") if "." in first or ":" in first: # custom registry - if first == "ghcr.io": - return f"https://github.com/{rest}/pkgs/container/{rest.split('/')[-1]}" return f"https://{first}/{rest}" return f"https://hub.docker.com/r/{image}" diff --git a/abxpkg/binprovider_goget.py b/abxpkg/binprovider_goget.py index d3821346..1df9eb15 100755 --- a/abxpkg/binprovider_goget.py +++ b/abxpkg/binprovider_goget.py @@ -55,6 +55,7 @@ class GoGetProvider(BinProvider): "install": "self.default_install_handler", "update": "self.default_update_handler", "uninstall": "self.default_uninstall_handler", + "docs_url": "self.default_docs_url_handler", }, "go": { "version": ["go", "version"], @@ -206,8 +207,13 @@ def default_docs_url_handler( for arg in install_args or []: if not arg or arg.startswith("-"): continue + if arg.startswith(("./", "../", "/")): + continue # local path target — pkg.go.dev can't resolve it module_path = str(arg).split("@", 1)[0] - if not module_path or "/" not in module_path: + # Go module paths must look like a domain followed by a path, + # e.g. ``example.com/foo`` — both a ``/`` and a ``.`` in the host. + host = module_path.split("/", 1)[0] + if "/" not in module_path or "." not in host: continue return f"https://pkg.go.dev/{module_path}" return None From a575c431f1cb89b73160eb64187d7dca9f2d14f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:53:53 +0000 Subject: [PATCH 04/18] Drop redundant BinProvider.docs_url alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BinProvider's getters are spelled get_abspath / get_version / get_install_args / get_packages — no bare aliases. Drop the parallel BinProvider.docs_url(...) shim so docs_url stays consistent with the rest of that surface. The user-facing ShallowBinary.docs_url() method (which matches Binary.install / Binary.load) is unchanged. --- abxpkg/binprovider.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/abxpkg/binprovider.py b/abxpkg/binprovider.py index 8818ff00..2b582447 100755 --- a/abxpkg/binprovider.py +++ b/abxpkg/binprovider.py @@ -2064,15 +2064,6 @@ def get_docs_url( return None return url or None - @log_method_call(include_result=True) - def docs_url( - self, - bin_name: BinName, - quiet: bool = True, - no_cache: bool = False, - ) -> str | None: - return self.get_docs_url(bin_name, quiet=quiet, no_cache=no_cache) - @log_method_call() def setup( self, From a910c5d266f0e07c518713e076ff2837bc70eddb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:56:54 +0000 Subject: [PATCH 05/18] Add docs_url for chromewebstore, ansible, pyinfra - chromewebstore: link to https://chromewebstore.google.com/detail/, preferring the cached webstore_id from /.extension.json and falling back to install_args[0] when no cache exists yet. - ansible / pyinfra: both drive the host's underlying package manager via ansible.builtin.package / pyinfra auto-routing, so route docs URLs through AptProvider._detect_distro_codename() the same way AptProvider does (Ubuntu/Debian packages.* with detected codename, default noble). --- abxpkg/binprovider_ansible.py | 17 +++++++++++++++++ abxpkg/binprovider_chromewebstore.py | 20 ++++++++++++++++++++ abxpkg/binprovider_pyinfra.py | 16 ++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/abxpkg/binprovider_ansible.py b/abxpkg/binprovider_ansible.py index 19e4f2d2..c8fc6cf6 100755 --- a/abxpkg/binprovider_ansible.py +++ b/abxpkg/binprovider_ansible.py @@ -357,6 +357,23 @@ def get_ansible_module_extra_kwargs(self) -> dict[str, Any]: """Return provider-specific kwargs to splice into the ansible module block.""" return {} + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + # ansible.builtin.package uses the host's underlying package manager, + # so route docs URLs the same way AptProvider does (Ubuntu/Debian/... + # detected from /etc/os-release, with a sensible default fallback). + from .binprovider_apt import AptProvider + + distro, codename = AptProvider._detect_distro_codename() + host = "packages.debian.org" if distro == "debian" else "packages.ubuntu.com" + return f"https://{host}/{codename}/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, diff --git a/abxpkg/binprovider_chromewebstore.py b/abxpkg/binprovider_chromewebstore.py index 0fe6c385..a4fd98ce 100755 --- a/abxpkg/binprovider_chromewebstore.py +++ b/abxpkg/binprovider_chromewebstore.py @@ -120,6 +120,26 @@ def chromewebstore_install_args_handler( """Default to `` --name=`` install args for extensions.""" return [bin_name, f"--name={bin_name}"] + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + # Prefer the cached webstore_id if we have one (set after install); + # otherwise fall back to install_args[0], which is the configured id. + cached = self._cached_extension(str(bin_name)) + webstore_id = str(cached.get("webstore_id") or "").strip() + if not webstore_id: + try: + install_args = list(self.get_install_args(bin_name, quiet=True)) + except Exception: + install_args = [] + if install_args: + webstore_id = str(install_args[0]).strip() + if not webstore_id: + return None + return f"https://chromewebstore.google.com/detail/{webstore_id}" + def _cached_extension(self, bin_name: str) -> dict[str, Any]: """Load the persisted extension metadata JSON for a cached extension, if any.""" bin_dir = self.bin_dir diff --git a/abxpkg/binprovider_pyinfra.py b/abxpkg/binprovider_pyinfra.py index ee299405..e28bbbb1 100755 --- a/abxpkg/binprovider_pyinfra.py +++ b/abxpkg/binprovider_pyinfra.py @@ -279,6 +279,22 @@ def INSTALLER_BINARY(self, no_cache: bool = False): ) return loaded + def default_docs_url_handler( + self, + bin_name: BinName, + **context, + ) -> str | None: + package = self._docs_url_package_name(bin_name) + if not package: + return None + # pyinfra auto-routes to the host's package manager (brew/apt/etc), + # so route docs URLs the same way AptProvider does. + from .binprovider_apt import AptProvider + + distro, codename = AptProvider._detect_distro_codename() + host = "packages.debian.org" if distro == "debian" else "packages.ubuntu.com" + return f"https://{host}/{codename}/{package}" + @remap_kwargs({"packages": "install_args"}) def default_install_handler( self, From cf6baa7776b7f9bce1948fb4b747733fb4d3b115 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 23:02:56 +0000 Subject: [PATCH 06/18] Address cubic review: route ansible/pyinfra docs_url per OS Previously ansible/pyinfra hardcoded packages.ubuntu.com URLs even on macOS (where pyinfra installs via Homebrew) or non-debian linux hosts. - AptProvider: split _detect_distro_codename into a raw _read_os_release helper (no fallback) and the existing apt-specific helper that keeps the ubuntu/noble default. Add a shared os_package_docs_url(package) classmethod for meta-installers. - AnsibleProvider / PyinfraProvider: route through that helper, which returns: macOS -> https://formulae.brew.sh/formula/ Ubuntu -> https://packages.ubuntu.com// Debian -> https://packages.debian.org// other -> None (so the next provider can take over) Also fix ChromeWebstoreProvider.default_docs_url_handler to read the cached extension name under the same ``\"name\"`` key that ``_extension_spec`` writes, falling back to the ``--name=`` install_arg. --- abxpkg/binprovider_ansible.py | 11 ++++----- abxpkg/binprovider_apt.py | 35 +++++++++++++++++++++++--- abxpkg/binprovider_chromewebstore.py | 37 ++++++++++++++++++++-------- abxpkg/binprovider_pyinfra.py | 9 +++---- 4 files changed, 67 insertions(+), 25 deletions(-) diff --git a/abxpkg/binprovider_ansible.py b/abxpkg/binprovider_ansible.py index c8fc6cf6..a274dbb4 100755 --- a/abxpkg/binprovider_ansible.py +++ b/abxpkg/binprovider_ansible.py @@ -365,14 +365,13 @@ def default_docs_url_handler( package = self._docs_url_package_name(bin_name) if not package: return None - # ansible.builtin.package uses the host's underlying package manager, - # so route docs URLs the same way AptProvider does (Ubuntu/Debian/... - # detected from /etc/os-release, with a sensible default fallback). + # ansible.builtin.package routes to whatever package manager the host + # actually has, so the docs URL has to follow. We only emit one for + # hosts we can confidently route (Homebrew on macOS, Ubuntu/Debian + # apt) — anything else returns None so the caller falls through. from .binprovider_apt import AptProvider - distro, codename = AptProvider._detect_distro_codename() - host = "packages.debian.org" if distro == "debian" else "packages.ubuntu.com" - return f"https://{host}/{codename}/{package}" + return AptProvider.os_package_docs_url(package) @remap_kwargs({"packages": "install_args"}) def default_install_handler( diff --git a/abxpkg/binprovider_apt.py b/abxpkg/binprovider_apt.py index daa85ffd..7dbd88df 100755 --- a/abxpkg/binprovider_apt.py +++ b/abxpkg/binprovider_apt.py @@ -9,7 +9,7 @@ from .base_types import BinProviderName, PATHStr, BinName, InstallArgs from .semver import SemVer -from .binprovider import BinProvider, EnvProvider, remap_kwargs +from .binprovider import BinProvider, EnvProvider, OPERATING_SYSTEM, remap_kwargs from .logging import format_subprocess_output _LAST_UPDATE_CHECK = None @@ -86,8 +86,8 @@ def setup_PATH(self, no_cache: bool = False) -> None: super().setup_PATH(no_cache=no_cache) @staticmethod - def _detect_distro_codename() -> tuple[str, str]: - """Return (distro_id, codename) parsed from /etc/os-release with fallbacks.""" + def _read_os_release() -> tuple[str, str]: + """Return raw (distro_id, codename) parsed from /etc/os-release ('' if missing).""" os_release: dict[str, str] = {} try: with open("/etc/os-release", encoding="utf-8") as fh: @@ -105,13 +105,40 @@ def _detect_distro_codename() -> tuple[str, str]: or os_release.get("UBUNTU_CODENAME") or "" ).lower() + return distro_id, codename + + @staticmethod + def _detect_distro_codename() -> tuple[str, str]: + """Return (distro_id, codename) for apt usage, defaulting to ubuntu/noble.""" + distro_id, codename = AptProvider._read_os_release() if distro_id in ("ubuntu", "debian"): return distro_id, codename or ( "noble" if distro_id == "ubuntu" else "stable" ) - # default to ubuntu LTS for derivatives that don't set a codename + # apt is debian-derived; fall back to ubuntu LTS for derivatives. return "ubuntu", codename or "noble" + @staticmethod + def os_package_docs_url(package: str) -> str | None: + """Return a docs URL for an OS-level package, or None if the host isn't recognized. + + Used by meta-installers (ansible, pyinfra) that drive whatever package + manager the host happens to have. Only returns a URL when we can + confidently route it (Homebrew on macOS, packages.ubuntu.com / + packages.debian.org on Ubuntu/Debian); other hosts get None so the + caller can fall back to the next provider. + """ + if not package: + return None + if OPERATING_SYSTEM == "darwin": + return f"https://formulae.brew.sh/formula/{package}" + distro_id, codename = AptProvider._read_os_release() + if distro_id == "ubuntu": + return f"https://packages.ubuntu.com/{codename or 'noble'}/{package}" + if distro_id == "debian": + return f"https://packages.debian.org/{codename or 'stable'}/{package}" + return None + def default_docs_url_handler( self, bin_name: BinName, diff --git a/abxpkg/binprovider_chromewebstore.py b/abxpkg/binprovider_chromewebstore.py index a4fd98ce..c7f331a5 100755 --- a/abxpkg/binprovider_chromewebstore.py +++ b/abxpkg/binprovider_chromewebstore.py @@ -120,24 +120,41 @@ def chromewebstore_install_args_handler( """Default to `` --name=`` install args for extensions.""" return [bin_name, f"--name={bin_name}"] + @staticmethod + def _docs_url_name_slug(name: str) -> str: + """Slugify an extension name into the URL-safe form used by the Web Store.""" + cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in name.strip()) + # collapse runs of hyphens and trim leading/trailing ones + while "--" in cleaned: + cleaned = cleaned.replace("--", "-") + return cleaned.strip("-") + def default_docs_url_handler( self, bin_name: BinName, **context, ) -> str | None: - # Prefer the cached webstore_id if we have one (set after install); - # otherwise fall back to install_args[0], which is the configured id. + # Prefer the cached webstore_id/extension_name if we have them (set + # after install); otherwise fall back to install_args, which is the + # configured ``[, --name=]`` pair. cached = self._cached_extension(str(bin_name)) - webstore_id = str(cached.get("webstore_id") or "").strip() - if not webstore_id: - try: - install_args = list(self.get_install_args(bin_name, quiet=True)) - except Exception: - install_args = [] - if install_args: - webstore_id = str(install_args[0]).strip() + try: + install_args = list(self.get_install_args(bin_name, quiet=True)) + except Exception: + install_args = [] + webstore_id = str( + cached.get("webstore_id") or (install_args[0] if install_args else ""), + ).strip() if not webstore_id: return None + extension_name = str( + cached.get("name") + or cached.get("extension_name") + or self._extension_name(str(bin_name), install_args), + ) + slug = self._docs_url_name_slug(extension_name) + if slug and slug != webstore_id: + return f"https://chromewebstore.google.com/detail/{slug}/{webstore_id}" return f"https://chromewebstore.google.com/detail/{webstore_id}" def _cached_extension(self, bin_name: str) -> dict[str, Any]: diff --git a/abxpkg/binprovider_pyinfra.py b/abxpkg/binprovider_pyinfra.py index e28bbbb1..b0cc5a83 100755 --- a/abxpkg/binprovider_pyinfra.py +++ b/abxpkg/binprovider_pyinfra.py @@ -287,13 +287,12 @@ def default_docs_url_handler( package = self._docs_url_package_name(bin_name) if not package: return None - # pyinfra auto-routes to the host's package manager (brew/apt/etc), - # so route docs URLs the same way AptProvider does. + # pyinfra auto-routes to brew on macOS and apt on Debian/Ubuntu; only + # emit URLs for hosts we recognize and return None for everything else + # so callers can fall back to the next provider. from .binprovider_apt import AptProvider - distro, codename = AptProvider._detect_distro_codename() - host = "packages.debian.org" if distro == "debian" else "packages.ubuntu.com" - return f"https://{host}/{codename}/{package}" + return AptProvider.os_package_docs_url(package) @remap_kwargs({"packages": "install_args"}) def default_install_handler( From 5669a5a68291870df88ecb02dda7f89d09f78fa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 23:09:19 +0000 Subject: [PATCH 07/18] Inline OS-routing in ansible/pyinfra docs_url for cleaner separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the shared os_package_docs_url() helper from AptProvider; it gave apt knowledge of brew and turned apt into a meta-installer router, which wasn't its job. - AptProvider: back to its single apt-specific _detect_distro_codename() with the ubuntu/noble fallback. No knowledge of brew/ansible/pyinfra. - AnsibleProvider / PyinfraProvider: each inlines its own /etc/os-release read + macOS check. The duplication is fine — it keeps each provider's knowledge of the host package-manager landscape inside that provider. --- abxpkg/binprovider_ansible.py | 33 +++++++++++++++++++++++++++------ abxpkg/binprovider_apt.py | 33 +++------------------------------ abxpkg/binprovider_pyinfra.py | 31 ++++++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/abxpkg/binprovider_ansible.py b/abxpkg/binprovider_ansible.py index a274dbb4..5e4642d3 100755 --- a/abxpkg/binprovider_ansible.py +++ b/abxpkg/binprovider_ansible.py @@ -366,12 +366,33 @@ def default_docs_url_handler( if not package: return None # ansible.builtin.package routes to whatever package manager the host - # actually has, so the docs URL has to follow. We only emit one for - # hosts we can confidently route (Homebrew on macOS, Ubuntu/Debian - # apt) — anything else returns None so the caller falls through. - from .binprovider_apt import AptProvider - - return AptProvider.os_package_docs_url(package) + # actually has, so the docs URL has to follow. Only emit a URL for + # hosts we recognize; anything else returns None so the caller can + # fall back to the next provider. + if OPERATING_SYSTEM == "darwin": + return f"https://formulae.brew.sh/formula/{package}" + distro_id, codename = "", "" + try: + with open("/etc/os-release", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or "=" not in line or line.startswith("#"): + continue + key, _, value = line.partition("=") + value = value.strip().strip('"').strip("'") + if key == "ID": + distro_id = value.lower() + elif ( + key in ("VERSION_CODENAME", "UBUNTU_CODENAME") and not codename + ): + codename = value.lower() + except OSError: + return None + if distro_id == "ubuntu": + return f"https://packages.ubuntu.com/{codename or 'noble'}/{package}" + if distro_id == "debian": + return f"https://packages.debian.org/{codename or 'stable'}/{package}" + return None @remap_kwargs({"packages": "install_args"}) def default_install_handler( diff --git a/abxpkg/binprovider_apt.py b/abxpkg/binprovider_apt.py index 7dbd88df..632cc887 100755 --- a/abxpkg/binprovider_apt.py +++ b/abxpkg/binprovider_apt.py @@ -9,7 +9,7 @@ from .base_types import BinProviderName, PATHStr, BinName, InstallArgs from .semver import SemVer -from .binprovider import BinProvider, EnvProvider, OPERATING_SYSTEM, remap_kwargs +from .binprovider import BinProvider, EnvProvider, remap_kwargs from .logging import format_subprocess_output _LAST_UPDATE_CHECK = None @@ -86,8 +86,8 @@ def setup_PATH(self, no_cache: bool = False) -> None: super().setup_PATH(no_cache=no_cache) @staticmethod - def _read_os_release() -> tuple[str, str]: - """Return raw (distro_id, codename) parsed from /etc/os-release ('' if missing).""" + def _detect_distro_codename() -> tuple[str, str]: + """Return (distro_id, codename) parsed from /etc/os-release with apt fallbacks.""" os_release: dict[str, str] = {} try: with open("/etc/os-release", encoding="utf-8") as fh: @@ -105,12 +105,6 @@ def _read_os_release() -> tuple[str, str]: or os_release.get("UBUNTU_CODENAME") or "" ).lower() - return distro_id, codename - - @staticmethod - def _detect_distro_codename() -> tuple[str, str]: - """Return (distro_id, codename) for apt usage, defaulting to ubuntu/noble.""" - distro_id, codename = AptProvider._read_os_release() if distro_id in ("ubuntu", "debian"): return distro_id, codename or ( "noble" if distro_id == "ubuntu" else "stable" @@ -118,27 +112,6 @@ def _detect_distro_codename() -> tuple[str, str]: # apt is debian-derived; fall back to ubuntu LTS for derivatives. return "ubuntu", codename or "noble" - @staticmethod - def os_package_docs_url(package: str) -> str | None: - """Return a docs URL for an OS-level package, or None if the host isn't recognized. - - Used by meta-installers (ansible, pyinfra) that drive whatever package - manager the host happens to have. Only returns a URL when we can - confidently route it (Homebrew on macOS, packages.ubuntu.com / - packages.debian.org on Ubuntu/Debian); other hosts get None so the - caller can fall back to the next provider. - """ - if not package: - return None - if OPERATING_SYSTEM == "darwin": - return f"https://formulae.brew.sh/formula/{package}" - distro_id, codename = AptProvider._read_os_release() - if distro_id == "ubuntu": - return f"https://packages.ubuntu.com/{codename or 'noble'}/{package}" - if distro_id == "debian": - return f"https://packages.debian.org/{codename or 'stable'}/{package}" - return None - def default_docs_url_handler( self, bin_name: BinName, diff --git a/abxpkg/binprovider_pyinfra.py b/abxpkg/binprovider_pyinfra.py index b0cc5a83..739ea652 100755 --- a/abxpkg/binprovider_pyinfra.py +++ b/abxpkg/binprovider_pyinfra.py @@ -288,11 +288,32 @@ def default_docs_url_handler( if not package: return None # pyinfra auto-routes to brew on macOS and apt on Debian/Ubuntu; only - # emit URLs for hosts we recognize and return None for everything else - # so callers can fall back to the next provider. - from .binprovider_apt import AptProvider - - return AptProvider.os_package_docs_url(package) + # emit URLs for hosts we recognize and return None for everything + # else so callers can fall back to the next provider. + if OPERATING_SYSTEM == "darwin": + return f"https://formulae.brew.sh/formula/{package}" + distro_id, codename = "", "" + try: + with open("/etc/os-release", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or "=" not in line or line.startswith("#"): + continue + key, _, value = line.partition("=") + value = value.strip().strip('"').strip("'") + if key == "ID": + distro_id = value.lower() + elif ( + key in ("VERSION_CODENAME", "UBUNTU_CODENAME") and not codename + ): + codename = value.lower() + except OSError: + return None + if distro_id == "ubuntu": + return f"https://packages.ubuntu.com/{codename or 'noble'}/{package}" + if distro_id == "debian": + return f"https://packages.debian.org/{codename or 'stable'}/{package}" + return None @remap_kwargs({"packages": "install_args"}) def default_install_handler( From aec7b87a5fd9bcb6a85e19aab485330b2899fac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 23:43:47 +0000 Subject: [PATCH 08/18] CargoProvider: validate cargo --version + rustup-init fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: CI runner had a partially-broken brew install of rust where ``/home/linuxbrew/.linuxbrew/bin/cargo`` exists on PATH but fails to run because its ``libllhttp.so.9.3`` dependency has been removed. Both ``test_cargoprovider`` and ``test_central_lib_dir`` saw the broken cargo return code 127 with the libllhttp error. The base ``BinProvider.load`` does a version probe so it should already reject this binary, but downstream provider caches (derived.env entries, shim symlink fingerprints) can persist a "valid" record that bypasses the probe — which is exactly what was happening on the stale runner. Fix: - Add ``CargoProvider._cargo_executes(abspath)``: runs ``cargo --version`` with no caching and returns False if the binary fails to execute or its output isn't a parseable SemVer. This is the final, no-cache check that guards every code path in ``INSTALLER_BINARY``. - Treat both the cached ``_INSTALLER_BINARY`` and ``super().INSTALLER_BINARY`` result as broken when ``_cargo_executes`` says no, falling through to the install path instead of returning a binary that won't run. - Wrap the env+brew+apt+nix install attempt in try/except (it raises ``BinaryInstallError`` when every provider fails) and require the upgraded binary to also execute cleanly. - Add a final ``_install_via_rustup`` fallback that downloads ``sh.rustup.rs`` and runs ``rustup-init -y --no-modify-path --default-toolchain stable --profile minimal`` into ``$CARGO_HOME``. Rustup is the canonical OS-agnostic cargo installer, doesn't need root, and bypasses any broken brew/apt/nix state. Returns the resulting cargo's abspath when it works. - If everything fails, raise ``BinProviderUnavailableError`` instead of the previous ``assert loaded is not None`` (which crashed with a bare ``AssertionError`` and no info). --- abxpkg/binprovider_cargo.py | 141 +++++++++++++++++++++++++++++++++--- 1 file changed, 132 insertions(+), 9 deletions(-) diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index fb93acb1..b301f686 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -2,6 +2,8 @@ __package__ = "abxpkg" import os +import subprocess +import urllib.request from pathlib import Path @@ -17,11 +19,13 @@ ) from .semver import SemVer from .binprovider import BinProvider, EnvProvider, log_method_call, remap_kwargs -from .logging import format_subprocess_output +from .logging import format_subprocess_output, get_logger DEFAULT_CARGO_HOME = Path(os.environ.get("CARGO_HOME", "~/.cargo")).expanduser() MIN_CARGO_INSTALLER_VERSION = cast(SemVer, SemVer.parse("1.85.0")) +RUSTUP_INIT_URL = "https://sh.rustup.rs" +logger = get_logger(__name__) class CargoProvider(BinProvider): @@ -93,6 +97,7 @@ def INSTALLER_BINARY(self, no_cache: bool = False): if ( cached_version is not None and cached_version >= MIN_CARGO_INSTALLER_VERSION + and self._cargo_executes(cached_installer.loaded_abspath) ): return cached_installer @@ -107,9 +112,14 @@ def INSTALLER_BINARY(self, no_cache: bool = False): if ( loaded_version is not None and loaded_version >= MIN_CARGO_INSTALLER_VERSION + and self._cargo_executes(loaded.loaded_abspath) ): self._INSTALLER_BINARY = loaded return loaded + # Discovered binary doesn't actually run (e.g. linuxbrew's cargo + # missing libllhttp.so on a stale CI image). Drop it so the + # install path below kicks in instead of returning a broken bin. + loaded = None raw_provider_names = os.environ.get("ABXPKG_BINPROVIDERS") selected_provider_names = ( @@ -137,17 +147,130 @@ def INSTALLER_BINARY(self, no_cache: bool = False): if not installer_providers: installer_providers = [env_provider] - upgraded = Binary( - name=self.INSTALLER_BIN, - min_version=MIN_CARGO_INSTALLER_VERSION, - binproviders=installer_providers, - ).install(no_cache=no_cache) - if upgraded and upgraded.loaded_abspath: + try: + upgraded = Binary( + name=self.INSTALLER_BIN, + min_version=MIN_CARGO_INSTALLER_VERSION, + binproviders=installer_providers, + ).install(no_cache=no_cache) + except Exception: + upgraded = None + if ( + upgraded + and upgraded.loaded_abspath + and self._cargo_executes(upgraded.loaded_abspath) + ): self._INSTALLER_BINARY = upgraded return upgraded - assert loaded is not None - return loaded + # Last-resort fallback: bootstrap a hermetic rust toolchain via the + # canonical rustup-init installer. This bypasses any broken/partial + # system rust installs (e.g. linuxbrew's cargo missing libllhttp.so + # on a stale CI image) and never depends on root/sudo. + rustup_cargo = self._install_via_rustup(no_cache=no_cache) + if rustup_cargo is not None: + rustup_loaded = EnvProvider( + install_root=None, + bin_dir=None, + PATH=str(rustup_cargo.parent), + ).load(self.INSTALLER_BIN, no_cache=no_cache) + if rustup_loaded and rustup_loaded.loaded_abspath: + self._INSTALLER_BINARY = rustup_loaded + return rustup_loaded + + from .exceptions import BinProviderUnavailableError + + raise BinProviderUnavailableError( + self.__class__.__name__, + self.INSTALLER_BIN, + ) + + def _cargo_executes(self, abspath) -> bool: + """Return True iff `` --version`` exits cleanly with parseable output. + + Guards against partially broken cargo installs where the binary exists + on PATH but won't actually run (e.g. brew's cargo dynamically linked + to a libllhttp that's been removed). The base BinProvider load() does + a version probe, but providers can persist cache entries that bypass + it; this is a final, no-cache executable check. + """ + if abspath is None: + return False + try: + proc = subprocess.run( + [str(abspath), "--version"], + capture_output=True, + text=True, + timeout=self.version_timeout, + ) + except (OSError, subprocess.SubprocessError): + return False + if proc.returncode != 0: + return False + return bool(SemVer.parse(proc.stdout.strip() or proc.stderr.strip())) + + def _install_via_rustup(self, no_cache: bool = False) -> Path | None: + """Bootstrap rust via rustup-init.sh into CARGO_HOME and return cargo's abspath. + + Used as a last-resort installer when no other BinProvider can produce + a working ``cargo`` binary. Honors ``$CARGO_HOME`` when set and + installs a minimal stable profile so this runs in 60-90s on CI. + """ + cargo_home = DEFAULT_CARGO_HOME + cargo_home.mkdir(parents=True, exist_ok=True) + rustup_init = cargo_home / "rustup-init.sh" + try: + with urllib.request.urlopen(RUSTUP_INIT_URL, timeout=30) as response: + rustup_init.write_bytes(response.read()) + except Exception as err: + logger.warning( + "Failed to download rustup-init from %s: %s", + RUSTUP_INIT_URL, + err, + ) + return None + rustup_init.chmod(0o755) + + env = { + **os.environ, + "CARGO_HOME": str(cargo_home), + "RUSTUP_HOME": str(cargo_home / ".rustup"), + } + try: + proc = subprocess.run( + [ + "sh", + str(rustup_init), + "-y", + "--no-modify-path", + "--default-toolchain", + "stable", + "--profile", + "minimal", + ], + capture_output=True, + text=True, + timeout=max(self.install_timeout, 600), + env=env, + ) + except (OSError, subprocess.SubprocessError) as err: + logger.warning("rustup-init failed to run: %s", err) + return None + finally: + rustup_init.unlink(missing_ok=True) + + if proc.returncode != 0: + logger.warning( + "rustup-init exited with %s: %s", + proc.returncode, + format_subprocess_output(proc.stdout, proc.stderr), + ) + return None + + cargo_path = cargo_home / "bin" / "cargo" + if cargo_path.is_file() and self._cargo_executes(cargo_path): + return cargo_path + return None @log_method_call() def setup( From 11425d1bd7bfdf808bbc5b29efeab8325cb14e55 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 23:48:53 +0000 Subject: [PATCH 09/18] Address cubic review: verify rustup-init SHA-256 before exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the rustup fallback piped ``sh.rustup.rs`` straight to ``sh``, which gives an in-flight tamperer a path to RCE on the host even though the URL is HTTPS-only. Replace the ``curl | sh``-style flow with a verified binary download: - Detect the host target triple (x86_64/aarch64 × linux-gnu/apple-darwin); unsupported hosts skip the rustup fallback entirely. - Download both ``rustup-init`` and its published ``rustup-init.sha256`` sidecar from ``https://static.rust-lang.org/rustup/dist//``. - Reject the download unless the sidecar's first token is a clean 64-char lowercase SHA-256 and matches the actual hash of the binary. - Only then chmod +x and exec the binary directly (no shell). If verification fails or any download errors, log and return None so the caller falls through to ``BinProviderUnavailableError`` instead of running unverified code. --- abxpkg/binprovider_cargo.py | 84 +++++++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index b301f686..3dd5a34f 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 __package__ = "abxpkg" +import hashlib import os +import platform import subprocess import urllib.request @@ -24,7 +26,10 @@ DEFAULT_CARGO_HOME = Path(os.environ.get("CARGO_HOME", "~/.cargo")).expanduser() MIN_CARGO_INSTALLER_VERSION = cast(SemVer, SemVer.parse("1.85.0")) -RUSTUP_INIT_URL = "https://sh.rustup.rs" +# Canonical rust-lang.org distribution. Each ``rustup-init`` binary has a +# sibling ``rustup-init.sha256`` file we use to verify the download — we +# never run a ``curl sh.rustup.rs | sh``-style unverified installer. +RUSTUP_DIST_BASE = "https://static.rust-lang.org/rustup/dist" logger = get_logger(__name__) @@ -209,26 +214,92 @@ def _cargo_executes(self, abspath) -> bool: return False return bool(SemVer.parse(proc.stdout.strip() or proc.stderr.strip())) + @staticmethod + def _rustup_target_triple() -> str | None: + """Return the rust-lang.org target triple for the current host, or None. + + Only the four targets we publish CI/dev support for are returned — + unknown hosts get None so the rustup fallback is skipped instead of + downloading a binary that doesn't match the host ABI. + """ + machine = platform.machine().lower() + system = platform.system().lower() + arch = { + "x86_64": "x86_64", + "amd64": "x86_64", + "aarch64": "aarch64", + "arm64": "aarch64", + }.get(machine) + if arch is None: + return None + if system == "linux": + return f"{arch}-unknown-linux-gnu" + if system == "darwin": + return f"{arch}-apple-darwin" + return None + def _install_via_rustup(self, no_cache: bool = False) -> Path | None: - """Bootstrap rust via rustup-init.sh into CARGO_HOME and return cargo's abspath. + """Bootstrap rust via the rustup-init binary into CARGO_HOME and return cargo's abspath. Used as a last-resort installer when no other BinProvider can produce a working ``cargo`` binary. Honors ``$CARGO_HOME`` when set and installs a minimal stable profile so this runs in 60-90s on CI. + + The downloaded ``rustup-init`` binary is verified against the + ``rustup-init.sha256`` sidecar file published alongside it on + ``static.rust-lang.org`` BEFORE we exec it, so a tampered download + won't run. """ + triple = self._rustup_target_triple() + if triple is None: + logger.warning( + "Unsupported host for rustup fallback: %s %s", + platform.system(), + platform.machine(), + ) + return None + cargo_home = DEFAULT_CARGO_HOME cargo_home.mkdir(parents=True, exist_ok=True) - rustup_init = cargo_home / "rustup-init.sh" + binary_url = f"{RUSTUP_DIST_BASE}/{triple}/rustup-init" + sha_url = f"{binary_url}.sha256" try: - with urllib.request.urlopen(RUSTUP_INIT_URL, timeout=30) as response: - rustup_init.write_bytes(response.read()) + with urllib.request.urlopen(binary_url, timeout=60) as response: + binary_bytes = response.read() + with urllib.request.urlopen(sha_url, timeout=30) as response: + sha_text = response.read().decode("utf-8", errors="replace") except Exception as err: logger.warning( "Failed to download rustup-init from %s: %s", - RUSTUP_INIT_URL, + binary_url, err, ) return None + + # The sidecar is a single line: " rustup-init". Take the first + # 64 hex chars and reject anything that isn't a clean SHA-256. + expected_sha = (sha_text.strip().split() or [""])[0].lower() + if len(expected_sha) != 64 or any( + ch not in "0123456789abcdef" for ch in expected_sha + ): + logger.warning( + "rustup-init.sha256 sidecar at %s is malformed: %r", + sha_url, + sha_text[:120], + ) + return None + + actual_sha = hashlib.sha256(binary_bytes).hexdigest() + if actual_sha != expected_sha: + logger.warning( + "rustup-init SHA-256 mismatch (got %s, expected %s) — refusing to run", + actual_sha, + expected_sha, + ) + return None + + rustup_init = cargo_home / "rustup-init" + rustup_init.write_bytes(binary_bytes) rustup_init.chmod(0o755) env = { @@ -239,7 +310,6 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: try: proc = subprocess.run( [ - "sh", str(rustup_init), "-y", "--no-modify-path", From 78e7d7743245bca2fa1959e11eb73d5d3ea30e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 00:01:27 +0000 Subject: [PATCH 10/18] Surface why CargoProvider falls back to rustup, catch raw mkdir errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous CI run still failed with ``BinProviderUnavailableError`` without any rustup diagnostic — the fallback path was apparently returning ``None`` silently. Make the fallback observable so the next CI run tells us why: - Emit a warning when env/brew/apt/nix all fail to produce a working cargo, just before invoking the rustup-init fallback. That single line confirms we reached the fallback branch. - Wrap the fallback call in try/except so any unexpected exception is logged instead of being lost between providers. - Wrap ``cargo_home.mkdir(parents=True, exist_ok=True)`` in its own try/except so a permissions issue under ``$CARGO_HOME`` produces a clear warning rather than an uncaught ``OSError``. - Log the verified-download URL before fetching, so download failures show which mirror was attempted. --- abxpkg/binprovider_cargo.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index 3dd5a34f..f1704aab 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -172,7 +172,19 @@ def INSTALLER_BINARY(self, no_cache: bool = False): # canonical rustup-init installer. This bypasses any broken/partial # system rust installs (e.g. linuxbrew's cargo missing libllhttp.so # on a stale CI image) and never depends on root/sudo. - rustup_cargo = self._install_via_rustup(no_cache=no_cache) + logger.warning( + "%s: no working cargo from env/brew/apt/nix; falling back to rustup-init", + self.__class__.__name__, + ) + try: + rustup_cargo = self._install_via_rustup(no_cache=no_cache) + except Exception as err: + logger.warning( + "%s: rustup-init fallback raised %r", + self.__class__.__name__, + err, + ) + rustup_cargo = None if rustup_cargo is not None: rustup_loaded = EnvProvider( install_root=None, @@ -260,9 +272,18 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: return None cargo_home = DEFAULT_CARGO_HOME - cargo_home.mkdir(parents=True, exist_ok=True) + try: + cargo_home.mkdir(parents=True, exist_ok=True) + except OSError as err: + logger.warning("rustup fallback: cannot create %s: %s", cargo_home, err) + return None + binary_url = f"{RUSTUP_DIST_BASE}/{triple}/rustup-init" sha_url = f"{binary_url}.sha256" + logger.warning( + "rustup fallback: downloading verified rustup-init from %s", + binary_url, + ) try: with urllib.request.urlopen(binary_url, timeout=60) as response: binary_bytes = response.read() From 8517f1037796851dd5c36daf72e19ceddfc2ebd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 00:51:35 +0000 Subject: [PATCH 11/18] Fix install-chain wedges (apt setup_PATH recursion, mutual-bootstrap deadlock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulled in from PR #35 — these are orthogonal install-chain fixes that unblock ``CargoProvider.INSTALLER_BINARY``'s cross-provider attempts on broken-runner CI environments where my new validation correctly rejects the libllhttp-broken brew cargo and we need apt/nix to actually be able to run. - ``AptProvider.setup_PATH``: seed ``self.PATH`` with apt-get's bin_dir before invoking ``self.exec(dpkg -L bash)``. ``self.exec``'s ``build_exec_env`` re-enters ``self.setup_PATH``; without a non-empty PATH the ``not self.PATH`` guard fires on every recursive entry and loops forever. - ``BinProvider.INSTALLER_BINARY``: drop cross-providers whose own bootstrap chain would mutually recurse back through ``self``. Only active when ``env`` is NOT in the selected provider list — the env fallback short-circuits the cycle in the default configuration so legitimate cross-provider bootstraps (cargo installed via brew, etc.) keep working. --- abxpkg/binprovider.py | 17 +++++++++++++++++ abxpkg/binprovider_apt.py | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/abxpkg/binprovider.py b/abxpkg/binprovider.py index 2b582447..2f23cf23 100755 --- a/abxpkg/binprovider.py +++ b/abxpkg/binprovider.py @@ -880,6 +880,18 @@ def INSTALLER_BINARY(self, no_cache: bool = False) -> ShallowBinary: if raw_provider_names or not self.INSTALLER_BINPROVIDERS else list(self.INSTALLER_BINPROVIDERS) ) + # When ``env`` isn't in the user-selected provider list, drop any + # cross-provider whose own INSTALLER_BIN bootstrap chain would + # include ``self`` — adding it back here would deadlock (e.g. + # ``--binproviders=apt,npm`` has apt resolve apt-get via + # ``npm.install`` while npm simultaneously resolves npm via + # ``apt.install``). When env IS in the selected list (the default + # provider configuration), the cycle never materializes because + # ``Binary([env, ...]).install`` finds the installer on the host + # PATH before any cross-provider attempt runs, so we skip the + # filter entirely to preserve legitimate cross-provider bootstraps + # like ``cargo`` installed via ``brew``. + env_in_selected = "env" in selected_provider_names installer_provider_names = [ provider_name for provider_name in preferred_provider_names @@ -887,6 +899,11 @@ def INSTALLER_BINARY(self, no_cache: bool = False) -> ShallowBinary: and provider_name in selected_provider_names and provider_name in PROVIDER_CLASS_BY_NAME and provider_name != self.name + and ( + provider_name == "env" + or env_in_selected + or self.name not in selected_provider_names + ) ] installer_providers: list[BinProvider] = [ env_provider diff --git a/abxpkg/binprovider_apt.py b/abxpkg/binprovider_apt.py index 632cc887..2d73f684 100755 --- a/abxpkg/binprovider_apt.py +++ b/abxpkg/binprovider_apt.py @@ -65,6 +65,17 @@ def setup_PATH(self, no_cache: bool = False) -> None: if not dpkg_abspath or not apt_abspath: self.PATH = "" else: + # Seed self.PATH with apt-get's bin_dir before calling + # self.exec(dpkg -L bash). self.exec's build_exec_env + # re-enters self.setup_PATH; without a non-empty PATH, + # the ``not self.PATH`` guard at the top of this method + # would fire on every recursive entry and infinitely + # loop. The bin_dir is correct as a baseline value — + # the dpkg-discovered runtime bin dirs get prepended + # onto it just below. + self.PATH = TypeAdapter(PATHStr).validate_python( + str(apt_abspath.parent), + ) PATH = self.PATH dpkg_install_dirs = ( self.exec( From 82829566f57e2074662e462cd121793d082d85ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 01:00:20 +0000 Subject: [PATCH 12/18] Address cubic review: tighten installer-cycle filter to candidate's own chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous filter used ``self.name not in selected_provider_names`` which fired for any candidate whenever ``self`` was in the user-selected provider list — that's almost always the case (e.g. running the cargo provider with ``ABXPKG_BINPROVIDERS=apt,brew,cargo`` would filter out brew and apt for cargo's installer resolution, leaving only env). Switch to the per-candidate check cubic suggested: drop a candidate iff the candidate's *own* explicit ``INSTALLER_BINPROVIDERS`` list names ``self``. Candidates with ``INSTALLER_BINPROVIDERS=None`` (the common case — only cargo and gem set it) fall back to PATH via env first and don't actually recurse, so they're kept. --- abxpkg/binprovider.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/abxpkg/binprovider.py b/abxpkg/binprovider.py index 2f23cf23..a895efa0 100755 --- a/abxpkg/binprovider.py +++ b/abxpkg/binprovider.py @@ -880,18 +880,17 @@ def INSTALLER_BINARY(self, no_cache: bool = False) -> ShallowBinary: if raw_provider_names or not self.INSTALLER_BINPROVIDERS else list(self.INSTALLER_BINPROVIDERS) ) - # When ``env`` isn't in the user-selected provider list, drop any - # cross-provider whose own INSTALLER_BIN bootstrap chain would - # include ``self`` — adding it back here would deadlock (e.g. - # ``--binproviders=apt,npm`` has apt resolve apt-get via - # ``npm.install`` while npm simultaneously resolves npm via - # ``apt.install``). When env IS in the selected list (the default - # provider configuration), the cycle never materializes because - # ``Binary([env, ...]).install`` finds the installer on the host - # PATH before any cross-provider attempt runs, so we skip the - # filter entirely to preserve legitimate cross-provider bootstraps - # like ``cargo`` installed via ``brew``. - env_in_selected = "env" in selected_provider_names + # Drop a cross-provider candidate iff that candidate's *own* + # explicit INSTALLER_BINPROVIDERS list names ``self`` — that's a + # genuine mutual-bootstrap cycle (e.g. a hypothetical brew with + # INSTALLER_BINPROVIDERS=("cargo",) trying to bootstrap brew via + # cargo while cargo's own chain bootstraps cargo via brew). + # Candidates with INSTALLER_BINPROVIDERS=None fall back to the + # ambient PATH via env first and don't actually recurse, so we + # keep them — over-filtering here would break legitimate + # cross-provider bootstraps like ``cargo`` installed via ``brew`` + # (especially under ``ABXPKG_BINPROVIDERS=...`` configurations + # that exclude env). installer_provider_names = [ provider_name for provider_name in preferred_provider_names @@ -899,11 +898,8 @@ def INSTALLER_BINARY(self, no_cache: bool = False) -> ShallowBinary: and provider_name in selected_provider_names and provider_name in PROVIDER_CLASS_BY_NAME and provider_name != self.name - and ( - provider_name == "env" - or env_in_selected - or self.name not in selected_provider_names - ) + and self.name + not in (PROVIDER_CLASS_BY_NAME[provider_name].INSTALLER_BINPROVIDERS or ()) ] installer_providers: list[BinProvider] = [ env_provider From aaca8a6fd494c40de67e3b7cdeeba3f0bd387266 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 01:44:43 +0000 Subject: [PATCH 13/18] Surface why rustup-init returns no working cargo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latest CI showed the rustup ``downloading verified rustup-init`` warning firing, then no follow-up — meaning ``_install_via_rustup`` returned None silently from one of the post-subprocess branches that didn't log. Add explicit warnings for those branches so the next run says exactly which step (post-install cargo missing / cargo present but not runnable) caused the fallback to bail out. --- abxpkg/binprovider_cargo.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index f1704aab..978faab9 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -359,9 +359,21 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: return None cargo_path = cargo_home / "bin" / "cargo" - if cargo_path.is_file() and self._cargo_executes(cargo_path): - return cargo_path - return None + if not cargo_path.is_file(): + logger.warning( + "rustup-init exited 0 but %s was not produced; output=%s", + cargo_path, + format_subprocess_output(proc.stdout, proc.stderr), + ) + return None + if not self._cargo_executes(cargo_path): + logger.warning( + "rustup-init produced %s but it does not run cleanly", + cargo_path, + ) + return None + logger.warning("rustup-init successfully bootstrapped %s", cargo_path) + return cargo_path @log_method_call() def setup( From d54066a47b9c95f52f123ffdfdb636d3426485c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 01:58:06 +0000 Subject: [PATCH 14/18] Log cargo --version output + rustup-init log on rustup-bootstrap failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous diagnostic told us rustup-init produced ``~/.cargo/bin/cargo`` but ``cargo --version`` didn't run cleanly — without saying *why*. Capture the actual stderr/stdout from the version probe along with rustup-init's own install log so the next failure surfaces the real cause (e.g. broken dynamic-link inheritance from the host's LD_LIBRARY_PATH, or rustup-init silently treating the dir as already-installed and skipping). --- abxpkg/binprovider_cargo.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index 978faab9..977627f0 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -367,9 +367,28 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: ) return None if not self._cargo_executes(cargo_path): + try: + version_proc = subprocess.run( + [str(cargo_path), "--version"], + capture_output=True, + text=True, + timeout=self.version_timeout, + ) + version_output = format_subprocess_output( + version_proc.stdout, + version_proc.stderr, + ) + version_rc = version_proc.returncode + except Exception as err: + version_output = f"" + version_rc = "?" logger.warning( - "rustup-init produced %s but it does not run cleanly", + "rustup-init produced %s but it does not run cleanly " + "(rc=%s, output=%s); rustup-init log was: %s", cargo_path, + version_rc, + version_output, + format_subprocess_output(proc.stdout, proc.stderr), ) return None logger.warning("rustup-init successfully bootstrapped %s", cargo_path) From bbef3b1ebe1b811c1728a33d0d31ac13d90a9564 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 02:23:36 +0000 Subject: [PATCH 15/18] Cargo rustup fallback: force-set default toolchain when rustup-init no-ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on the broken-cargo runner showed ``rustup-init`` exiting 0 but producing a cargo proxy that errors with:: error: rustup could not choose a version of cargo to run, because one wasn't specified explicitly, and no default is configured. That happens when ``$CARGO_HOME``/``$RUSTUP_HOME`` already contained a rustup proxy from a prior partial install — ``rustup-init`` skips the toolchain install AND the ``default`` set, leaving ``cargo --version`` broken. After ``rustup-init`` succeeds, run the now-installed ``rustup`` binary to explicitly install the stable toolchain and pin it as the default, so the cargo proxy always has a real toolchain to delegate to. --- abxpkg/binprovider_cargo.py | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index 977627f0..68ca2da5 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -358,6 +358,45 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: ) return None + # When ``$CARGO_HOME``/``$RUSTUP_HOME`` already contained a + # rustup proxy from a prior partial install, rustup-init exits + # 0 without installing the toolchain or setting a default — + # leaving ``cargo --version`` failing with "rustup could not + # choose a version of cargo to run". Force-install the stable + # toolchain and set it as default so the proxy has a target. + rustup_path = cargo_home / "bin" / "rustup" + if rustup_path.is_file(): + for cmd in ( + [ + str(rustup_path), + "toolchain", + "install", + "stable", + "--profile", + "minimal", + ], + [str(rustup_path), "default", "stable"], + ): + try: + fixup = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=max(self.install_timeout, 600), + env=env, + ) + except (OSError, subprocess.SubprocessError) as err: + logger.warning("rustup fixup %s failed to run: %s", cmd, err) + return None + if fixup.returncode != 0: + logger.warning( + "rustup fixup %s exited with %s: %s", + cmd, + fixup.returncode, + format_subprocess_output(fixup.stdout, fixup.stderr), + ) + return None + cargo_path = cargo_home / "bin" / "cargo" if not cargo_path.is_file(): logger.warning( From ab9103dda2d49ea85187eab084dbc1efd2c573e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 02:53:24 +0000 Subject: [PATCH 16/18] Cargo rustup diagnostics: mirror to stderr to survive subprocess contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ``central_lib_dir`` test runs the install through a fresh ``python -c`` subprocess. abxpkg's package logger has a ``NullHandler`` attached to suppress duplicate output during normal use, which has the side effect of swallowing all warnings before they reach the root logger — meaning the rustup-fallback diagnostics are invisible in that subprocess context. Add a small ``_rustup_warn`` helper that mirrors each rustup-related warning to ``sys.stderr`` (with a ``[abxpkg.cargo]`` prefix) in addition to the regular logger emission. Now both the pytest captured-log path (test_cargoprovider) and the raw subprocess stderr path (central_lib_dir) surface the bootstrap chain's failure mode. --- abxpkg/binprovider_cargo.py | 46 +++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index 68ca2da5..6512e11e 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -5,6 +5,7 @@ import os import platform import subprocess +import sys import urllib.request from pathlib import Path @@ -33,6 +34,21 @@ logger = get_logger(__name__) +def _rustup_warn(message: str, *args) -> None: + """Emit a rustup-fallback diagnostic that survives subprocess contexts. + + abxpkg's parent logger has a NullHandler attached, which swallows + warnings before they reach the root logger — fine for normal use, + but it hides the rustup-init bootstrap diagnostics from subprocess + callers (e.g. ``test_central_lib_dir``'s install script) that don't + configure handlers themselves. Mirror to stderr so the install + chain's failure mode is observable in any context. + """ + formatted = (message % args) if args else message + logger.warning("%s", formatted) + print(f"[abxpkg.cargo] {formatted}", file=sys.stderr, flush=True) + + class CargoProvider(BinProvider): name: BinProviderName = "cargo" _log_emoji = "🦀" @@ -172,14 +188,14 @@ def INSTALLER_BINARY(self, no_cache: bool = False): # canonical rustup-init installer. This bypasses any broken/partial # system rust installs (e.g. linuxbrew's cargo missing libllhttp.so # on a stale CI image) and never depends on root/sudo. - logger.warning( + _rustup_warn( "%s: no working cargo from env/brew/apt/nix; falling back to rustup-init", self.__class__.__name__, ) try: rustup_cargo = self._install_via_rustup(no_cache=no_cache) except Exception as err: - logger.warning( + _rustup_warn( "%s: rustup-init fallback raised %r", self.__class__.__name__, err, @@ -264,7 +280,7 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: """ triple = self._rustup_target_triple() if triple is None: - logger.warning( + _rustup_warn( "Unsupported host for rustup fallback: %s %s", platform.system(), platform.machine(), @@ -275,12 +291,12 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: try: cargo_home.mkdir(parents=True, exist_ok=True) except OSError as err: - logger.warning("rustup fallback: cannot create %s: %s", cargo_home, err) + _rustup_warn("rustup fallback: cannot create %s: %s", cargo_home, err) return None binary_url = f"{RUSTUP_DIST_BASE}/{triple}/rustup-init" sha_url = f"{binary_url}.sha256" - logger.warning( + _rustup_warn( "rustup fallback: downloading verified rustup-init from %s", binary_url, ) @@ -290,7 +306,7 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: with urllib.request.urlopen(sha_url, timeout=30) as response: sha_text = response.read().decode("utf-8", errors="replace") except Exception as err: - logger.warning( + _rustup_warn( "Failed to download rustup-init from %s: %s", binary_url, err, @@ -303,7 +319,7 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: if len(expected_sha) != 64 or any( ch not in "0123456789abcdef" for ch in expected_sha ): - logger.warning( + _rustup_warn( "rustup-init.sha256 sidecar at %s is malformed: %r", sha_url, sha_text[:120], @@ -312,7 +328,7 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: actual_sha = hashlib.sha256(binary_bytes).hexdigest() if actual_sha != expected_sha: - logger.warning( + _rustup_warn( "rustup-init SHA-256 mismatch (got %s, expected %s) — refusing to run", actual_sha, expected_sha, @@ -345,13 +361,13 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: env=env, ) except (OSError, subprocess.SubprocessError) as err: - logger.warning("rustup-init failed to run: %s", err) + _rustup_warn("rustup-init failed to run: %s", err) return None finally: rustup_init.unlink(missing_ok=True) if proc.returncode != 0: - logger.warning( + _rustup_warn( "rustup-init exited with %s: %s", proc.returncode, format_subprocess_output(proc.stdout, proc.stderr), @@ -386,10 +402,10 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: env=env, ) except (OSError, subprocess.SubprocessError) as err: - logger.warning("rustup fixup %s failed to run: %s", cmd, err) + _rustup_warn("rustup fixup %s failed to run: %s", cmd, err) return None if fixup.returncode != 0: - logger.warning( + _rustup_warn( "rustup fixup %s exited with %s: %s", cmd, fixup.returncode, @@ -399,7 +415,7 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: cargo_path = cargo_home / "bin" / "cargo" if not cargo_path.is_file(): - logger.warning( + _rustup_warn( "rustup-init exited 0 but %s was not produced; output=%s", cargo_path, format_subprocess_output(proc.stdout, proc.stderr), @@ -421,7 +437,7 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: except Exception as err: version_output = f"" version_rc = "?" - logger.warning( + _rustup_warn( "rustup-init produced %s but it does not run cleanly " "(rc=%s, output=%s); rustup-init log was: %s", cargo_path, @@ -430,7 +446,7 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: format_subprocess_output(proc.stdout, proc.stderr), ) return None - logger.warning("rustup-init successfully bootstrapped %s", cargo_path) + _rustup_warn("rustup-init successfully bootstrapped %s", cargo_path) return cargo_path @log_method_call() From b2ef3f7e327594e248a0a87aa68167a9d46915ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 03:16:05 +0000 Subject: [PATCH 17/18] Cargo rustup fallback: use standard RUSTUP_HOME=~/.rustup layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI diagnostics revealed the actual cause: I was setting ``RUSTUP_HOME=$CARGO_HOME/.rustup`` (a non-standard layout). rustup-init ran fine and installed the toolchain there, but every subsequent ``cargo --version`` invocation runs without that env var override and falls back to the default ``$RUSTUP_HOME=~/.rustup`` lookup — which is empty — and fails with:: error: rustup could not choose a version of cargo to run, because one wasn't specified explicitly, and no default is configured. Use the standard ``~/.rustup`` (or ``$RUSTUP_HOME`` if the user already exports one) so the cargo proxy at ``$CARGO_HOME/bin/cargo`` finds its toolchains via the default lookup path with no env-var pinning needed. --- abxpkg/binprovider_cargo.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index 6512e11e..9dc337cc 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -339,10 +339,18 @@ def _install_via_rustup(self, no_cache: bool = False) -> Path | None: rustup_init.write_bytes(binary_bytes) rustup_init.chmod(0o755) + # Use the standard layout (CARGO_HOME=~/.cargo, RUSTUP_HOME=~/.rustup) + # so the cargo proxy at ``$CARGO_HOME/bin/cargo`` can find its + # toolchains via the default ``RUSTUP_HOME`` lookup when subsequent + # invocations don't pass the env var. A non-standard + # ``RUSTUP_HOME=$CARGO_HOME/.rustup`` makes rustup-init succeed, but + # any later ``cargo --version`` without that override fails with + # "rustup could not choose a version of cargo to run". + rustup_home = Path(os.environ.get("RUSTUP_HOME") or "~/.rustup").expanduser() env = { **os.environ, "CARGO_HOME": str(cargo_home), - "RUSTUP_HOME": str(cargo_home / ".rustup"), + "RUSTUP_HOME": str(rustup_home), } try: proc = subprocess.run( From 4a114a9382ba39f8bef0291c13c68a1d14617728 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 06:07:08 +0000 Subject: [PATCH 18/18] Cargo bootstrap: trust BinProvider chain, fix BrewProvider broken-install detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per feedback: don't preinstall cargo via a custom rustup-init download. Instead, fix the actual setup() bootstrap chain so a stale broken brew rust install gets repaired automatically. CargoProvider: - Drop the rustup-init machinery (download + SHA verification + manual toolchain install + default-set fixup + stderr-mirror). The proper bootstrap is the existing env/brew/apt/nix INSTALLER_BINPROVIDERS chain — augmenting it with a custom installer was wrong. - Keep ``_cargo_executes`` validation so we never return a cargo binary whose ``--version`` exits non-zero (this is what guarded against the broken brew cargo on the CI runner). On total chain failure, raise ``BinProviderUnavailableError`` cleanly. BrewProvider.default_install_handler: - Detect the case where ``brew install `` exits 0 with output containing ``"already installed"`` BUT the resulting binary doesn't actually run (e.g. the runner's ``cargo`` was linked against a now- removed ``libllhttp.so.9.3`` from a separate ``brew uninstall node``). When both signals match, automatically run ``brew reinstall `` to relink the formula against the current dependency set. - Healthy already-installed packages stay on the fast path — the reinstall only fires when the post-install version probe would actually fail, so we never waste time on a working install. - Adds ``_installed_binary_executes`` helper that runs ``--version`` on the brew-resolved abspath of ``bin_name``. --- abxpkg/binprovider_brew.py | 58 ++++++++ abxpkg/binprovider_cargo.py | 268 +----------------------------------- 2 files changed, 59 insertions(+), 267 deletions(-) diff --git a/abxpkg/binprovider_brew.py b/abxpkg/binprovider_brew.py index 12feca8a..013fefe1 100755 --- a/abxpkg/binprovider_brew.py +++ b/abxpkg/binprovider_brew.py @@ -2,6 +2,7 @@ __package__ = "abxpkg" import os +import subprocess import sys import time import platform @@ -350,8 +351,65 @@ def default_install_handler( return proc_output if proc.returncode != 0: self._raise_proc_error("install", install_args, proc) + + # If brew said "already installed" but the resulting binary doesn't + # actually run (e.g. an upstream uninstall removed a shared library + # the formula was linked against — happens on stale CI runners + # where ``brew install rust`` is a no-op even though + # ``cargo --version`` exits 127 with "libllhttp.so not found"), + # automatically ``brew reinstall`` to relink against the current + # set of dependencies. Only run the reinstall when both signals + # match so a healthy already-installed package is just a fast path. + if ( + "already installed" in proc_output.lower() + and not self._installed_binary_executes(bin_name) + ): + for package in dict.fromkeys( + arg + for arg in install_args + if isinstance(arg, str) and not arg.startswith("-") + ): + reinstall_proc = self.exec( + bin_name=installer_bin, + cmd=["reinstall", package], + timeout=timeout, + ) + if reinstall_proc.returncode != 0: + self._raise_proc_error( + "install", + install_args, + reinstall_proc, + ) return proc_output + def _installed_binary_executes(self, bin_name: str) -> bool: + """Return True iff `` --version`` exits cleanly. + + Used to detect the case where ``brew install`` reports the package + is already installed but the on-disk binary is broken (e.g. its + dynamic-link dependencies got purged by a separate uninstall). + Looks up the binary in brew's PATH first, falling back to a bare + ``shutil.which`` so it works during the initial install before + ``setup_PATH`` populates ``self.PATH``. + """ + from shutil import which as shutil_which + + candidate = bin_abspath(bin_name, PATH=self.PATH) or shutil_which(bin_name) + if not candidate: + # No binary on disk to test — defer the call (the post-install + # load() will still validate the install via the version probe). + return True + try: + proc = subprocess.run( + [str(candidate), "--version"], + capture_output=True, + text=True, + timeout=self.version_timeout, + ) + except (OSError, subprocess.SubprocessError): + return False + return proc.returncode == 0 + @remap_kwargs({"packages": "install_args"}) def default_update_handler( self, diff --git a/abxpkg/binprovider_cargo.py b/abxpkg/binprovider_cargo.py index 9dc337cc..bd533937 100755 --- a/abxpkg/binprovider_cargo.py +++ b/abxpkg/binprovider_cargo.py @@ -1,12 +1,8 @@ #!/usr/bin/env python3 __package__ = "abxpkg" -import hashlib import os -import platform import subprocess -import sys -import urllib.request from pathlib import Path @@ -22,31 +18,11 @@ ) from .semver import SemVer from .binprovider import BinProvider, EnvProvider, log_method_call, remap_kwargs -from .logging import format_subprocess_output, get_logger +from .logging import format_subprocess_output DEFAULT_CARGO_HOME = Path(os.environ.get("CARGO_HOME", "~/.cargo")).expanduser() MIN_CARGO_INSTALLER_VERSION = cast(SemVer, SemVer.parse("1.85.0")) -# Canonical rust-lang.org distribution. Each ``rustup-init`` binary has a -# sibling ``rustup-init.sha256`` file we use to verify the download — we -# never run a ``curl sh.rustup.rs | sh``-style unverified installer. -RUSTUP_DIST_BASE = "https://static.rust-lang.org/rustup/dist" -logger = get_logger(__name__) - - -def _rustup_warn(message: str, *args) -> None: - """Emit a rustup-fallback diagnostic that survives subprocess contexts. - - abxpkg's parent logger has a NullHandler attached, which swallows - warnings before they reach the root logger — fine for normal use, - but it hides the rustup-init bootstrap diagnostics from subprocess - callers (e.g. ``test_central_lib_dir``'s install script) that don't - configure handlers themselves. Mirror to stderr so the install - chain's failure mode is observable in any context. - """ - formatted = (message % args) if args else message - logger.warning("%s", formatted) - print(f"[abxpkg.cargo] {formatted}", file=sys.stderr, flush=True) class CargoProvider(BinProvider): @@ -184,33 +160,6 @@ def INSTALLER_BINARY(self, no_cache: bool = False): self._INSTALLER_BINARY = upgraded return upgraded - # Last-resort fallback: bootstrap a hermetic rust toolchain via the - # canonical rustup-init installer. This bypasses any broken/partial - # system rust installs (e.g. linuxbrew's cargo missing libllhttp.so - # on a stale CI image) and never depends on root/sudo. - _rustup_warn( - "%s: no working cargo from env/brew/apt/nix; falling back to rustup-init", - self.__class__.__name__, - ) - try: - rustup_cargo = self._install_via_rustup(no_cache=no_cache) - except Exception as err: - _rustup_warn( - "%s: rustup-init fallback raised %r", - self.__class__.__name__, - err, - ) - rustup_cargo = None - if rustup_cargo is not None: - rustup_loaded = EnvProvider( - install_root=None, - bin_dir=None, - PATH=str(rustup_cargo.parent), - ).load(self.INSTALLER_BIN, no_cache=no_cache) - if rustup_loaded and rustup_loaded.loaded_abspath: - self._INSTALLER_BINARY = rustup_loaded - return rustup_loaded - from .exceptions import BinProviderUnavailableError raise BinProviderUnavailableError( @@ -242,221 +191,6 @@ def _cargo_executes(self, abspath) -> bool: return False return bool(SemVer.parse(proc.stdout.strip() or proc.stderr.strip())) - @staticmethod - def _rustup_target_triple() -> str | None: - """Return the rust-lang.org target triple for the current host, or None. - - Only the four targets we publish CI/dev support for are returned — - unknown hosts get None so the rustup fallback is skipped instead of - downloading a binary that doesn't match the host ABI. - """ - machine = platform.machine().lower() - system = platform.system().lower() - arch = { - "x86_64": "x86_64", - "amd64": "x86_64", - "aarch64": "aarch64", - "arm64": "aarch64", - }.get(machine) - if arch is None: - return None - if system == "linux": - return f"{arch}-unknown-linux-gnu" - if system == "darwin": - return f"{arch}-apple-darwin" - return None - - def _install_via_rustup(self, no_cache: bool = False) -> Path | None: - """Bootstrap rust via the rustup-init binary into CARGO_HOME and return cargo's abspath. - - Used as a last-resort installer when no other BinProvider can produce - a working ``cargo`` binary. Honors ``$CARGO_HOME`` when set and - installs a minimal stable profile so this runs in 60-90s on CI. - - The downloaded ``rustup-init`` binary is verified against the - ``rustup-init.sha256`` sidecar file published alongside it on - ``static.rust-lang.org`` BEFORE we exec it, so a tampered download - won't run. - """ - triple = self._rustup_target_triple() - if triple is None: - _rustup_warn( - "Unsupported host for rustup fallback: %s %s", - platform.system(), - platform.machine(), - ) - return None - - cargo_home = DEFAULT_CARGO_HOME - try: - cargo_home.mkdir(parents=True, exist_ok=True) - except OSError as err: - _rustup_warn("rustup fallback: cannot create %s: %s", cargo_home, err) - return None - - binary_url = f"{RUSTUP_DIST_BASE}/{triple}/rustup-init" - sha_url = f"{binary_url}.sha256" - _rustup_warn( - "rustup fallback: downloading verified rustup-init from %s", - binary_url, - ) - try: - with urllib.request.urlopen(binary_url, timeout=60) as response: - binary_bytes = response.read() - with urllib.request.urlopen(sha_url, timeout=30) as response: - sha_text = response.read().decode("utf-8", errors="replace") - except Exception as err: - _rustup_warn( - "Failed to download rustup-init from %s: %s", - binary_url, - err, - ) - return None - - # The sidecar is a single line: " rustup-init". Take the first - # 64 hex chars and reject anything that isn't a clean SHA-256. - expected_sha = (sha_text.strip().split() or [""])[0].lower() - if len(expected_sha) != 64 or any( - ch not in "0123456789abcdef" for ch in expected_sha - ): - _rustup_warn( - "rustup-init.sha256 sidecar at %s is malformed: %r", - sha_url, - sha_text[:120], - ) - return None - - actual_sha = hashlib.sha256(binary_bytes).hexdigest() - if actual_sha != expected_sha: - _rustup_warn( - "rustup-init SHA-256 mismatch (got %s, expected %s) — refusing to run", - actual_sha, - expected_sha, - ) - return None - - rustup_init = cargo_home / "rustup-init" - rustup_init.write_bytes(binary_bytes) - rustup_init.chmod(0o755) - - # Use the standard layout (CARGO_HOME=~/.cargo, RUSTUP_HOME=~/.rustup) - # so the cargo proxy at ``$CARGO_HOME/bin/cargo`` can find its - # toolchains via the default ``RUSTUP_HOME`` lookup when subsequent - # invocations don't pass the env var. A non-standard - # ``RUSTUP_HOME=$CARGO_HOME/.rustup`` makes rustup-init succeed, but - # any later ``cargo --version`` without that override fails with - # "rustup could not choose a version of cargo to run". - rustup_home = Path(os.environ.get("RUSTUP_HOME") or "~/.rustup").expanduser() - env = { - **os.environ, - "CARGO_HOME": str(cargo_home), - "RUSTUP_HOME": str(rustup_home), - } - try: - proc = subprocess.run( - [ - str(rustup_init), - "-y", - "--no-modify-path", - "--default-toolchain", - "stable", - "--profile", - "minimal", - ], - capture_output=True, - text=True, - timeout=max(self.install_timeout, 600), - env=env, - ) - except (OSError, subprocess.SubprocessError) as err: - _rustup_warn("rustup-init failed to run: %s", err) - return None - finally: - rustup_init.unlink(missing_ok=True) - - if proc.returncode != 0: - _rustup_warn( - "rustup-init exited with %s: %s", - proc.returncode, - format_subprocess_output(proc.stdout, proc.stderr), - ) - return None - - # When ``$CARGO_HOME``/``$RUSTUP_HOME`` already contained a - # rustup proxy from a prior partial install, rustup-init exits - # 0 without installing the toolchain or setting a default — - # leaving ``cargo --version`` failing with "rustup could not - # choose a version of cargo to run". Force-install the stable - # toolchain and set it as default so the proxy has a target. - rustup_path = cargo_home / "bin" / "rustup" - if rustup_path.is_file(): - for cmd in ( - [ - str(rustup_path), - "toolchain", - "install", - "stable", - "--profile", - "minimal", - ], - [str(rustup_path), "default", "stable"], - ): - try: - fixup = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=max(self.install_timeout, 600), - env=env, - ) - except (OSError, subprocess.SubprocessError) as err: - _rustup_warn("rustup fixup %s failed to run: %s", cmd, err) - return None - if fixup.returncode != 0: - _rustup_warn( - "rustup fixup %s exited with %s: %s", - cmd, - fixup.returncode, - format_subprocess_output(fixup.stdout, fixup.stderr), - ) - return None - - cargo_path = cargo_home / "bin" / "cargo" - if not cargo_path.is_file(): - _rustup_warn( - "rustup-init exited 0 but %s was not produced; output=%s", - cargo_path, - format_subprocess_output(proc.stdout, proc.stderr), - ) - return None - if not self._cargo_executes(cargo_path): - try: - version_proc = subprocess.run( - [str(cargo_path), "--version"], - capture_output=True, - text=True, - timeout=self.version_timeout, - ) - version_output = format_subprocess_output( - version_proc.stdout, - version_proc.stderr, - ) - version_rc = version_proc.returncode - except Exception as err: - version_output = f"" - version_rc = "?" - _rustup_warn( - "rustup-init produced %s but it does not run cleanly " - "(rc=%s, output=%s); rustup-init log was: %s", - cargo_path, - version_rc, - version_output, - format_subprocess_output(proc.stdout, proc.stderr), - ) - return None - _rustup_warn("rustup-init successfully bootstrapped %s", cargo_path) - return cargo_path - @log_method_call() def setup( self,