Skip to content

Commit 345b152

Browse files
sylirreclaude
andcommitted
audit: fix sysdata path, verify layer digests, drop legacy alias
Three issues surfaced while auditing the new container layout: 1. setup_fake_sysdata() and fake_proc_bindings() still wrote to and bound from installed-rootfs/<name>/. login binds rootfs/sys/.empty from the new path, so the empty file was missing inside the container. Both helpers now take the rootfs path directly and keep stub files co-located with the container. 2. _download_blob() did not verify layer integrity, contrary to the architecture spec. It now streams each blob through hashlib.sha256 and rejects mismatched digests before promoting the temp file into the cache. Only sha256 is accepted. 3. INSTALLED_ROOTFS_DIR was kept as a back-compat alias of the legacy path. Its only remaining users (arch.py fallback, sysdata.py) have moved to CONTAINERS_DIR, so the alias and the unused private name re-exports in helpers/rootfs.py are removed. CLAUDE.md updated to describe the new sysdata layout and the layer integrity check, and to reflect detect_installed_arch() resolving bare names against CONTAINERS_DIR. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8e1d71f commit 345b152

8 files changed

Lines changed: 86 additions & 45 deletions

File tree

CLAUDE.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ attributes.
203203
- Installed arch detected by `detect_installed_arch(rootfs_path)` in
204204
`arch.py`: reads the first 20 bytes of candidate ELF binaries, checks
205205
magic, reads endianness from `EI_DATA`, unpacks `e_machine` with `struct`,
206-
and maps via `_ELF_MACHINE_MAP`. Accepts a full rootfs path or dist name.
206+
and maps via `_ELF_MACHINE_MAP`. Accepts either a full rootfs path or a
207+
bare container name (resolved as `CONTAINERS_DIR/<name>/rootfs`).
207208
- 32-bit support on AArch64 probed via `ctypes.CDLL(None).personality(PER_LINUX32)`
208209
- Cross-arch: proot `-q qemu-*` (QEMU user-mode)
209210
- 32-bit guests on 64-bit hosts run natively when supported
@@ -232,6 +233,16 @@ URLs. CDN hosts reject `Bearer` tokens (HTTP 400).
232233
`_AuthStrippingRedirectHandler` strips the `Authorization` header on
233234
cross-host redirects.
234235

236+
**Layer integrity:** `_download_blob()` streams the blob through a
237+
`hashlib.sha256` hasher while writing it. After the body is fully read the
238+
computed digest is compared to the expected one from the manifest; on
239+
mismatch the temp file is unlinked and `RuntimeError` is raised before any
240+
data is promoted into the cache. Only `sha256` digests are accepted (the
241+
only algorithm currently used by Docker Hub and the OCI distribution
242+
spec). Cached layers are trusted because the cache only ever contains
243+
verified blobs — verification happens before `os.replace` moves the temp
244+
file to its final location.
245+
235246
**`parse_image_ref(image_ref)`** returns `(registry, repo, tag)` where
236247
`registry` is empty for Docker Hub images.
237248

@@ -330,9 +341,20 @@ Uses `os.scandir` for iteration, `shutil.rmtree` for subdirectories,
330341

331342
### Fake sysdata
332343

333-
On login, fake `/proc` and `/sys` entries are written to `$PREFIX/var/lib/proot-distro/`
334-
and bind-mounted read-only. Constants `_FAKE_LOADAVG`, `_FAKE_STAT`,
335-
`_FAKE_UPTIME`, `_FAKE_VMSTAT` are hardcoded in `proot_distro/sysdata.py`.
344+
On install and on every login of a `normal`-type container, fake `/proc`
345+
and `/sys` stub files are written **inside the container's own rootfs**
346+
(`containers/<name>/rootfs/proc/.loadavg`, `…/.stat`, `…/.uptime`,
347+
`…/.version`, `…/.vmstat`, `…/.sysctl_entry_cap_last_cap`,
348+
`…/.sysctl_inotify_max_user_watches`, plus `…/sys/.empty`) and then
349+
bind-mounted by proot over the corresponding `/proc/*` and
350+
`/sys/fs/selinux` paths. Storing them inside the rootfs keeps them
351+
co-located with the container, so `remove` cleans them up automatically.
352+
Both `setup_fake_sysdata(rootfs)` and `fake_proc_bindings(rootfs)` take
353+
the absolute rootfs path as their argument. Constants `_FAKE_LOADAVG`,
354+
`_FAKE_STAT`, `_FAKE_UPTIME`, `_FAKE_VMSTAT` are hardcoded in
355+
`proot_distro/sysdata.py`. `termux`-type containers do not get fake
356+
sysdata (they share the host's `/proc` and `/sys` via the existing
357+
`--bind=/proc` / `--bind=/sys`).
336358

