Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,7 @@ jobs:
- run: uv sync --locked
- run: uv run python scripts/smoke_tools.py clang
- run: uv run python scripts/smoke_tools.py tailwindcss
- if: runner.os == 'Linux'
run: uv run python scripts/smoke_browser.py --browser-argument=--no-sandbox
- if: runner.os != 'Linux'
run: uv run python scripts/smoke_browser.py
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- Discover Chrome/Chromium without launching it; optionally provision pinned,
checksum-verified Chrome for Testing with explicit managed selection.
- Inspect existing Android SDKs without requiring the installer CLI.
- Acquire caller-selected HTTPS artifacts through a checksum-addressed cache
with verified reuse, installation locks, and atomic publication.
- Discover and provision host Clang through OS package managers, with explicit
consent and C/LLVM IR compile/link validation. Report missing platform SDKs
and optionally check sanitizer linking without running generated programs.
Expand Down
89 changes: 86 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ vendor requirement for existing installations, or exact patch-version pinning.
Node provisioning prefers existing mise, otherwise existing nvm, otherwise
bootstraps mise. sdkmgr no longer downloads Node archives itself. Android
provisioning delegates package installation to Google's command-line tools.
The unreleased source also provisions Clang and standalone Tailwind CSS and
resolves Android build-tool paths. These additions are not in PyPI 0.2.0 yet.
The unreleased source also provisions Clang, Tailwind CSS, and Chrome for
Testing, resolves Android build-tool paths, and caches checksum-locked files.
These additions are not in PyPI 0.2.0 yet.

## Install

Expand Down Expand Up @@ -419,7 +420,82 @@ The generic `list` command still lists Node and Java installations only.
Use `clang which`, `which tailwindcss@X.Y.Z`, and `android list` for the
other tools.

## Development
## Browser discovery and provisioning (unreleased)

```sh
sdkmgr browser which
sdkmgr browser install
sdkmgr browser install --managed
```

```python
browser = manager.find_browser() # No downloads or browser processes.
browser = manager.ensure_browser() # Download only when none is found.
pinned = manager.ensure_browser(managed=True)
executable = pinned.executable()
```

Discovery checks an explicit `executable`, then `CHROME_BIN`, PATH, standard
Chrome/Chromium locations, and the pinned cache. A missing or non-executable
explicit selection is an error, not permission to download a replacement.
Discovery does not launch browsers or infer installed versions; external
installations have `version=None`. `managed=True` ignores PATH and `CHROME_BIN`
and selects only the pinned Chrome for Testing copy. It cannot be combined
with an explicit executable. The CLI exposes `--executable` and `--managed`.

The managed provider pins Chrome for Testing **155.0.8059.84** and archive
SHA-256 values for Linux glibc x64/ARM64, macOS x64/ARM64, and Windows x64.
Musl hosts need an existing compatible Chromium. The pins
were calculated from Google's official HTTPS archives; they are not publisher
signatures. Downloads are locked, verified, extracted into bounded staging,
and published atomically. macOS framework symlinks must stay within the
extracted application. Installed files are not rehashed on each discovery;
the retained download is verified whenever the artifact cache reads it.

sdkmgr does not install browser OS libraries, configure Linux sandboxes, set
a default browser, create profiles, or control browser processes. A browser
found on disk can still fail at launch due to missing host prerequisites.
Browser testing libraries retain that responsibility. Windows ARM64 needs an
existing compatible browser rather than a silently substituted x64 download.

## Android SDK inspection without an installer (unreleased)

```python
sdk = manager.inspect_android(sdk_root="/path/to/sdk")
if sdk is not None:
platform = sdk.platform_jar(35)
compiler = sdk.build_tool("d8", version="35.0.1")
```

`inspect_android()` accepts any existing SDK directory without requiring
Google's installer CLI, package records, or adb. Missing directories return
`None`; existing empty directories return a view whose individual package
lookups report missing files. adb/emulator paths are computed, not validated.
`tools` is `None` when no installer is present, and requesting its executable
raises a diagnostic. `find_android()` retains its installer-discovery contract.
Neither API downloads, launches tools, or changes the filesystem.

## Checksum-locked artifacts (unreleased)

```python
from sdkmgr import Artifact, ArtifactCache

artifact = Artifact(url, sha256, "library.jar")
cache = ArtifactCache(cache_directory)
path = cache.find(artifact) # Read-only; None when absent.
path = cache.ensure(artifact)
```

