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
8 changes: 7 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,13 @@ jobs:
- name: Restore/persist the booted savevm overlay
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: vmphone/vm-overlay.qcow2
# Both the overlay AND its cache-key sidecar must be restored: the
# CLI's warm resume treats a missing sidecar as a stale cache and
# cold-boots instead, so caching the overlay alone leaves it dead
# (issue #260).
path: |
vmphone/vm-overlay.qcow2
vmphone/vm-overlay.cache-key
key: ${{ runner.os }}-vm-savevm-${{ steps.vmkey.outputs.key }}

- name: "Boot the binder:vm instance under QEMU/TCG"
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Security

- **The VM rootfs bake verifies the Docker static bundle against a pinned sha256 before extracting it into the guest (#262).** The verify-before-extract machinery mirrors the guest-kernel check (#184); an unpinned or `DOCKER_URL`-overridden bundle prints an explicit unverified-source warning. (The default bundle's literal digest is pinned as a follow-up.)
- **`rootfs-release.yml` installs `uv` via the SHA-pinned `astral-sh/setup-uv` action (#209).** This closes the last bypass of the single pinned `uv` path in the `contents: write` release workflow (it used an unpinned `pipx install uv`).
- **The guest-kernel source tarball is verified against a pinned sha256 before compiling (#184).** The `binder: vm` kernel build (and the release/CI lanes) now hash the downloaded `cdn.kernel.org` tarball and fold the digest into the published prebuilt fingerprint, so a tampered tarball can't be compiled into a trusted `bzImage`.
- **`frida-server` downloads decompress incrementally with a bounded output ceiling and a truncation check (#228).** A corrupt or zip-bomb `.xz` raises `FridaFetchError` past the ceiling instead of OOM-ing the host; the per-call output is now capped (so a single chunk can't expand without bound), and a stream that ends before its LZMA end-of-stream marker (a truncated download) is rejected instead of silently accepted as a complete binary.
Expand Down Expand Up @@ -100,6 +101,7 @@

### Features

- **`frida-server` architecture auto-detects from the host (#189).** A redroid `host`/`auto` instance on aarch64 now stages `android-arm64` instead of the x86_64 default; `binder: vm` stays pinned to x86_64 and `BEETROOT_FRIDA_ARCH` still overrides.
- **New `beetroot install-frida <name> --version <tag>` verb (#205).** Pushes and launches `frida-server` on an adb-adopted device, wiring the previously API-only `AdbDevice.install_frida()` into the CLI that the `adopt` hint already advertised.
- **Set-but-inert fields under `binder: vm` now surface once at config time, not on every boot (#104).**
The advisory that names `beetroot.yaml` settings the plain-redroid VM can't
Expand Down Expand Up @@ -504,6 +506,10 @@
pre-abort rows in its `results` attribute).

### Quality & internals
- **`beetroot ls`/`status` reuse a single registry snapshot and memoize each instance's port index (#230).** Cuts the redundant `instances.json` reads and double `beetroot.yaml` parses per command.
- **`binder: vm` boot_cache no longer re-streams a full SHA-256 over the multi-GB rootfs on every warm `up` (#254).** Each input's digest is memoized on `(path, size, mtime)`, so an unchanged rootfs is not re-hashed — restoring the warm-start speedup — while a size/mtime change still forces a re-hash. `scripts/vm_cache_key.py` stays in parity.
- **`beetroot restore` reads the snapshot manifest without decompressing the whole archive twice (#265).** The manifest is written as the first archive member so `read_manifest` early-exits (old manifest-last archives still restore); restore also caps total decompressed bytes to reject a `.tar.zst` decompression bomb.
- **Fixed docs GitHub links that used `/blob/main` and `/tree/main` (#266).** The default branch is `master`; the `main` links 404'd.
- **CI `apt-get install` steps now run `apt-get update` first (#211).** A stale hosted-runner apt index no longer 404s and fails the install step (e2e, benchmark, beetroot-ci).
- **Migration docs refreshed to `api_version` 8 (#213).** Added the 6→7 (gapps intent/vendor split) and 7→8 (ports list) walkthroughs and dropped the stale "set `api_version: 6`" instructions, with a test guarding the documented version against `SUPPORTED_API_VERSION`.
- **`architecture.md` compose env-file contract corrected (#214).** Removed the retired `ADB_PORT`/`FRIDA_PORT`/`FRIDA_PORT_CONTROL` tokens and pointed at the per-instance `compose.override.yaml` ports overlay.
Expand Down Expand Up @@ -624,6 +630,17 @@
are absent), so shell regressions are caught locally before the push.

### Bug fixes
- **Fixed a `KeyError: 'frida'` crash on a Frida-less `ports:` config (#158).** It took down `ls`/`status`/`doctor`/`frida-addr` (and the whole-fleet `ls`); `frida_address` now returns the `unsupported` sentinel like the vm backend.
- **The cross-instance host-port collision check runs inside the exclusive registry lock (#183).** Two concurrent `apply`/`create` operations pinning the same `host:` port can no longer both pass and double-bind at `up`.
- **`beetroot apply` no longer wipes a working `frida-server` to an empty placeholder before re-downloading (#165).** A failed cache-miss re-fetch leaves the prior binary intact (staged via temp + atomic replace).
- **A malformed `BEETROOT_*_TIMEOUT` maps to a friendly `error: …` + exit 1 instead of a raw pydantic traceback at import (#197).** The bad var used to brick even `beetroot --help`; `Settings` is now built lazily inside `cli.main()`'s error boundary.
- **`AdbDevice.install_frida` no longer hangs forever launching `frida-server` (#253).** The daemon's stdio is detached off adb's captured pipes (`</dev/null >/dev/null 2>&1 &`), and daemon-launch commands carry a timeout.
- **`serial_is_available` / `_adb_unchecked` run adb with a 5s timeout (#256).** A wedged adb-server can no longer hang `ls`/`status`/`doctor` or the auto-install re-probe; a timeout is treated as unavailable/failed.
- **The local rootfs bake pins `--platform=linux/amd64` on the redroid pull (#258).** A non-x86_64 build host no longer bakes a wrong-arch image into the hard-x86_64 guest.
- **`console.table()` markup-escapes cell and header values (#259).** An instance path containing `[brackets]` is no longer silently corrupted (or crashed on) by rich's tag parser.
- **Module downloads validate bytes-received against `Content-Length` (#261).** A clean short read no longer caches a truncated module zip.
- **The `magisk-config.sh` denylist loop trims surrounding whitespace on each package (#263).** A space-padded entry (`com.foo, com.bar`) is enrolled as the real package instead of `' com.bar'`, so root-hiding actually applies.
- **The e2e `tier-vm-qemu` savevm cache now stores the `vm-overlay.cache-key` sidecar alongside the overlay (#260).** Without it the warm resume was always judged stale, so the boot-cache delivered no speedup in CI.
- **`beetroot up` self-heal now runs the cross-instance port-collision precheck, and `restart` self-heals a missing compose override (#166).** Neither boots with a colliding or zero-port mapping anymore.
- **`beetroot apply` flips the registry backend kind before staging Frida/modules (#182).** A transient fetch failure no longer leaves a `binder: vm` config dispatching the redroid backend.
- **`beetroot doctor` against an adopted adb device no longer reports a phantom `fail` for the GMS Magisk-denylist check (#201).** The row is `skip` since adb devices carry no Beetroot-managed denylist.
Expand Down
31 changes: 27 additions & 4 deletions docker/magisk-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,34 @@ if [ -n "$DENYLIST_PACKAGES" ]; then
# shellcheck disable=SC2086 # word-splitting on IFS=, is the point.
set -- $DENYLIST_PACKAGES
IFS="$OLD_IFS"
# Whitespace to strip from each field: a literal space and a literal
# tab. Built via printf so no raw tab has to live in the source (which
# would trip shfmt) — toybox printf honours \t.
trim_ws="$(printf ' \t')"
for pkg in "$@"; do
# Skip empty fields produced by trailing / leading commas.
# The pydantic regex in T1 already rejected empty entries at
# config-load time, but defend at the boundary too in case a
# hand-crafted .env arrives via the raw compose escape hatch.
# Trim leading / trailing space and tab so a spaced CSV like
# ``com.foo, com.bar`` enrols ``com.bar`` and not `` com.bar`` — a
# leading space never matches the real package, silently defeating
# the denylist (issue #263). Toybox sh has no ``${var//}`` or a
# coreutils ``xargs``, so strip one char at a time with POSIX
# ``case`` + parameter expansion.
while :; do
case "$pkg" in
[$trim_ws]*) pkg="${pkg#?}" ;;
*) break ;;
esac
done
while :; do
case "$pkg" in
*[$trim_ws]) pkg="${pkg%?}" ;;
*) break ;;
esac
done
# Skip empty fields produced by trailing / leading commas, or a
# field that was pure whitespace and trimmed away to nothing. The
# pydantic regex in T1 already rejected empty entries at config-load
# time, but defend at the boundary too in case a hand-crafted .env
# arrives via the raw compose escape hatch.
if [ -z "$pkg" ]; then
continue
fi
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This section walks you through getting Beetroot running on your host machine —
## Steps

1. **[Prerequisites](prerequisites.md)** — Linux host, Docker, kernel modules, `uv`, ADB.
2. **[Installation](installation.md)** — `uv tool install git+https://github.com/Xiddoc/Beetroot.git`, then `beetroot build` to build the base image. (If you're hacking on Beetroot itself, see [CLAUDE.md](https://github.com/Xiddoc/Beetroot/blob/main/CLAUDE.md) for the editable `uv sync` workflow.)
2. **[Installation](installation.md)** — `uv tool install git+https://github.com/Xiddoc/Beetroot.git`, then `beetroot build` to build the base image. (If you're hacking on Beetroot itself, see [CLAUDE.md](https://github.com/Xiddoc/Beetroot/blob/master/CLAUDE.md) for the editable `uv sync` workflow.)
3. **[Your First Instance](first-instance.md)** — `create` → `up` → `shell` → `down` end-to-end.

If you've already done the one-time setup and want to spin up a second environment, jump straight to the [Guides](../guides/index.md) section.
2 changes: 1 addition & 1 deletion docs/guides/adding-a-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ rather than a silent drop.
## 7. Test your backend

The pattern is laid out in
[`tests/test_backend_extension.py`](https://github.com/Xiddoc/Beetroot/blob/main/tests/test_backend_extension.py) —
[`tests/test_backend_extension.py`](https://github.com/Xiddoc/Beetroot/blob/master/tests/test_backend_extension.py) —
the synthetic third-backend test that grades the entire recipe at
every CI run. Extend the pattern in your own package:

Expand Down
4 changes: 2 additions & 2 deletions docs/guides/examples.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Examples

Beetroot ships a handful of starter `beetroot.yaml` files under the [`examples/`](https://github.com/Xiddoc/Beetroot/tree/main/examples) directory of the repository. They are **documentation only** — the CLI does not load or reference them. Each file is a hand-readable, copy-pasteable snippet you drop over a fresh `beetroot.yaml` when you want that configuration as your starting point.
Beetroot ships a handful of starter `beetroot.yaml` files under the [`examples/`](https://github.com/Xiddoc/Beetroot/tree/master/examples) directory of the repository. They are **documentation only** — the CLI does not load or reference them. Each file is a hand-readable, copy-pasteable snippet you drop over a fresh `beetroot.yaml` when you want that configuration as your starting point.

`beetroot create <name>` always writes a minimal `beetroot.yaml`:

Expand Down Expand Up @@ -160,7 +160,7 @@ beetroot apply research-clean
beetroot up research-clean
```

The `examples/` directory is a sibling of `docs/` in the [Beetroot repo](https://github.com/Xiddoc/Beetroot/tree/main/examples). If you installed via `uv tool install` and don't have a checkout handy, copy the YAML from this page directly into your instance's `beetroot.yaml`.
The `examples/` directory is a sibling of `docs/` in the [Beetroot repo](https://github.com/Xiddoc/Beetroot/tree/master/examples). If you installed via `uv tool install` and don't have a checkout handy, copy the YAML from this page directly into your instance's `beetroot.yaml`.

## Modifying your config

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ CLI is built on [Typer](https://typer.tiangolo.com/), so `--help` renders
as boxed sections with color (via Rich); flag and argument tables in this
reference mirror the same shape.

After `uv tool install`, invocations are plain `beetroot <verb>` — the tool venv puts `beetroot` directly on your `PATH`. (Contributors hacking on Beetroot from an editable `uv sync` checkout use `uv run beetroot <verb>` instead; see [CLAUDE.md](https://github.com/Xiddoc/Beetroot/blob/main/CLAUDE.md).)
After `uv tool install`, invocations are plain `beetroot <verb>` — the tool venv puts `beetroot` directly on your `PATH`. (Contributors hacking on Beetroot from an editable `uv sync` checkout use `uv run beetroot <verb>` instead; see [CLAUDE.md](https://github.com/Xiddoc/Beetroot/blob/master/CLAUDE.md).)

Beetroot's path model is Docker-inspired: an instance is any directory on disk containing a `beetroot.yaml`. The CLI discovers the current instance by walking up from `cwd` like `git` walks up to find `.git`. The cross-instance registry — name → absolute path — lives at `~/.config/beetroot/instances.json` (respects `XDG_CONFIG_HOME`).

Expand Down
32 changes: 27 additions & 5 deletions scripts/vm_cache_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,30 @@
# Streaming read size for hashing the (multi-GB) rootfs without loading it all.
_CHUNK = 1024 * 1024

# Memoized {(path, st_size, st_mtime_ns): sha256-hexdigest}. A multi-GB rootfs is
# immutable between builds, so re-streaming a full SHA-256 on every call is pure
# waste; keying on (path, size, mtime_ns) reuses the digest whenever the file is
# byte-identical and recomputes only when size/mtime say it changed. This keeps
# `hash_file` in parity with ``beetroot.vm.boot_cache._hash_file`` (issue #254).
_HASH_CACHE: dict[tuple[str, int, int], str] = {}


def _stream_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as fh:
while chunk := fh.read(_CHUNK):
digest.update(chunk)
return digest.hexdigest()


def hash_file(path: Path) -> str:
"""
Return the streamed SHA-256 of a file's contents.

Memoized on ``(path, st_size, st_mtime_ns)`` so an unchanged file is hashed
at most once; a size/mtime change forces a re-stream. The returned digest is
identical to hashing the bytes directly — the cache only skips redundant work.

Args:
path: The file to hash. Read in chunks so a multi-GB rootfs image is
never loaded into memory at once.
Expand All @@ -53,11 +72,14 @@ def hash_file(path: Path) -> str:
Raises:
FileNotFoundError: If ``path`` does not exist.
"""
digest = hashlib.sha256()
with path.open("rb") as fh:
while chunk := fh.read(_CHUNK):
digest.update(chunk)
return digest.hexdigest()
stat = path.stat()
key = (str(path), stat.st_size, stat.st_mtime_ns)
cached = _HASH_CACHE.get(key)
if cached is not None:
return cached
hexdigest = _stream_sha256(path)
_HASH_CACHE[key] = hexdigest
return hexdigest


def compute_cache_key(paths: list[Path], *, prefix: str = DEFAULT_PREFIX) -> str:
Expand Down
Loading