| # Copyright 2026 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Host package-manager interface for Debian/Ubuntu (APT) systems.""" |
| |
| import functools |
| import pathlib |
| import re |
| import shutil |
| import subprocess |
| import sys |
| |
| # The basename of this script, used to prefix diagnostic and error messages. |
| _BASENAME = pathlib.Path(__file__).name |
| |
| # Compiled regular expression matching standard Debian package names. |
| # Debian package names can consist only of lowercase letters, numbers, and '+', '-', '.', and ':'. |
| _DEBIAN_PACKAGE_NAME_RE = re.compile(r"^[a-z0-9\-.:+]+$") |
| |
| |
| def _validate_package_name(package_name: str) -> None: |
| """Validates that the package name follows standard Debian naming conventions. |
| |
| Raises: |
| ValueError: If the package name contains illegal or unsafe characters. |
| """ |
| if not _DEBIAN_PACKAGE_NAME_RE.match(package_name): |
| raise ValueError( |
| f"[{_BASENAME}] Malformed package name: '{package_name}'" |
| ) |
| |
| |
| @functools.cache |
| def is_available() -> bool: |
| """Returns True if 'apt' and 'apt-cache' are available on the host.""" |
| return ( |
| shutil.which("apt") is not None |
| and shutil.which("apt-cache") is not None |
| ) |
| |
| |
| def exists(package_name: str) -> bool: |
| """Queries the APT package caches to verify if a package is installable.""" |
| _validate_package_name(package_name) |
| if not is_available(): |
| return False |
| try: |
| res = subprocess.run( |
| ["apt-cache", "show", package_name], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| ) |
| return res.returncode == 0 |
| except OSError: |
| # If the query command fails (e.g. package not found or apt is locked), |
| # return False so that the caller can gracefully fall back to displaying |
| # manual installation/curl instructions to the developer. |
| return False |
| |
| |
| def get_binary_path(package_name: str, binary_name: str) -> pathlib.Path | None: |
| """Finds and returns the absolute path to an installed package's binary. |
| |
| This is particularly useful right after package installation, when the binary |
| may not yet be added to the active shell's PATH environment variable. |
| |
| Returns: |
| The Path to the binary if found, or None otherwise. |
| """ |
| # Check common standard system installation locations first (fast path) |
| standard_paths = [ |
| pathlib.Path("/usr/bin") / binary_name, |
| pathlib.Path("/usr/local/bin") / binary_name, |
| pathlib.Path("/bin") / binary_name, |
| ] |
| for p in standard_paths: |
| if p.is_file(): |
| return p.resolve() |
| |
| # Fall back to querying dpkg (Debian/Ubuntu package manager) if available |
| if shutil.which("dpkg"): |
| res = subprocess.run( |
| ["dpkg", "-L", package_name], |
| stdout=subprocess.PIPE, |
| stderr=subprocess.DEVNULL, |
| text=True, |
| ) |
| if res.returncode == 0: |
| for line in res.stdout.splitlines(): |
| p = pathlib.Path(line) |
| if p.name == binary_name and p.is_file(): |
| return p.resolve() |
| |
| return None |
| |
| |
| def install(package_name: str, interactive: bool = True) -> None: |
| """Installs the specified package using 'sudo apt install -y'. |
| |
| Raises: |
| RuntimeError: If installation is declined or fails. |
| """ |
| _validate_package_name(package_name) |
| install_cmd = ["sudo", "apt", "install", "-y", package_name] |
| |
| if not interactive or not sys.stdin.isatty(): |
| raise RuntimeError( |
| f"Package '{package_name}' is missing and cannot be installed interactively.\n" |
| f"Please run the following command manually:\n" |
| f" {' '.join(install_cmd)}" |
| ) |
| |
| try: |
| response = ( |
| input( |
| f"Install '{package_name}' package now?\n" |
| f"Command: {' '.join(install_cmd)} [Y/n]: " |
| ) |
| .strip() |
| .lower() |
| ) |
| if response not in ("", "y", "yes"): |
| raise RuntimeError( |
| f"Package '{package_name}' installation declined." |
| ) |
| |
| print(f"[apt] Command: {' '.join(install_cmd)}") |
| subprocess.run(install_cmd, check=True) |
| except (EOFError, KeyboardInterrupt): |
| raise RuntimeError(f"Package '{package_name}' installation declined.") |