Callers supply an HTTPS URL, a lowercase SHA-256 digest, and a portable single
filename. Files are stored under `artifacts/DIGEST/FILENAME`; cached bytes are
rehash-verified instead of trusting a sidecar. Downloads have size/time bounds,
installation locks, and atomic publication. Failed downloads are discarded;
corrupt entries require explicit repair and are not overwritten. This is not
a hostile-local-user cache boundary: keep the cache owned by the calling user.
Artifact selection, Maven coordinates, dependency graphs, lock-file formats,
archive interpretation, and classpaths remain the caller's responsibility.

## Development and verification

Python 3.12 or newer is required. Activate the environment after `uv sync`:

Expand Down Expand Up @@ -462,12 +538,19 @@ Additional opt-in smokes cover the new tools:
```sh
python scripts/smoke_tools.py clang --sanitizer address
python scripts/smoke_tools.py tailwindcss
python scripts/smoke_browser.py
```

The Clang smoke requires an existing compiler and never installs OS packages.
The Tailwind smoke uses a temporary cache, or reuses an exact-version tool
already on PATH. Both clean up their temporary files. Real Clang linking and
Tailwind download, reuse, and CSS generation pass on Linux, macOS, and Windows.
Clang installation through OS package managers still needs live validation.
The browser smoke uses a temporary pinned installation and profile, then
checks headless JavaScript rendering. `--root` can reuse a cache. Linux hosts
must allow Chrome's sandbox; the script never changes host security settings.
Use `--browser-argument=FLAG` only for explicitly chosen test launch options.
The `tool-provider` CI job runs these smokes on all three operating systems
when the Tests workflow is dispatched manually; it does not run on every push.
Its Linux browser smoke explicitly disables the sandbox only for the disposable
local fixture; sdkmgr discovery and installation never alter sandbox settings.
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ The first release establishes independent provisioning before PyJS adopts it.

## Further validation and follow-up

- Browser discovery and pinned Chrome for Testing provisioning, installer-free
Android SDK inspection, and checksum-addressed artifact acquisition are
implemented in unreleased source. Editable-source integration is being
exercised before the next release and downstream dependency update.
- Linux pinned browser download, reuse, and headless rendering pass. This
Ubuntu host needs an explicit test-only sandbox option; no host policy was
changed. Browser discovery does not imply launch compatibility. Real macOS
and Windows browser smokes remain part of the release gate.
- Initial unreleased validation: 365 local tests passed, including tests run
against isolated wheel and source installations. Ruff and diff checks pass.
Fresh Linux Tailwind 4.3.3 download, checksum/version validation, cache
Expand Down
37 changes: 37 additions & 0 deletions scripts/smoke_browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Opt-in Chrome for Testing download, reuse, and headless rendering smoke."""

import argparse
import subprocess
import tempfile
from pathlib import Path

from sdkmgr import Manager


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, help="Reuse this cache instead of a disposable installation")
parser.add_argument("--browser-argument", action="append", default=[], help="Explicit browser launch flag")
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="sdkmgr-browser-smoke-") as temporary:
root = Path(temporary)
manager = Manager(args.root or root / "cache", interactive=False)
tool = manager.ensure_browser(managed=True)
assert manager.find_browser(managed=True) == tool
assert manager.ensure_browser(managed=True) == tool
page = root / "page.html"
page.write_text('<body><script>document.body.textContent = "browser-ready";</script></body>')
try:
result = subprocess.run(
[str(tool.executable()), "--headless=new", "--disable-gpu", "--no-first-run",
f"--user-data-dir={root / 'profile'}", *args.browser_argument, "--dump-dom", page.as_uri()],
check=True, capture_output=True, text=True, timeout=60, stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as error:
raise RuntimeError(f"Chrome rendering failed: {error.stderr[-8000:]}") from error
assert "<body>browser-ready</body>" in result.stdout, result
print(f"Chrome for Testing {tool.version}: pinned installation, reuse, and headless rendering passed.")


if __name__ == "__main__":
main()
5 changes: 4 additions & 1 deletion sdkmgr/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Discover and provision development tools without changing global selections."""

from .android import AndroidInstallation
from .artifacts import Artifact, ArtifactCache
from .browser import BrowserInstallation
from .clang import ClangInstallation
from .core import Installation, Manager
from .errors import InstallationRequired, SDKError
from .installation import ToolInstallation

__all__ = [
"AndroidInstallation", "ClangInstallation", "Installation", "InstallationRequired",
"AndroidInstallation", "Artifact", "ArtifactCache", "BrowserInstallation", "ClangInstallation",
"Installation", "InstallationRequired",
"Manager", "SDKError", "ToolInstallation",
]
__version__ = "0.2.0"
20 changes: 17 additions & 3 deletions sdkmgr/android.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,16 @@ def unreadable(error):

@dataclass(frozen=True)
class AndroidInstallation:
"""An SDK root and its native Android CLI; no JVM is needed to provision it."""
"""An installed SDK root, optionally with Google's native installer CLI."""