337359
### Subcommand help
338360

proot_distro/arch.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import struct
3030
import sys
3131

32-
from proot_distro.constants import PREFIX, INSTALLED_ROOTFS_DIR
32+
from proot_distro.constants import PREFIX, CONTAINERS_DIR
3333
from proot_distro.colors import C, msg
3434

3535

@@ -89,15 +89,15 @@ def _elf_arch(path: str) -> str:
8989

9090

9191
def detect_installed_arch(dist_name_or_rootfs: str) -> str:
92-
"""Detect CPU architecture of an installed distro by reading ELF headers.
92+
"""Detect CPU architecture of an installed container by reading ELF headers.
9393
94-
Accepts either a plain distribution name (looked up under INSTALLED_ROOTFS_DIR)
95-
or a full path to the rootfs directory.
94+
Accepts either a plain container name (resolved as
95+
``CONTAINERS_DIR/<name>/rootfs``) or a full path to the rootfs directory.
9696
"""
9797
if os.sep in dist_name_or_rootfs or dist_name_or_rootfs.startswith("/"):
9898
root = dist_name_or_rootfs
9999
else:
100-
root = os.path.join(INSTALLED_ROOTFS_DIR, dist_name_or_rootfs)
100+
root = os.path.join(CONTAINERS_DIR, dist_name_or_rootfs, "rootfs")
101101

102102
candidates = [
103103
"/usr/bin/bash", "/usr/bin/sh", "/usr/bin/su", "/usr/bin/busybox",

proot_distro/commands/install.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def _cleanup() -> None:
156156
f"Registering Android-specific UIDs and GIDs...{C['RST']}")
157157
register_android_ids(rootfs_dir)
158158

159-
setup_fake_sysdata(install_name)
159+
setup_fake_sysdata(rootfs_dir)
160160

161161
image_env = metadata.get("env", [])
162162
if image_env:

proot_distro/commands/login.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ def command_login(args, configs: dict) -> None: # noqa: ARG001
365365
+ shell_args
366366
)
367367

368-
setup_fake_sysdata(dist_name)
368+
setup_fake_sysdata(rootfs)
369369

370370
# Architecture detection.
371371
target_arch = detect_installed_arch(rootfs)
@@ -425,7 +425,7 @@ def command_login(args, configs: dict) -> None: # noqa: ARG001
425425
proot_args.append(f"--bind=/proc/self/fd/{i}:/dev/{name}")
426426

427427
proot_args.append(f"--bind={rootfs}/sys/.empty:/sys/fs/selinux")
428-
proot_args += fake_proc_bindings(dist_name)
428+
proot_args += fake_proc_bindings(rootfs)
429429

430430
tmp_dir = os.path.join(rootfs, "tmp")
431431
os.makedirs(tmp_dir, exist_ok=True)

proot_distro/constants.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,6 @@
5757
# Legacy rootfs path — used only for migrating old installations.
5858
LEGACY_ROOTFS_DIR = os.path.join(RUNTIME_DIR, "installed-rootfs")
5959

60-
# Kept for backward compatibility during transition; points to legacy path.
61-
INSTALLED_ROOTFS_DIR = LEGACY_ROOTFS_DIR
62-
6360
# Layer and manifest caches (subdirectories of the download cache).
6461
LAYER_CACHE_DIR = os.path.join(DOWNLOAD_CACHE_DIR, "layers")
6562
MANIFEST_CACHE_DIR = os.path.join(DOWNLOAD_CACHE_DIR, "manifests")

proot_distro/helpers/docker.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
# tokens are stripped on cross-host redirects because CDN pre-signed URLs
2626
# reject them with HTTP 400.
2727

28+
import hashlib
2829
import json
2930
import os
3031
import re
@@ -322,7 +323,12 @@ def _fetch_config_blob(
322323
def _download_blob(
323324
repo: str, digest: str, token: str, registry: str = ""
324325
) -> str:
325-
"""Download a blob to the layer cache; return the local file path."""
326+
"""Download a blob to the layer cache; return the local file path.
327+
328+
Computes the SHA-256 of the blob while it streams to disk and verifies
329+
it against the expected *digest* before promoting the temp file to its
330+
final location. The cache therefore only ever contains intact layers.
331+
"""
326332
dest = _layer_cache_path(digest)
327333
os.makedirs(os.path.dirname(dest), exist_ok=True)
328334
if os.path.isfile(dest):
@@ -336,6 +342,17 @@ def _download_blob(
336342
req = urllib.request.Request(url, headers=headers)
337343
tmp = dest + ".tmp"
338344
use_tty = sys.stderr.isatty()
345+
346+
if ":" not in digest:
347+
raise RuntimeError(f"Malformed layer digest '{digest}'.")
348+
algo, expected_hex = digest.split(":", 1)
349+
if algo.lower() != "sha256":
350+
raise RuntimeError(
351+
f"Unsupported layer digest algorithm '{algo}' (only sha256 "
352+
f"is supported)."
353+
)
354+
hasher = hashlib.sha256()
355+
339356
try:
340357
with _auth_stripping_opener.open(req) as resp, open(tmp, "wb") as fh:
341358
total = int(resp.headers.get("Content-Length", 0))
@@ -345,6 +362,7 @@ def _download_blob(
345362
if not chunk:
346363
break
347364
fh.write(chunk)
365+
hasher.update(chunk)
348366
downloaded += len(chunk)
349367
if use_tty:
350368
pfx = f"{C['BLUE']}[{C['GREEN']}*{C['BLUE']}] {C['CYAN']}"
@@ -363,6 +381,14 @@ def _download_blob(
363381
if use_tty:
364382
sys.stderr.write("\r\033[K")
365383
sys.stderr.flush()
384+
385+
actual_hex = hasher.hexdigest()
386+
if actual_hex != expected_hex.lower():
387+
raise RuntimeError(
388+
f"Layer integrity check failed for digest '{digest}': "
389+
f"expected {expected_hex}, got {actual_hex}."
390+
)
391+
366392
os.replace(tmp, dest)
367393
except BaseException:
368394
if use_tty:

proot_distro/helpers/rootfs.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,3 @@ def register_android_ids(rootfs: str) -> None:
193193
)
194194
except OSError:
195195
pass
196-
197-
198-
# ---------------------------------------------------------------------------
199-
# Backward-compatible aliases (kept for callers still using old names)
200-
# ---------------------------------------------------------------------------
201-
202-
_write_environment = write_environment
203-
_fix_path_in_configs = fix_path_in_configs
204-
_write_resolv_conf = write_resolv_conf
205-
_write_hosts = write_hosts
206-
_register_android_ids = register_android_ids

proot_distro/sysdata.py

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
# Architecture: Supplies fake /proc and /sys content that proot bind-mounts
2222
# read-only into the container. Android restricts or blocks several /proc
2323
# files; providing static replacements ensures distro tools that read them
24-
# (top, htop, etc.) work correctly. setup_fake_sysdata() takes a dist_name
25-
# and resolves the path internally for backward compatibility.
24+
# (top, htop, etc.) work correctly. The fake files live inside the
25+
# container's own rootfs (containers/<name>/rootfs/proc/.* and sys/.empty)
26+
# so they are removed together with the container and stay aligned with the
27+
# new storage layout.
2628

2729
import os
2830

2931
from proot_distro.constants import (
30-
INSTALLED_ROOTFS_DIR,
3132
DEFAULT_FAKE_KERNEL_RELEASE,
3233
DEFAULT_FAKE_KERNEL_VERSION,
3334
)
@@ -243,11 +244,14 @@ def _write_if_missing(path: str, content: str) -> None:
243244
fh.write(content)
244245

245246

246-
def setup_fake_sysdata(dist_name: str) -> None:
247-
"""Create fake /proc and /sys stubs required by proot on Android."""
248-
root = os.path.join(INSTALLED_ROOTFS_DIR, dist_name)
247+
def setup_fake_sysdata(rootfs: str) -> None:
248+
"""Create fake /proc and /sys stubs required by proot on Android.
249+
250+
*rootfs* is the absolute path to the container's rootfs directory
251+
(e.g. ``$RUNTIME_DIR/containers/<name>/rootfs``).
252+
"""
249253
for d in ("proc", "sys", "sys/.empty"):
250-
p = os.path.join(root, d)
254+
p = os.path.join(rootfs, d)
251255
os.makedirs(p, exist_ok=True)
252256
os.chmod(p, 0o700)
253257

@@ -257,22 +261,25 @@ def setup_fake_sysdata(dist_name: str) -> None:
257261
f"{DEFAULT_FAKE_KERNEL_VERSION}\n"
258262
)
259263

260-
_write_if_missing(os.path.join(root, "proc/.loadavg"), _FAKE_LOADAVG)
261-
_write_if_missing(os.path.join(root, "proc/.stat"), _FAKE_STAT)
262-
_write_if_missing(os.path.join(root, "proc/.uptime"), _FAKE_UPTIME)
263-
_write_if_missing(os.path.join(root, "proc/.version"), fake_version)
264-
_write_if_missing(os.path.join(root, "proc/.vmstat"), _FAKE_VMSTAT)
264+
_write_if_missing(os.path.join(rootfs, "proc/.loadavg"), _FAKE_LOADAVG)
265+
_write_if_missing(os.path.join(rootfs, "proc/.stat"), _FAKE_STAT)
266+
_write_if_missing(os.path.join(rootfs, "proc/.uptime"), _FAKE_UPTIME)
267+
_write_if_missing(os.path.join(rootfs, "proc/.version"), fake_version)
268+
_write_if_missing(os.path.join(rootfs, "proc/.vmstat"), _FAKE_VMSTAT)
265269
_write_if_missing(
266-
os.path.join(root, "proc/.sysctl_entry_cap_last_cap"), "40\n"
270+
os.path.join(rootfs, "proc/.sysctl_entry_cap_last_cap"), "40\n"
267271
)
268272
_write_if_missing(
269-
os.path.join(root, "proc/.sysctl_inotify_max_user_watches"), "4096\n"
273+
os.path.join(rootfs, "proc/.sysctl_inotify_max_user_watches"),
274+
"4096\n",
270275
)
271276

272277

273-
def fake_proc_bindings(dist_name: str) -> list:
274-
"""Return --bind args for fake /proc entries that are unreadable on Android."""
275-
root = os.path.join(INSTALLED_ROOTFS_DIR, dist_name)
278+
def fake_proc_bindings(rootfs: str) -> list:
279+
"""Return --bind args for fake /proc entries unreadable on Android.
280+
281+
*rootfs* is the absolute path to the container's rootfs directory.
282+
"""
276283
bindings = []
277284
checks = [
278285
("/proc/loadavg", "proc/.loadavg"),
@@ -289,6 +296,6 @@ def fake_proc_bindings(dist_name: str) -> list:
289296
fh.read(1)
290297
except OSError:
291298
bindings.append(
292-
f"--bind={os.path.join(root, fake_rel)}:{real}"
299+
f"--bind={os.path.join(rootfs, fake_rel)}:{real}"
293300
)
294301
return bindings

0 commit comments

Comments
 (0)