Store now spells its root \\?\C:\... on Windows so PM's own file calls
under it pass MAX_PATH. That spelling must not escape: the same folder
spelled two ways hashes and compares differently, and children such as
cmd.exe, gpg or uv refuse or record the prefix (uv would write it into
pyvenv.cfg).
native() now converts store-derived paths where their text leaves PM:
- process argv and cwd: uv via PythonEnvironment._run (its one exit),
binary version probes, npm self-install, the PortableGit extractor,
gpg provenance checks, the plugin-eviction interpreter probe;
- environment values: compose_env (PATH and package env diffs), the
activation PATH dirs, HERMES_RUNTIME_DIR;
- hashed identities: the PM runtime inputs and isolated-environment
identity, so existing records still match and nothing rebuilds;
- API results callers execute or compare: installed_package,
uv_launcher, verified_tools entries;
- user-facing verification messages.
Facts records already store {STORE}-templated env, so no absolute path
reaches them. Tests pin that identities are spelling-independent and that
records, composed env and uv invocations from a verbatim root carry no
prefix (both fail on e8c97320ac).
132 lines
6.3 KiB
Python
132 lines
6.3 KiB
Python
"""Optional security executables. Acquisition and publication belong to PM."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from pm import paths
|
|
from pm.filesystem import native
|
|
from pm.lock import Lockfile
|
|
from pm.package import InstallError
|
|
from pm.packages import BinaryPackage, _RUST_TRIPLE
|
|
from pm.registry import register
|
|
from pm.store import MUSL_TARGETS, flatten_single_dir
|
|
|
|
|
|
@register
|
|
class Bws(BinaryPackage):
|
|
name = "bws"
|
|
optional = True
|
|
binary_rel = {"posix": "bws", "win32": "bws.exe"}
|
|
gaps = {"linux-arm64-bionic": "upstream does not ship an Android binary"}
|
|
|
|
def fetch_url(self, version: str, target: str) -> str:
|
|
# The static musl build runs on glibc too. One portable Linux artifact
|
|
# removes the consumer's ldd probe without losing musl support.
|
|
triple = _RUST_TRIPLE[target].replace("linux-gnu", "linux-musl")
|
|
platform = "macos-universal" if target.startswith("darwin") else triple
|
|
return f"https://github.com/bitwarden/sdk-sm/releases/download/bws-v{version}/bws-{platform}-{version}.zip"
|
|
|
|
def stage(self, store, staged: Path, version: str, target: str) -> None:
|
|
super().stage(store, staged, version, target)
|
|
binary = self.binary(staged, target)
|
|
if binary is not None and binary.is_file():
|
|
binary.chmod(0o755)
|
|
|
|
|
|
class _SignedBinary(BinaryPackage):
|
|
"""The same PM artifact transaction also acquires pinned provenance files.
|
|
|
|
The consumer owns signature semantics, not a second downloader. Checksum
|
|
signatures must cover the *archive* pinned by PM, not an unrelated blob.
|
|
Every locked provenance artifact is required even without a verifier on
|
|
PATH. This intentionally strengthens cold-install availability: a missing
|
|
or corrupt signature/key/certificate cannot silently reduce the pinned
|
|
closure. Verifier execution remains optional; explicit rejection is fatal.
|
|
"""
|
|
optional = True
|
|
gaps = {
|
|
"win32-x64": "this integration requires a Linux/macOS host (or WSL)",
|
|
"win32-arm64": "this integration requires a Linux/macOS host (or WSL)",
|
|
"linux-arm64-bionic": "upstream does not ship an Android binary",
|
|
}
|
|
|
|
def unpack(self, archive: Path, staged: Path, target: str) -> None:
|
|
if archive.name.endswith((".txt", ".sig", ".pem", ".asc")):
|
|
directory = staged / ".provenance"
|
|
directory.mkdir(parents=True)
|
|
shutil.copyfile(archive, directory / archive.name)
|
|
else:
|
|
super().unpack(archive, staged, target)
|
|
flatten_single_dir(staged)
|
|
|
|
def stage(self, store, staged: Path, version: str, target: str) -> None:
|
|
binary = self.binary(staged, target)
|
|
if binary is None or not binary.is_file() or binary.is_symlink():
|
|
raise InstallError(self.name, "archive binary is not a regular file")
|
|
binary.chmod(0o755)
|
|
provenance = staged / ".provenance"
|
|
artifacts = Lockfile(paths.lockfile_path()).artifacts(self.name, target)
|
|
archive = artifacts[0]
|
|
filename = Path(urlparse(archive["url"]).path).name
|
|
rows = (line.split() for line in (provenance / "checksums.txt").read_text(encoding="utf-8-sig").splitlines())
|
|
expected = next((row[0] for row in rows if len(row) == 2 and row[1].lstrip("*") == filename), None)
|
|
if expected != archive["sha256"]:
|
|
raise InstallError(self.name, "release checksums do not cover the pinned archive")
|
|
self.verify_provenance(provenance)
|
|
|
|
def verify_provenance(self, directory: Path) -> None:
|
|
raise NotImplementedError
|
|
|
|
|
|
@register
|
|
class IronProxy(_SignedBinary):
|
|
name = "iron-proxy"
|
|
binary_rel = {"posix": "iron-proxy"}
|
|
# The release omits its public key asset; pin the key from its source commit.
|
|
signing_key_url = "https://raw.githubusercontent.com/paradigmxyz/iron-proxy/be5f255d0d9d10d8573bd65f480dd48a07772bf1/public-key.asc"
|
|
|
|
def _probe_env(self) -> dict:
|
|
from agent.proxy_sources.iron_proxy import allowlisted_env
|
|
|
|
return allowlisted_env()
|
|
|
|
def fetch_url(self, version: str, target: str) -> str:
|
|
# Linux releases are built with CGO_ENABLED=0, so the same signed
|
|
# archive is portable across glibc and musl userlands.
|
|
lookup_target = target.removesuffix("-musl") if target in MUSL_TARGETS else target
|
|
platform, arch = lookup_target.split("-")
|
|
arch = "amd64" if arch == "x64" else arch
|
|
return f"https://github.com/paradigmxyz/iron-proxy/releases/download/v{version}/iron-proxy_{version}_{platform}_{arch}.tar.gz"
|
|
|
|
def fetch_urls(self, version: str, target: str) -> list[str]:
|
|
archive = self.fetch_url(version, target)
|
|
base = archive.rsplit("/", 1)[0]
|
|
return [archive, f"{base}/checksums.txt", f"{base}/checksums.txt.asc", self.signing_key_url]
|
|
|
|
def verify_provenance(self, directory: Path) -> None:
|
|
# Keep package provenance inside PM rather than calling a private
|
|
# helper in the running proxy (which also owns runtime subprocesses).
|
|
gpg = shutil.which("gpg")
|
|
if not gpg:
|
|
logging.getLogger(__name__).warning("gpg unavailable; iron-proxy archive checksum remains enforced")
|
|
return
|
|
with tempfile.TemporaryDirectory(prefix="hermes-iron-signature-") as home:
|
|
args = [gpg, "--homedir", home, "--batch", "--no-tty"]
|
|
signature = directory / "checksums.txt.asc"
|
|
key = directory / "public-key.asc"
|
|
if not signature.is_file() or not key.is_file():
|
|
raise InstallError(self.name, "pinned signature assets missing")
|
|
imported = subprocess.run([*args, "--import", native(key)], stdin=subprocess.DEVNULL,
|
|
capture_output=True, timeout=60, check=False)
|
|
if imported.returncode:
|
|
logging.getLogger(__name__).warning("Could not import iron-proxy signing key; archive checksum remains enforced")
|
|
return
|
|
verified = subprocess.run([*args, "--verify", native(signature), native(directory / "checksums.txt")],
|
|
stdin=subprocess.DEVNULL, capture_output=True, timeout=60, check=False)
|
|
if verified.returncode:
|
|
raise InstallError(self.name, "GPG signature verification failed") |