home: Path
tools: Path
tools: Path | None
platform: Platform

def executable(self, name: str = "android") -> Path:
if name == "android":
if self.tools is None:
raise SDKError(f"Android installer CLI is not installed under {self.home}.")
suffix = ".exe" if self.platform.system == "windows" else ""
return self.tools / "bin" / (name + suffix)
if name in {"adb", "fastboot", "emulator"}:
Expand All @@ -137,7 +139,9 @@ def executable(self, name: str = "android") -> Path:
def environment(self, base: dict[str, str] | None = None) -> dict[str, str]:
result = dict(os.environ if base is None else base)
result["ANDROID_HOME"] = result["ANDROID_SDK_ROOT"] = str(self.home)
paths = (self.tools / "bin", self.home / "platform-tools", self.home / "emulator")
paths = [self.home / "platform-tools", self.home / "emulator"]
if self.tools is not None:
paths.insert(0, self.tools / "bin")
result["PATH"] = os.pathsep.join(map(str, paths)) + os.pathsep + result.get("PATH", "")
# Cross-OS download overrides must not silently provision unusable tools.
result.pop("REPO_OS_OVERRIDE", None)
Expand Down Expand Up @@ -186,6 +190,16 @@ def find(home: Path, host: Platform) -> AndroidInstallation | None:
raise SDKError(f"Cannot discover Android command-line tools: {error}") from error


def inspect(home: Path, host: Platform) -> AndroidInstallation | None:
"""Inspect an existing root; individual tool lookups validate their files."""
try:
if home.is_dir():
return AndroidInstallation(home, command_tools(home, host), host)
return None
except OSError as error:
raise SDKError(f"Cannot inspect Android SDK: {error}") from error


def _verify_packages(sdk: AndroidInstallation, packages):
suffix = ".exe" if sdk.platform.system == "windows" else ""
for package in packages:
Expand Down
69 changes: 69 additions & 0 deletions sdkmgr/archives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Staged ZIP extraction, including contained macOS framework symlinks."""

import shutil
import stat
import zipfile
from pathlib import Path, PurePosixPath

from .errors import SDKError


def extract_zip(archive: Path, destination: Path, *, root: str, symlinks: bool = False):
"""Extract into a fresh staging directory, never into a live installation."""
destination.mkdir()
with zipfile.ZipFile(archive) as source:
entries = source.infolist()
if len(entries) > 20000 or sum(p.file_size for p in entries) > 2 * 1024 ** 3:
raise SDKError("Tool archive exceeds extraction limits.")
names = set()
links = []
for entry in entries:
path = PurePosixPath(entry.filename)
original = entry.orig_filename
mode = entry.external_attr >> 16
kind = stat.S_IFMT(mode)
if (
path.is_absolute() or ".." in path.parts or not path.parts
or path.parts[0] != root or "\\" in original or "\x00" in original
or ":" in original or path in names
or kind not in {0, stat.S_IFREG, stat.S_IFDIR, stat.S_IFLNK}
or (stat.S_ISLNK(mode) and (not symlinks or entry.file_size > 4096 or len(path.parts) == 1))
):
raise SDKError(f"Unsafe tool archive member: {original!r}.")
names.add(path)
target = destination.joinpath(*path.parts)
if stat.S_ISLNK(mode):
link = source.read(entry).decode("utf-8")
if (not link or PurePosixPath(link).is_absolute()
or "\\" in link or "\x00" in link or ":" in link):
raise SDKError(f"Unsafe tool archive symlink: {original!r}.")
links.append((target, link))
elif entry.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
with source.open(entry) as incoming, target.open("xb") as outgoing:
shutil.copyfileobj(incoming, outgoing)
target.chmod(0o755 if mode & 0o111 else 0o644)
# Creating links last prevents any archived file from writing through one.
link_paths = {target for target, _ in links}
for target, _ in links:
if any(parent in link_paths for parent in target.parents):
raise SDKError(f"Tool archive nests a member under a symlink: {target.name!r}.")
for target, link in links:
# Filesystem aliases may hide a link ancestor from Path equality.
for parent in target.parents:
if parent == destination:
break
if parent.is_symlink():
raise SDKError(f"Tool archive nests a member under a symlink: {target.name!r}.")
target.parent.mkdir(parents=True, exist_ok=True)
target.symlink_to(link)
boundary = (destination / root).resolve()
for target, _ in links:
try:
resolved = target.resolve(strict=True)
except (OSError, RuntimeError) as error:
raise SDKError(f"Invalid tool archive symlink: {target.name!r}.") from error
if not resolved.is_relative_to(boundary):
raise SDKError(f"Tool archive symlink escapes its root: {target.name!r}.")
Loading
Loading