3 Commits
Author SHA1 Message Date
retoorandClaude Sonnet 5.5 fdb96c7b1c Document apt field trial and gap G7
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 21:23:29 +02:00
retoorandClaude Sonnet 5.5 0d18aff519 Research solutions for all known gaps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 21:01:23 +02:00
retoorandClaude Sonnet 5.5 3b5e28e06e Document project, add AGENTS.md release workflow and release script
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 20:57:55 +02:00
7 changed files with 389 additions and 2 deletions
+77
View File
@@ -0,0 +1,77 @@
# AGENTS.md
Rules for any AI agent (or human) changing this repository. The user's global rules
also apply; this file adds the project-specific ones. Keep this file current: it is
part of every release.
## Project in one paragraph
`doos` is a user-level container tool in C++20. `attach` builds a user + mount + pid
+ uts + ipc (+ net) namespace set, overlays the host's `/usr`, `/etc`, `/opt` with a
per-container upper dir, pivots into it and runs a command. Networking is slirp4netns.
No root at runtime. Authoritative behavior: `SPEC.md`. Design notes: `docs/ARCHITECTURE.md`.
Measurements and gaps: `docs/RESEARCH.md`.
## Mandatory release workflow (every change, same session)
1. Make the change. Smallest diff that does what was asked.
2. Update docs in the same change (see the table below).
3. Pick the bump:
- `patch`: bug fix, docs, tests, tooling, no behavior change for users.
- `minor`: new or changed user-visible behavior (flag, command, mount, limit).
- `major`: breaking change to CLI, state layout, or container compatibility.
4. Run `scripts/release.sh <patch|minor|major> "imperative commit subject"`.
It bumps `VERSION`, builds, runs `tests/run.sh`, commits all changes, and creates
the annotated tag `vX.Y.Z`. It aborts on any build or test failure.
5. Verify live: run `build/doos` once against the real thing (the e2e suite does this).
6. Report honestly what passed and what did not. Do not push unless the user asks.
Never hand-edit `VERSION` or create tags manually. Never skip the tests to release.
## Docs that must be updated with the code
| If you change | Update |
|---|---|
| CLI commands, flags, output | `README.md`, `SPEC.md` (CLI), `src/cli.cpp` usage text |
| Limits or constants in `src/spec.hpp` | `SPEC.md` limits/layout tables (both must match) |
| Mounts, namespaces, isolation | `SPEC.md`, `docs/ARCHITECTURE.md`, `docs/RESEARCH.md` host safety section |
| Networking | `SPEC.md`, `docs/RESEARCH.md` networking table |
| Source files added/removed/renamed | `docs/ARCHITECTURE.md` file map, `CMakeLists.txt`, this file |
| A new measurement | `docs/RESEARCH.md` with machine, kernel, and what was run |
| A new known gap or a closed gap | `docs/RESEARCH.md` gaps list and `README.md` |
| These workflow rules | this file |
| Anything above, in every release | `README.md` version line and `CHANGELOG.md` entry |
Docs describe what the system does today. No roadmap text. A gap is listed next to
what would close it. A number in the docs needs a run behind it, else say it is unmeasured.
## Changelog
Every release adds a `CHANGELOG.md` entry (newest first): version, date, bullets of
what changed for users. The release script does not write it; write it before running
the script.
## Code rules
- C++20, extremely object oriented: one class per responsibility (see file map).
- Every limit is a named constant in `src/spec.hpp` and listed in `SPEC.md`.
- No unbounded loops, sizes or recursion. Add a cap when you add a loop.
- Destructive operations: dry-run by default, `--yes` to act, scoped to the store.
- Fail loudly with a clear message. Never swallow an error that leaves state half-built.
- Match surrounding style. No new dependencies without a stated reason.
- Never use em dashes (U+2014) anywhere. The release script rejects them.
- Output must stay readable on a narrow terminal.
## Testing
`tests/run.sh [path/to/doos]` runs the real path: real overlayfs, namespaces,
slirp4netns, filesystem. Add a case for every behavior change. A green run is required
before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be
installed once (needs sudo, ask the user, do not do it yourself).
## Environment notes
- State dir: `$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`.
Tests use a temp `DOOS_HOME`; never point tests at the user's real store.
- Build: `cmake -S . -B build && cmake --build build`.
- Requirements: Linux >= 5.11, `slirp4netns` for the default network mode.
+17
View File
@@ -0,0 +1,17 @@
# Changelog
## 0.1.3 - 2026-10-23
- Docs: field trial of apt and common tools, new gap G7 (host-owned lower layer), README limits section. No code change.
## 0.1.2 - 2026-10-23
- Docs: researched solutions for all known gaps (G1 to G6) in `docs/RESEARCH.md`, with measurements. Nothing implemented yet.
## 0.1.1 - 2026-10-23
- Docs: AGENTS.md release workflow, ARCHITECTURE.md, expanded README, this changelog.
- Tooling: `scripts/release.sh` (bump, build, test, commit, tag).
## 0.1.0 - 2026-10-23
- First release: `list`, `get_or_create`, `attach`, `delete`.
- overlayfs over host `/usr /etc /opt`, user/mount/pid/uts/ipc/net namespaces.
- Networking: slirp4netns (default), `host`, `none`.
- End-to-end test suite and AppArmor profile for Ubuntu 24.04+.
+61
View File
@@ -1,5 +1,7 @@
# doos
Version: see `VERSION` (currently 0.1.3). Release history: `CHANGELOG.md`.
User-level containers in C++20: overlayfs over the host's `/usr`, `/etc`, `/opt`, user/mount/pid namespaces, networking through slirp4netns. No root at runtime.
```
@@ -18,3 +20,62 @@ sudo cp apparmor/doos /etc/apparmor.d/doos && sudo apparmor_parser -r /etc/appar
```
See `SPEC.md` (behavior, limits) and `docs/RESEARCH.md` (what works, measurements, known gaps).
## Usage examples
```
doos get_or_create dev # created dev
doos attach dev # interactive shell as root inside
doos attach dev -- apt --version # run one command
doos attach --net none sandbox -- sh # no network at all
doos list
doos delete dev # dry run: shows what would go
doos delete --yes dev # really deletes
```
Container writes to `/usr`, `/etc`, `/opt` stay in the container (copy-on-write);
`/root` and `/var` are private and persistent. `/tmp` is memory-backed and lost on exit.
A container is `running` while its first `attach` command is alive; more `attach` calls
join it. Data persists until `delete --yes`.
## Current limits you will hit
Tools run fine (curl, git, gcc, python3, pip, ping all verified). Package management does
not: `apt-get update` needs a seeded `/var` and `-o APT::Sandbox::User=root`, and
`apt-get install` cannot finish configuring packages because host-root-owned files are
unwritable and unchownable inside. Details, measurements and the fix are in
`docs/RESEARCH.md` (G7, "Field trial").
## Configuration
| Variable | Meaning |
|---|---|
| `DOOS_HOME` | State directory (default `~/.local/share/doos`). No `,` or `:` allowed. |
| `XDG_DATA_HOME` | Used as `$XDG_DATA_HOME/doos` when `DOOS_HOME` is unset. |
## Troubleshooting
| Message | Cause and fix |
|---|---|
| `write /proc/<pid>/uid_map ... blocked` or `mount ...: Permission denied` | AppArmor restricts user namespaces. Install `apparmor/doos` (command above). |
| `slirp4netns failed to start` | Install `slirp4netns`, or use `--net none` / `--net host`. |
| `state path must not contain ',' or ':'` | Set `DOOS_HOME` to a plain path. |
| `container 'x' is running` on delete | Exit the attached command first. |
| `container limit reached (64)` | Delete one first. |
## Safety summary
Host files are never written: system dirs are reached only through overlayfs. Host
`/home`, `/sys` and host loopback are not reachable in the default mode. `--net host`
removes network isolation and is opt-in. Known gaps (no cgroups, readable host `/etc`)
are listed with researched solutions (G1 to G6, not implemented) in `docs/RESEARCH.md`.
## Project docs
| File | Content |
|---|---|
| `SPEC.md` | Behavior contract and limits |
| `docs/ARCHITECTURE.md` | Code map and flows |
| `docs/RESEARCH.md` | What works unprivileged, measurements, gaps |
| `AGENTS.md` | Rules for AI agents, release workflow |
| `CHANGELOG.md` | Release history |
+1 -1
View File
@@ -1 +1 @@
0.1.0
0.1.3
+75
View File
@@ -0,0 +1,75 @@
# Architecture
How doos works internally. Behavior contract is `SPEC.md`; this explains the code.
## File map
| File | Class / role |
|---|---|
| `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. |
| `src/util.{hpp,cpp}` | `SysError` (errno-carrying exception), `Fd` (RAII fd), `FileLock` (bounded flock), `fsutil` (mkdirs, read/write, bounded `removeTree`). |
| `src/network.{hpp,cpp}` | `NetMode` + `Network` (parse, resolv.conf per mode), `SlirpSession` (owns the slirp4netns process). |
| `src/container.{hpp,cpp}` | `Container` (one container's paths, meta, pid file, layout), `Store` (list, get_or_create, remove). |
| `src/sandbox.{hpp,cpp}` | `Sandbox`: start a container (clone + mounts + pivot_root + init) or join a running one (setns). |
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing and the four commands. |
| `src/main.cpp` | Catches exceptions, prints `doos: <message>`, exit 1. |
| `tests/run.sh` | End-to-end suite. |
| `scripts/release.sh` | Bump, build, test, commit, tag. |
| `apparmor/doos` | Profile granting `userns` on Ubuntu 24.04+. |
## attach flow
```
Cli::cmdAttach
Store::getOrCreate lock, create layout + meta if missing
Sandbox::run
FileLock (start-or-join decision)
runningPid()? yes -> join() no -> start()
start():
clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1
parent writes /proc/<child>/{setgroups,uid_map,gid_map} 0 -> invoking uid, size 1
slirp mode: SlirpSession.start(child) waits for ready-fd
recordPid(child) (pid + starttime, defeats pid reuse)
lock.unlock() joiners must not wait for container lifetime
write 'G' on sync pipe child continues
parent: ignore SIGINT/SIGQUIT, forward SIGTERM/SIGHUP, waitpid(child)
on exit: pid file cleared, slirp stopped (RAII)
child (containerInit, pid 1):
PDEATHSIG=SIGKILL, close inherited lock fd, wait for 'G'
setupRoot -> sethostname -> loopback up (isolated netns) -> rlimits/env/no_new_privs
fork worker -> exec command
pid 1 loop: forward signals, reap all, on worker exit kill(-1, SIGKILL), exit with its code
```
## setupRoot order
1. `MS_REC|MS_PRIVATE` on `/` so nothing propagates to the host.
2. Bind the container `rootfs/` onto itself (pivot_root needs a mountpoint).
3. Write `resolv.conf` into the `/etc` upper dir (matches network mode every start).
4. Overlay mounts (`lowerdir=<host dir>,upperdir,workdir,userxattr`, nosuid, nodev).
5. Compat dirs: recreate host symlinks (usr-merge) or overlay real dirs.
6. Bind private dirs (`/root`, `/var`), mount tmpfs (`/tmp`, `/run`).
7. `/dev`: tmpfs, bind host device nodes, new devpts, `/dev/shm`, symlinks.
8. `/proc`, then read-only remount and masks from `spec.hpp`.
9. `chdir(rootfs)`, `pivot_root(".", ".")`, lazy unmount of old root.
## join flow
Open `/proc/<pid>/ns/{user,mnt,uts,ipc,net,pid}` and `/proc/<pid>/root` first, fork,
`setns` user first (grants capability for the rest), `fchdir`+`chroot` to the container
root, fork again (pid ns applies to children), apply env and limits, exec.
In `host` network mode the net namespace is not joined.
## Lifetime model
The namespaces live as long as the first `attach` command. When it exits, pid 1 kills
the rest and the container is `stopped`. State (upper dirs, `/root`, `/var`) persists on
disk until `delete --yes`. There is no daemon.
## Error handling
All failures throw (`SysError` or `std::runtime_error`); `main` prints one line.
Inside the child, errors print `doos: <message>` and exit 125. A parent-side failure
after `clone` kills the child (`Reaper`), so no half-built container survives.
+123 -1
View File
@@ -29,7 +29,7 @@ Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1
- No host paths writable: overlay lower dirs are only reachable through overlay (writes copy up to the container upper). Verified: write to `/usr` and `/etc` inside leaves host untouched.
- Only `/usr /etc /opt` and compat dirs are exposed read-through. `/home`, `/var`, `/sys`, `/root` of the host are not present.
- Old root detached after pivot, `no_new_privs`, `/proc` hardened, nosuid/nodev on tmpfs/devpts/overlay.
- Residual exposure (gaps): host `/etc` is readable inside (any world-readable secret in it); abstract unix sockets are shared in `--net host`; no resource cgroups, so a container can fill the disk through its upper dir or fork-bomb within the user's `RLIMIT_NPROC`; kernel attack surface of userns is that of the host kernel.
- Residual exposure (gaps G1 to G6 below): no resource limits, no disk cap, single uid, host `/etc` readable, abstract unix sockets shared in `--net host`, no seccomp filter.
## Measurements (this machine, 5 sequential `attach -- true`)
@@ -39,3 +39,125 @@ Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1
| none | ~6 ms |
Single run set, one machine; no other hardware measured.
## Known gaps and researched solutions
Status of everything in this section: **researched, not implemented**. Probes ran on the
machine above (cgroup v2, systemd user manager, ext4 root, uid 1000). Probe cgroups were
removed afterwards. Rows marked "unmeasured" have no run behind them.
| Id | Gap | Proposed fix | Bump | Code that would change |
|---|---|---|---|---|
| G1 | No memory/cpu/pids limits | Per-container cgroup v2 leaf under the delegated user service | minor | `sandbox.cpp` start/join, new `Cgroup` class, `spec.hpp` |
| G2 | No disk cap on upper dirs | Usage watchdog with `cgroup.freeze` at the hard limit; optional tmpfs upper | minor | `Sandbox::start` parent loop, `spec.hpp` |
| G3 | Single uid mapping | `newuidmap`/`newgidmap` with `/etc/subuid`, fallback to single map | minor, delete path changes | `sandbox.cpp` writeMap, `Store::remove` |
| G4 | Host `/etc` readable | Allowlist-only private `/etc` instead of an overlay of host `/etc` | minor | `spec.hpp` lists, `setupRoot` |
| G5 | `--net host` shares abstract sockets | Keep opt-in; evaluate pasta as an isolated replacement | patch or minor | `network.cpp` |
| G6 | No seccomp, nested userns allowed | Hand-built seccomp-bpf denylist after `no_new_privs` | minor | new `Seccomp` class, `sandbox.cpp` |
| G7 | Host-owned lower dirs are not writable; `chown --reference` of host files fails; `/var` is empty so apt has no databases | Image-based lower layer (extracted rootfs, owned by mapped ids) plus G3; short term: pre-seeded directory skeleton | major (changes layout) | `Container::prepareLayout`, `setupRoot`, new image import |
### G1: memory, cpu and pids limits (cgroup v2)
Findings, measured:
- `/sys/fs/cgroup` is cgroup2 with `nsdelegate`. `user@1000.service/cgroup.controllers` lists `cpuset cpu io memory pids`, and its `subtree_control` already enables all five. A plain user can `mkdir` below `/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/`.
- A leaf with `pids.max=20`: the 21st fork failed with `Cannot fork`.
- A leaf with `memory.max=64m`: touching 200 MiB ended in `Killed`, `memory.events` showed `oom_kill 1`. An untouched `bytearray(200MiB)` is not charged (lazy pages), so the first attempt proved nothing; only the touched run counts.
- Gotcha, measured: enabling `+memory +pids` in `subtree_control` of a cgroup makes it a non-leaf; writing a pid into its `cgroup.procs` then fails with `I/O error`. The layout must be `.../doos/` (controllers enabled here, no processes) with one leaf per container `.../doos/<name>/` (limits set here, processes here).
- `RLIMIT_NPROC` is not an option: it counts per real uid across the whole host user, not per container.
Design:
1. Discover the base: parse `/proc/self/cgroup`, take the path up to and including `user@<uid>.service`.
2. Parent creates `doos/<name>` after `clone`, writes `memory.max`, `memory.swap.max=0`, `pids.max`, `cpu.max`, then writes the child pid to `cgroup.procs` before the sync byte `G` (child never runs outside limits). `join()` children enter via the same `cgroup.procs` write before `setns`.
3. On exit: write `1` to `cgroup.kill` (kernel >= 5.14), then `rmdir`.
4. Defaults as constants in `spec.hpp` (static ceilings, no autotuning): for example memory 2g, pids 512, cpu unlimited. Flags `--memory`, `--pids`, `--cpus` may lower or raise within a hard cap.
5. No delegation (no systemd user manager, cgroup v1): fail loudly with a counter and a clear reason; `--no-limits` is an explicit opt-out, never silent.
Rejected: `systemd-run --user --scope -p MemoryMax=...` (needs D-Bus, one more moving part). Unmeasured: behavior when doos itself runs inside a scope that is not under `user@.service` (the tmux scope here is, but other launchers may not be).
Sources: [rootlesscontaine.rs cgroup v2](https://rootlesscontaine.rs/getting-started/common/cgroup2/), [runc cgroup v2](https://github.com/thaJeztah/runc/blob/main/docs/cgroup-v2.md), [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/).
### G2: disk usage of upper dirs
Findings:
- Project quotas (XFS `pquota`, ext4 project quota) need root to set. This machine's root is ext4 on `/dev/md2`. Not usable unprivileged. Docker's overlay2 size limit also only works on XFS with `pquota` ([source](https://fabianlee.org/2020/01/15/docker-use-overlay2-with-an-xfs-backing-filesystem-to-limit-rootfs-size/)).
- Loop-mounted ext4 images need root; the kernel does not allow ext4 mounts from a user namespace. `fuse2fs` is not installed here. `fuse-overlayfs` is installed but has no quota either.
- A `size=` tmpfs mounted inside the container userns is enforced, measured: `size=1m`, writing 5 MiB ended in `No space left on device`.
Design (follows the rule "machines hold, humans analyze"):
1. Default: the parent process (already alive in `Sandbox::start`) measures upper-dir usage on a fixed interval with a floor (for example every 30 s, never faster). Soft limit: warning on stderr. Hard limit: write `1` to the container's `cgroup.freeze` (needs G1). Freeze stops processes but deletes nothing; the human frees space or raises the limit. Never auto-delete.
2. Opt-in `--ephemeral`: upper dirs on a size-capped tmpfs. Hard cap by the kernel, but memory-backed, counted in the memory cgroup, and lost on exit.
3. Constants (soft, hard, interval) in `spec.hpp`.
Unmeasured: cost of scanning a large upper dir (needs a run on a populated container); overshoot between two scans.
### G3: more than one uid inside
Findings, measured with `podman unshare` (map `0 -> 1000`, `1..65536 -> 100000..`): overlayfs with `userxattr` mounts fine under a multi-id map. `/etc/subuid` and `/etc/subgid` contain `retoor:100000:65536`, `newuidmap` and `newgidmap` are installed.
- Host root-owned files in the lower dirs show as `65534 nobody`. That is already true with the single map, so nothing gets worse.
- Gain: `chown`, `su`, `apt` sandbox users, daemons that drop privileges.
- Cost, important: files created by container uids 1..65535 are owned by host uids 100000+. The host user cannot delete them. `Store::remove` as written would fail with `Permission denied`. Delete must run inside a user namespace with the same map (re-enter and `rm` there, or `podman unshare`-style helper) before removing the directory.
Design: after `clone`, parent execs `newuidmap <pid> 0 <uid> 1 1 <sub> 65536` and the gid equivalent, `setgroups` allowed. Fallback to the current single map when the user has no subuid range, with a printed notice. Store the chosen mode in `meta` (`idmap=single|subuid`) so delete knows which path to take.
Unmeasured: `apt install` end to end.
### G4: host `/etc` visible inside
Finding, measured: `find /etc -type f -perm -004` returns 1116 world-readable files on this host, including names such as `/etc/fail2ban/action.d/cloudflare-token.conf` (content not read). Anything world-readable in host `/etc` is readable in every container.
Design: replace the `/etc` overlay by a private `/etc`. At create time, populate `private/etc` from an allowlist in `spec.hpp` (`kEtcAllow`): `passwd group nsswitch.conf ld.so.cache ld.so.conf ld.so.conf.d ssl/certs ca-certificates alternatives bash.bashrc profile profile.d localtime timezone os-release protocols services hosts hostname`, plus a generated `resolv.conf`. Two ways: copy (stable, can go stale, for example CA certs) or read-only bind of each allowed path (always current, more mounts). Recommendation: read-only bind, since doos already carries the mount machinery.
Unmeasured: whether this allowlist is enough for `apt`, `curl`, `python3`, `git`; needs a run per tool before fixing the list.
### G5: `--net host` and abstract unix sockets
Fact: abstract `AF_UNIX` sockets belong to the network namespace, so `--net host` exposes host abstract sockets (some X11 and D-Bus setups use them) and host loopback services. This is inherent to sharing the namespace.
Researched alternative, pasta (installed, `pasta 0.0~git20260120`): it attaches to an existing netns by pid (`pasta --config-net <pid>`), gives outbound access, and mirrors host addresses into the namespace. Measured: outbound TCP worked, and an inside connection to `127.0.0.1:22` reached the host (default flags, once). A second run with `--no-splice --no-map-gw --map-host-loopback none` printed `Couldn't get any nameserver address`, and I could not confirm the loopback result from it. Conclusion: pasta is not proven to match slirp4netns' `--disable-host-loopback` guarantee. Slirp stays the default. Next step is a clean test matrix of pasta flags with a listener on a known host port.
Interim: `host` remains an explicit opt-in documented as unisolated.
### G6: kernel attack surface (seccomp, nested user namespaces)
Findings:
- A seccomp-bpf filter installs unprivileged once `no_new_privs` is set (already done). Measured with a raw `prctl(PR_SET_SECCOMP)` program: a denied syscall (`keyctl`) returned `-1 EPERM`. No `libseccomp` headers on this machine, and "fewer dependencies" argues for hand-assembled BPF.
- `/proc/sys` is read-only in the container, so `user.max_user_namespaces=0` cannot be set from inside; seccomp is the lever.
Design: denylist modelled on the Docker default profile, applied after `setupRoot` and before `exec`, and also in the `join()` path. Check `AUDIT_ARCH` first and kill on a foreign ABI (blocks the x32 bypass). Deny with `EPERM`: `kexec_load kexec_file_load bpf perf_event_open userfaultfd keyctl add_key request_key open_by_handle_at process_vm_readv process_vm_writev reboot swapon swapoff init_module finit_module delete_module`. Nested userns: deny `unshare` and `setns` with `CLONE_NEWUSER`, deny `clone` with `CLONE_NEWUSER`, and return `ENOSYS` for `clone3` (flags live in a struct, cannot be inspected; libc falls back to `clone`). Trade-off: breaks nested containers and Chromium-style sandboxes; make it a documented constant, not a switch that hides in code.
Unmeasured: compatibility impact on real workloads.
### Priority
1. G1 (limits): cheapest, measured to work, unlocks G2's freeze.
2. G6 (seccomp): self-contained, no new dependency.
3. G4 (private `/etc`): needs the per-tool allowlist runs first.
4. G2, then G3 (G3 changes the delete path, do it deliberately).
5. G5: needs a test matrix before any change.
## Field trial: apt and common tools (v0.1.2, measured)
Run in a fresh `--net slirp` container on the host above (Ubuntu with apt 3.2.0, dpkg 1.23.7).
| Step | Result |
|---|---|
| curl https, git ls-remote, gcc compile+run, python3 (`ssl`, `sqlite3`), pip, ping 1.1.1.1 | all work |
| `apt-get update` as shipped | fails: `/var/lib/apt/lists/partial is missing` (`/var` is an empty private dir) |
| `apt-get install` as shipped | fails: `/var/lib/dpkg/lock-frontend` missing, no dpkg status database |
| after seeding `/var` (dirs plus copy of host `dpkg/status`, `info`, `alternatives`, 40 MB) | apt starts, but the download method dies: `setegid 65534 failed`, `seteuid 42 failed` (sandbox user `_apt` cannot exist with a single id) |
| with `-o APT::Sandbox::User=root` | `apt-get update` works: 58.9 MB fetched in 3 s |
| `apt-get install cowsay` | download works; unpack fails: `unable to create '/usr/games/cowsay.dpkg-new': Permission denied` |
| after pre-creating the host directory tree in the upper dirs (13164 dirs for `/usr`, done with a shell loop, 38 s; not yet implemented in C++) | unpack works, `/usr/games/cowsay hoi` runs; host `/usr/games/cowsay` still absent (isolation holds) |
| configure step | fails: `chown: changing ownership of '/etc/shells.tmp': Invalid argument` (debianutils postinst copies owner of `/etc/shells`, which shows as `65534`), so dpkg reports 4 packages not fully installed |
Root causes:
1. Host files owned by host root appear as `nobody` (uid 65534). Container root has `CAP_DAC_OVERRIDE` only over inodes whose owner is mapped, so it cannot write into host-root-owned directories of the lower layer, and cannot `chown` to or from an unmapped owner.
2. `/var` has no package database.
3. apt wants a second uid (G3).
Conclusion: the current design (host `/usr`, `/etc`, `/opt` as lower layer) is good for running tools and for isolated experiments, not for package management. Bare tools and compilers work. Honest status: **`apt-get install` does not complete today.**
Researched ways forward, in order of effort:
- Short term, writable skeleton: at create time mirror every directory of the lower dirs into the upper dir with the same mode (directories owned by the user are writable; merged view is unchanged). Directories unreadable to the user (for example `/etc/ssl/private`, mode 700, root-owned) are skipped and stay as they are. Measured effect: writes into `/usr/bin` work, attach time unchanged (41 ms). Unmeasured: C++ implementation speed, and it does not fix the `chown` failure.
- Proper fix, image layer: use an extracted distro rootfs (for example from `podman export` or `skopeo copy` plus unpack, both installed here; `mmdebstrap` and `debootstrap` are not) as the lower dir, with ownership shifted into the subuid range (G3). Then the tree is owned by mapped ids, `/var/lib/dpkg` exists, `chown` works, `_apt` exists, and host `/etc` stops leaking (G4). This also removes the dependence on the host distro. Cost: image import step, disk per image (shared lower, so once), and a layout change, hence a major bump.
- Alternative to evaluate: `fuse-overlayfs` (installed) supports uid mappings between layers. Unmeasured; adds a FUSE process per container.
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Bump VERSION, build, run the e2e suite, commit everything, tag.
# Usage: scripts/release.sh patch|minor|major "commit subject"
# Refuses to run when the build or tests fail. Does not push.
set -euo pipefail
cd "$(dirname "$0")/.."
kind=${1:?usage: release.sh patch|minor|major "subject"}
subject=${2:?usage: release.sh patch|minor|major "subject"}
IFS=. read -r maj min pat < VERSION
case "$kind" in
patch) pat=$((pat+1)) ;;
minor) min=$((min+1)); pat=0 ;;
major) maj=$((maj+1)); min=0; pat=0 ;;
*) echo "kind must be patch, minor or major" >&2; exit 2 ;;
esac
new="$maj.$min.$pat"
if git rev-parse "v$new" >/dev/null 2>&1; then echo "tag v$new already exists" >&2; exit 1; fi
if grep -rl $'\xe2\x80\x94' --exclude-dir=build --exclude-dir=.git . | grep -q .; then
echo "em dash found, remove it first" >&2; exit 1
fi
echo "$new" > VERSION
cmake -S . -B build >/dev/null
cmake --build build
tests/run.sh
git add -A
git commit -m "$subject
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
git tag -a "v$new" -m "doos $new"
echo "released v$new"