diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f2bb63..4dceb3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,11 @@
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
-## 1.3.2 - 2026-10-24
+## 1.5.0 - 2026-10-24
+- `--mount SRC:DST[:ro]` (repeatable, max 8): bind-mounts a host directory into the container, outside the `--size` cap. Lets a container reach storage that is not local, for example a WebDAV share mounted on the host first with `rclone mount` or `davfs2`; doos never speaks a network filesystem protocol itself, it only bind-mounts what the host already mounted. Changeable like `--memory`/`--pids`/`--cpus`: replaces the whole mount list, applies at the next start. Tracked as gap G12 in [RESEARCH](docs/RESEARCH.md); 5 new end-to-end checks (read, write, read-only, missing source rejected, too many rejected).
+- Docs: [COMPARISON](COMPARISON.md) section 1 and the gaps list updated (doos now has host-dir bind mounts); charts and generated numbers re-measured (`make bench && make charts`).
+
+## 1.4.0 - 2026-10-24
- CI: `.gitea/workflows/ci.yml` (Gitea Actions) runs `build`, `valgrind`, `test` and `audit` on every push. `build`/`valgrind` run on any Docker-mode runner; `test`/`audit` need a self-hosted host-mode runner (real namespaces, cgroup v2 delegation, the AppArmor profile), documented with its one-time setup in the new [docs/CI.md](docs/CI.md). Every step wraps its own `timeout`, since Gitea Actions parses but does not enforce `timeout-minutes`/`concurrency` (checked against the 1.25 docs).
- Fixed, from [review2.md](review2.md): `parseSize` (`src/util.cpp`) overflowed silently on an oversized input (`--size 16777217t` was accepted as exactly `1.0T` instead of rejected); now checked before the shift and rejected. Tracked as gap G9 in [RESEARCH](docs/RESEARCH.md); regression case added to `tests/run.sh`.
- Fixed, from [review2.md](review2.md): `newuidmap`, `newgidmap`, `slirp4netns` and `pasta` were each pinned to one hardcoded `/usr/bin/...` path; they now go through `resolveBinary()` (state-dir override, then a fixed list of system directories), the same resolution `curl`/`tar`/`sha256sum`/`gpg` already used. Tracked as gap G10.
diff --git a/COMPARISON.md b/COMPARISON.md
index e667f83..1e6f716 100644
--- a/COMPARISON.md
+++ b/COMPARISON.md
@@ -22,7 +22,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
| Who can use it safely | any user | only trusted users: access to the daemon is root-equivalent [S: [Docker security](https://docs.docker.com/engine/security/), [iximiuz](https://labs.iximiuz.com/tutorials/docker-security-introduction-a859718d)] | any user | any user |
| Image source | tarball, https URL with sha256, small catalog (Alpine, Ubuntu, Debian) [D] | OCI registries, Dockerfile builds [K] | same as Docker | OCI registries, Containerfile builds [K] |
| Build images | no [D] | yes (BuildKit) [K] | yes | yes (Buildah) [K] |
-| Host dirs into the container (`-v`) | **no** [D] | yes [K] | yes | yes |
+| Host dirs into the container (`-v`) | yes, `--mount SRC:DST[:ro]`, not subject to the size cap [D] | yes [K] | yes | yes |
| Published ports | none for `slirp`; automatic for `pasta` [D] | `-p` [K] | `-p` | `-p` [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
| Rootless network backend | slirp4netns or pasta [D] | n/a | RootlessKit based [S: [Podman blog](https://blog.podman.io/2023/07/podman-performance-root-and-rootless/)] | pasta default since 5.0 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
| Resource limits | memory, pids, cpus, written data [D][M] | memory, cpu, pids, more [K] | needs cgroup v2 + systemd delegation [S: [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/)] | needs cgroup v2 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
@@ -30,7 +30,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
| Default capabilities | Docker's default set [D][M] | that set [S] | that set | a smaller set [S: [dev.to comparison](https://dev.to/mechcloud_academy/docker-vs-podman-an-in-depth-comparison-2025-2eia)] |
| Orchestration (Compose, Kubernetes) | none [D] | Compose, Swarm, Kubernetes via runtimes [K] | same | Compose, pods, `kube play`, systemd units [K] |
| Background containers, logs, `stop` | none: a container lives while its first `attach` command lives [D] | yes [K] | yes | yes |
-| Binary size | 552 KiB, 6 shared libraries, 3,445 lines of C++ [M] | CLI 29.9 MiB, plus `dockerd` and `containerd` [M][K] | same | 41.2 MiB [M] |
+| Binary size | 532 KiB, 6 shared libraries, 3,712 lines of C++ [M] | CLI 29.9 MiB, plus `dockerd` and `containerd` [M][K] | same | 41.2 MiB [M] |
| Platforms | Linux only, tested on x86_64 only [D] | Linux, macOS, Windows (via a VM) [K] | Linux | Linux; macOS and Windows via a VM [K] |
| Maturity | version 1.1, one test machine | very mature, huge ecosystem | mature | mature |
@@ -48,24 +48,24 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
## 3. Measurements
-Method: a trivial command (`true`) started 10 times in a row after one warm-up run, average wall time per run, the Alpine image already present for every tool, one machine, one run set on 2026-10-23. Versions: doos 1.1.2, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1. The numbers come from `scripts/bench.sh` (`make bench`); the charts and the tables below are generated from its output (`make charts`), so they cannot drift from each other.
+Method: a trivial command (`true`) started 10 times in a row after one warm-up run, average wall time per run, the Alpine image already present for every tool, one machine, one run set on 2026-10-24. Versions: doos 1.4.0, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1. The numbers come from `scripts/bench.sh` (`make bench`); the charts and the tables below are generated from its output (`make charts`), so they cannot drift from each other.

| Setup | Network | Time per run |
|---|---|---|
-| bubblewrap (host /usr) | none | 8.4 ms |
-| doos Alpine image | none | 13.8 ms |
-| doos host mode | none | 18.7 ms |
-| Podman Alpine | none | 208 ms |
-| Docker Alpine (rootful) | none | 344 ms |
-| bubblewrap (host /usr) | default | 7.4 ms |
-| doos Alpine image | default | 52.1 ms |
-| doos host mode | default | 56.9 ms |
-| doos Alpine, pasta | default | 64.6 ms |
-| Podman Alpine | default | 241 ms |
-| Docker Alpine (rootful) | default | 436 ms |
+| bubblewrap (host /usr) | none | 8.8 ms |
+| doos Alpine image | none | 14.2 ms |
+| doos host mode | none | 20.2 ms |
+| Podman Alpine | none | 283 ms |
+| Docker Alpine (rootful) | none | 463 ms |
+| bubblewrap (host /usr) | default | 7.8 ms |
+| doos Alpine image | default | 58 ms |
+| doos host mode | default | 61.4 ms |
+| doos Alpine, pasta | default | 82.5 ms |
+| Podman Alpine | default | 313 ms |
+| Docker Alpine (rootful) | default | 645 ms |
How to read this honestly:
@@ -82,7 +82,7 @@ How to read this honestly:
|---|---|
| bubblewrap | 79 KiB (80,424 bytes) |
| crun | 472 KiB (483,392 bytes) |
-| doos | 552 KiB (565,680 bytes) |
+| doos | 532 KiB (544,640 bytes) |
| docker CLI | 29.9 MiB (31,374,336 bytes) |
| podman | 41.2 MiB (43,180,896 bytes) |
@@ -105,8 +105,8 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
**Where doos is better**
- No daemon and no root. With Docker's default install, anyone who can talk to the daemon controls the machine [S]. doos has no such trust boundary: it can only do what the invoking user can do, plus what the `newuidmap` helper allows.
-- Tiny and auditable: 552 KiB, 3,445 lines of C++ [M], one process tree, no background service, nothing listening.
-- Instant: 14 to 65 ms against 344 to 436 ms for a rootful Docker run and 208 to 241 ms for Podman [M].
+- Tiny and auditable: 532 KiB, 3,712 lines of C++ [M], one process tree, no background service, nothing listening.
+- Instant: 14 to 82 ms against 463 to 645 ms for a rootful Docker run and 283 to 313 ms for Podman [M].
- Safe defaults a user does not have to remember: Docker's default capability set, a seccomp filter, an allowlist of host `/etc`, a memory and pid cap, a written-data cap [D][M].
- A "host mode" that reuses the host's installed tools (compilers, `apt` state) for throwaway shells with no image download [D].
- A size cap that freezes instead of filling the disk [D][M]. Docker's writable-layer size limit needs the overlay2 driver on XFS with project quotas [S: [Fabian Lee](https://fabianlee.org/2020/01/15/docker-use-overlay2-with-an-xfs-backing-filesystem-to-limit-rootfs-size/)].
@@ -175,7 +175,7 @@ These are the gaps that matter when comparing, each next to where it lives. They
| Gap | Where |
|---|---|
| No OCI registry pull, no layers, no build | `src/image.cpp` (`Catalog`, `ImageStore`) |
-| No host directory bind mounts, no devices, no GPU | `setupRoot` in `src/sandbox.cpp`, option parsing in `src/cli.cpp` |
+| No devices, no GPU passthrough (host directory bind mounts exist: `--mount`) | `setupRoot` in `src/sandbox.cpp`, option parsing in `src/cli.cpp` |
| No background containers, `stop`, `exec` by name, logs | lifetime model in `Sandbox::start` / `Sandbox::watch` (container lives with its first command) |
| No user-defined networks, no `-p` | `src/network.cpp` |
| No Compose or Kubernetes integration | not present |
@@ -186,7 +186,7 @@ These are the gaps that matter when comparing, each next to where it lives. They
## 8. Conclusion
-doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a 552 KiB single binary, it starts in 14 to 65 ms [M], it runs with no daemon and no root, it caps memory, processes, CPU and written data by default, and it verifies what it downloads.
+doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a 532 KiB single binary, it starts in 14 to 82 ms [M], it runs with no daemon and no root, it caps memory, processes, CPU and written data by default, and it verifies what it downloads.
Choose by the job:
diff --git a/README.md b/README.md
index 4647c5f..d0901ea 100644
--- a/README.md
+++ b/README.md
@@ -114,9 +114,12 @@ Notes, all measured:
| `--memory S` | memory cap | 8g | changeable |
| `--pids N` | process/thread cap | 4096 | changeable |
| `--cpus N` | cpu cap, e.g. `0.5`, `2` | all cores | changeable |
+| `--mount SRC:DST[:ro]` | bind-mount host dir `SRC` at `DST` inside, repeatable (max 8) | none | changeable |
Changeable options apply the next time the container starts. Examples: `doos attach --memory 2g --cpus 1 build`, `doos attach --net pasta --image debian-bookworm web`.
+`--mount` gives a container extra storage without counting against `--size`: mount a remote filesystem on the host first (for example a WebDAV share with `rclone mount webdav-remote: /mnt/webdav`), then `doos attach --mount /mnt/webdav:/data NAME` to reach it inside. `SRC` must exist and be a directory when the container starts; it fails loudly otherwise. `--mount SRC:DST:ro` mounts it read-only.
+
## Limits, size cap and hardening
- **Limits** (`--memory`, `--pids`, `--cpus`): cgroup v2, measured here: a 256m container touching 400 MB is killed whole and doos says so; 80 forks against `--pids 32` fail and are reported; `--cpus 0.5` held 4 busy loops to 2.3 CPU-seconds in 4.3 s of wall time.
diff --git a/SPEC.md b/SPEC.md
index f3503b5..5272a2a 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -32,8 +32,9 @@ Container options (accepted by `get_or_create` and `attach`):
| `--memory SIZE` | Memory cap (cgroup `memory.max`, swap 0, whole-container OOM kill). | 8g | yes |
| `--pids N` | Process and thread cap (`pids.max`). | 4096 | yes |
| `--cpus N` | CPU cap, decimals allowed (`0.5`, `2`); `0` = all cores. | 0 | yes |
+| `--mount SRC:DST[:ro]` | Bind-mount host directory `SRC` at `DST` inside the container, repeatable (max 8). Not subject to `--size`: it is host storage, not the container's writable layer. Replaces the whole mount list when given. | none | yes |
-Changeable options apply at the next start of the container. Naming a fixed option on an existing container with a different value is an error, never a silent change.
+Changeable options apply at the next start of the container. Naming a fixed option on an existing container with a different value is an error, never a silent change. `--mount`'s `SRC` must exist and be a directory at the time the container starts (host responsibility, e.g. `rclone mount` a WebDAV share there first); a missing `SRC` fails the start loudly.
## Limits (hard caps)
@@ -59,6 +60,7 @@ Changeable options apply at the next start of the container. Naming a fixed opti
| slirp4netns ready wait | 5 s |
| Lock wait | 5 s |
| Tree recursion depth | 256 |
+| `--mount` entries | 8 |
## State layout
@@ -69,7 +71,7 @@ containers/NAME/{meta,pid,lock,seeded.*,rootfs/,layers/
/{upper,work},privat
images/NAME/{meta,rootfs/}
```
-`meta` keys: `net created version image ids size`.
+`meta` keys: `net created version image ids size memory pids cpus hardening mounts`.
## Container filesystem, host mode (no `--image`)
diff --git a/VERSION b/VERSION
index 88c5fb8..bc80560 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.0
+1.5.0
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index d821583..ff21e15 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -71,7 +71,9 @@ Image mode:
2. Mountpoints (`/proc /dev /tmp /run ...`) created inside the merged tree.
Both modes then: tmpfs `/tmp` and `/run`; `/dev` (tmpfs, bound host nodes, new devpts, `/dev/shm`, symlinks);
-`/proc` with read-only remounts and masks from `spec.hpp`; `chdir(rootfs)`, `pivot_root(".", ".")`, lazy
+`/proc` with read-only remounts and masks from `spec.hpp`; `mountExtra` binds each `Container::mounts()`
+entry (`--mount SRC:DST[:ro]`) at `DST`, creating the mountpoint first (`fsutil::mkdirs`), read-only ones
+remounted the same way `/proc`'s read-only paths are; `chdir(rootfs)`, `pivot_root(".", ".")`, lazy
unmount of the old root.
## Seeding (host mode, once per container)
diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md
index 71807d2..2a5115f 100644
--- a/docs/RESEARCH.md
+++ b/docs/RESEARCH.md
@@ -66,6 +66,7 @@ removed afterwards. Rows marked "unmeasured" have no run behind them.
| G9 | `parseSize` left-shifted without an overflow check: `--size 16777217t` wrapped to exactly `1.0T` and was accepted instead of rejected | check `v > (UINT64_MAX >> shift)` before shifting, reject instead of wrapping | **done in 1.3.2**, reproduced before the fix, re-run after: rejected | `parseSize` in `src/util.cpp` |
| G10 | `newuidmap`/`newgidmap`/`slirp4netns`/`pasta` were pinned to a single hardcoded `/usr/bin/...` path, unlike `curl`/`tar`/`sha256sum`/`gpg` which go through `resolveBinary()`'s state-dir-override-then-fixed-dirs search; a distro installing them elsewhere would fail even though PATH-poisoning was never possible either way | route all four through `resolveBinary()`, same as the other helpers | **done in 1.3.2** | `idmap.cpp`, `network.cpp` |
| G11 | Alpine's `latest-releases.yaml` and Debian's `index.json` (embedded base64 manifest) are read with fixed string/byte-offset search, not a YAML/JSON library; a publisher reformatting either file breaks extraction with a thrown error (fails closed) rather than silently misreading it | none: a dependency to parse two small, publisher-controlled files is a worse trade than failing loudly when their format changes, given the project's own "fewer dependencies" rule | **accepted, not fixed**; this row exists so the trade-off is written down, not rediscovered | `Catalog::resolve` in `src/image.cpp` |
+| G12 | No way to give a container storage that does not count against `--size` (remote/network storage, e.g. a WebDAV share); every writable path was either the host overlay (capped) or a private dir (capped) | `--mount SRC:DST[:ro]`: bind-mount an already-mounted host path into the container, outside the size watchdog's scan set (`Container::usedBytes` only scans `layers/`/`private/`, never `--mount` targets). doos itself never speaks a network filesystem protocol: mount WebDAV/NFS/etc. on the host first (e.g. `rclone mount`), doos only bind-mounts what is already there | **done in 1.5.0** (`--mount`, max 8 per container, changeable, applies at next start); missing `SRC` fails the start loudly | `Container::mounts`, `mountExtra` in `src/sandbox.cpp` |
### G1: memory, cpu and pids limits (cgroup v2)
@@ -271,7 +272,7 @@ delegated with `cpuset cpu io memory pids`). The end-to-end suite passed 83 chec

-The chart above is generated from `docs/charts/bench.json` (`make bench`, then `make charts`); it shows 16.3 CPU-seconds without a limit and 2.3 with `--cpus 0.5`; the startup and size charts live in [COMPARISON](../COMPARISON.md).
+The chart above is generated from `docs/charts/bench.json` (`make bench`, then `make charts`); it shows 15.6 CPU-seconds without a limit and 2.4 with `--cpus 0.5`; the startup and size charts live in [COMPARISON](../COMPARISON.md).
### What changed in behavior (and why it is not a loss)
diff --git a/docs/charts/bench.json b/docs/charts/bench.json
index 704c67a..c276041 100644
--- a/docs/charts/bench.json
+++ b/docs/charts/bench.json
@@ -1,121 +1,17 @@
{
"meta": {
- "date": "2026-10-23",
+ "date": "2026-10-24",
"kernel": "7.0.0-38-generic",
"cpus": 12,
"runs": 10,
- "doos": "1.1.2",
+ "doos": "1.4.0",
"podman": "podman version 5.7.0",
"docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1",
"bubblewrap": "bubblewrap 0.11.1",
- "src_lines": 3445,
+ "src_lines": 3712,
"shared_libs": 6
},
- "startup": [
- {
- "id": "bwrap_none",
- "label": "bubblewrap (host /usr)",
- "group": "none",
- "kind": "other",
- "ms": 8.4
- },
- {
- "id": "bwrap_net",
- "label": "bubblewrap (host /usr)",
- "group": "default",
- "kind": "other",
- "ms": 7.4
- },
- {
- "id": "doos_host_none",
- "label": "doos host mode",
- "group": "none",
- "kind": "doos",
- "ms": 18.7
- },
- {
- "id": "doos_alpine_none",
- "label": "doos Alpine image",
- "group": "none",
- "kind": "doos",
- "ms": 13.8
- },
- {
- "id": "doos_host_slirp",
- "label": "doos host mode",
- "group": "default",
- "kind": "doos",
- "ms": 56.9
- },
- {
- "id": "doos_alpine_slirp",
- "label": "doos Alpine image",
- "group": "default",
- "kind": "doos",
- "ms": 52.1
- },
- {
- "id": "doos_alpine_pasta",
- "label": "doos Alpine, pasta",
- "group": "default",
- "kind": "doos",
- "ms": 64.6
- },
- {
- "id": "podman_none",
- "label": "Podman Alpine",
- "group": "none",
- "kind": "other",
- "ms": 207.8
- },
- {
- "id": "podman_net",
- "label": "Podman Alpine",
- "group": "default",
- "kind": "other",
- "ms": 241.2
- },
- {
- "id": "docker_none",
- "label": "Docker Alpine (rootful)",
- "group": "none",
- "kind": "other",
- "ms": 343.9
- },
- {
- "id": "docker_net",
- "label": "Docker Alpine (rootful)",
- "group": "default",
- "kind": "other",
- "ms": 435.6
- }
- ],
- "sizes": [
- {
- "id": "bwrap",
- "bytes": 80424
- },
- {
- "id": "crun",
- "bytes": 483392
- },
- {
- "id": "doos",
- "bytes": 565680
- },
- {
- "id": "docker",
- "bytes": 31374336
- },
- {
- "id": "podman",
- "bytes": 43180896
- }
- ],
- "cpu": {
- "wall_seconds": 4.2,
- "busy_loops": 4,
- "unlimited": 16.26,
- "half_cpu": 2.29
- }
+ "startup": [{"id":"bwrap_none","label":"bubblewrap (host /usr)","group":"none","kind":"other","ms":8.8},{"id":"bwrap_net","label":"bubblewrap (host /usr)","group":"default","kind":"other","ms":7.8},{"id":"doos_host_none","label":"doos host mode","group":"none","kind":"doos","ms":20.2},{"id":"doos_alpine_none","label":"doos Alpine image","group":"none","kind":"doos","ms":14.2},{"id":"doos_host_slirp","label":"doos host mode","group":"default","kind":"doos","ms":61.4},{"id":"doos_alpine_slirp","label":"doos Alpine image","group":"default","kind":"doos","ms":58.0},{"id":"doos_alpine_pasta","label":"doos Alpine, pasta","group":"default","kind":"doos","ms":82.5},{"id":"podman_none","label":"Podman Alpine","group":"none","kind":"other","ms":282.9},{"id":"podman_net","label":"Podman Alpine","group":"default","kind":"other","ms":313.2},{"id":"docker_none","label":"Docker Alpine (rootful)","group":"none","kind":"other","ms":462.6},{"id":"docker_net","label":"Docker Alpine (rootful)","group":"default","kind":"other","ms":645.1}],
+ "sizes": [{"id":"bwrap","bytes":80424},{"id":"crun","bytes":483392},{"id":"doos","bytes":544640},{"id":"docker","bytes":31374336},{"id":"podman","bytes":43180896}],
+ "cpu": {"wall_seconds": 4.2, "busy_loops": 4, "unlimited": 15.56, "half_cpu": 2.38}
}
diff --git a/docs/charts/cpu.svg b/docs/charts/cpu.svg
index 75d7ff5..bf33a83 100644
--- a/docs/charts/cpu.svg
+++ b/docs/charts/cpu.svg
@@ -1,6 +1,6 @@
diff --git a/docs/charts/size.svg b/docs/charts/size.svg
index 8085341..91ae219 100644
--- a/docs/charts/size.svg
+++ b/docs/charts/size.svg
@@ -1,6 +1,6 @@
diff --git a/src/cli.cpp b/src/cli.cpp
index 68d0887..6d8d1b9 100644
--- a/src/cli.cpp
+++ b/src/cli.cpp
@@ -18,6 +18,30 @@ namespace {
std::uint32_t parseCount(const std::string& text);
std::uint32_t parseCpus(const std::string& text);
+// "SRC:DST" or "SRC:DST:ro"/"SRC:DST:rw" (paths themselves may not contain ':').
+MountSpec parseMountFlag(const std::string& text) {
+ std::vector parts;
+ std::string cur;
+ for (char ch : text) {
+ if (ch == ':') { parts.push_back(cur); cur.clear(); }
+ else cur += ch;
+ }
+ parts.push_back(cur);
+ if (parts.size() < 2 || parts.size() > 3) {
+ throw std::runtime_error("--mount needs SRC:DST or SRC:DST:ro (got '" + text + "')");
+ }
+ MountSpec m;
+ m.src = parts[0];
+ m.dst = parts[1];
+ if (parts.size() == 3) {
+ if (parts[2] != "ro" && parts[2] != "rw") {
+ throw std::runtime_error("--mount's third field must be 'ro' or 'rw' (got '" + parts[2] + "')");
+ }
+ m.readOnly = parts[2] == "ro";
+ }
+ return m;
+}
+
// Leading --options, then the first positional, then everything else untouched.
struct Parsed {
Options opts;
@@ -59,6 +83,13 @@ Parsed parse(const std::vector& rest, Allowed allow, std::size_t wa
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
p.opts.relaxed = (v == "relaxed");
}
+ else if (allow.container && is(a, "--mount")) {
+ if (!p.opts.mounts) p.opts.mounts = std::vector{};
+ if (p.opts.mounts->size() >= spec::kMaxMounts) {
+ throw std::runtime_error("too many --mount flags (max " + std::to_string(spec::kMaxMounts) + ")");
+ }
+ p.opts.mounts->push_back(parseMountFlag(value(i, "--mount")));
+ }
else if (allow.yes && a == "--yes") p.yes = true;
else if (a == "--i-understand") p.iUnderstand = true;
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
@@ -141,7 +172,9 @@ void Cli::usage(std::FILE* out) {
" --memory SIZE memory cap, 64m..1t, default 8g\n"
" --pids N process cap, 16..4194304, default 4096\n"
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\n"
- " size, memory, pids and cpus can be changed on any later call; they apply at the next start.\n"
+ " --mount SRC:DST[:ro] bind-mount host path SRC at DST inside the container, repeatable (max 8)\n"
+ " size, memory, pids, cpus and mount can be changed on any later call; they apply at the next start.\n"
+ " mount replaces the whole mount list; SRC must exist and be a directory when the container starts.\n"
"\n"
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\n"
"attach creates NAME when missing. delete only reports unless --yes is given.\n"
diff --git a/src/container.cpp b/src/container.cpp
index 1d5c28e..a9c995d 100644
--- a/src/container.cpp
+++ b/src/container.cpp
@@ -26,6 +26,44 @@ std::string slug(std::string_view hostDir) {
return s;
}
+void validateMount(const MountSpec& m) {
+ auto bad = [](const std::string& s) {
+ return s.find(':') != std::string::npos || s.find(';') != std::string::npos || s.find('\n') != std::string::npos;
+ };
+ if (m.src.empty() || m.src[0] != '/' || bad(m.src)) {
+ throw std::runtime_error("--mount source must be an absolute path without ':' or ';': '" + m.src + "'");
+ }
+ if (m.dst.empty() || m.dst[0] != '/' || m.dst == "/" || bad(m.dst)) {
+ throw std::runtime_error("--mount destination must be an absolute path, not '/', without ':' or ';': '" + m.dst + "'");
+ }
+}
+
+std::string serializeMounts(const std::vector& mounts) {
+ std::string out;
+ for (const auto& m : mounts) {
+ if (!out.empty()) out += ';';
+ out += m.src + ":" + m.dst + ":" + (m.readOnly ? "ro" : "rw");
+ }
+ return out;
+}
+
+std::vector parseMounts(const std::string& text) {
+ std::vector out;
+ std::istringstream in(text);
+ for (std::string entry; std::getline(in, entry, ';');) {
+ if (entry.empty()) continue;
+ std::size_t c1 = entry.find(':');
+ std::size_t c2 = entry.rfind(':');
+ if (c1 == std::string::npos || c2 == c1) throw std::runtime_error("corrupt mount entry in meta: '" + entry + "'");
+ MountSpec m;
+ m.src = entry.substr(0, c1);
+ m.dst = entry.substr(c1 + 1, c2 - c1 - 1);
+ m.readOnly = entry.substr(c2 + 1) == "ro";
+ out.push_back(std::move(m));
+ }
+ return out;
+}
+
std::map parseKv(const std::string& text) {
std::map kv;
std::istringstream in(text);
@@ -240,7 +278,8 @@ void Container::writeMeta() const {
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
- "\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n");
+ "\nhardening=" + (relaxed_ ? "relaxed" : "default") +
+ "\nmounts=" + serializeMounts(mounts_) + "\n");
}
void Container::setSizeCap(std::uint64_t bytes) {
@@ -252,6 +291,15 @@ void Container::setSizeCap(std::uint64_t bytes) {
writeMeta();
}
+void Container::setMounts(const std::vector& mounts) {
+ if (mounts.size() > spec::kMaxMounts) {
+ throw std::runtime_error("too many mounts (max " + std::to_string(spec::kMaxMounts) + ")");
+ }
+ for (const auto& m : mounts) validateMount(m);
+ mounts_ = mounts;
+ writeMeta();
+}
+
void Container::setLimits(std::optional memory, std::optional pids,
std::optional milliCpus) {
if (memory) {
@@ -287,6 +335,13 @@ void Container::create(const NetMode& net, const std::string& image, IdMode ids,
pids_ = spec::kDefaultPids;
milliCpus_ = 0;
if (o.memory || o.pids || o.milliCpus) setLimits(o.memory, o.pids, o.milliCpus);
+ if (o.mounts) {
+ if (o.mounts->size() > spec::kMaxMounts) {
+ throw std::runtime_error("too many mounts (max " + std::to_string(spec::kMaxMounts) + ")");
+ }
+ for (const auto& m : *o.mounts) validateMount(m);
+ mounts_ = *o.mounts;
+ }
created_ = std::time(nullptr);
fsutil::mkdirs(dir_);
prepareLayout();
@@ -309,6 +364,7 @@ void Container::readMeta() {
pids_ = kv.count("pids") ? static_cast(std::stoul(kv["pids"])) : spec::kDefaultPids;
milliCpus_ = kv.count("cpus") ? static_cast(std::stoul(kv["cpus"])) : 0;
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
+ mounts_ = kv.count("mounts") ? parseMounts(kv["mounts"]) : std::vector{};
}
std::optional Container::runningPid() const {
@@ -399,6 +455,7 @@ std::pair Store::getOrCreate(const std::string& name, const Opt
if (opts.relaxed) fixed("hardening", c.relaxed() ? "relaxed" : "default", *opts.relaxed ? "relaxed" : "default");
if (opts.size && *opts.size != c.sizeCap()) c.setSizeCap(*opts.size);
if (opts.memory || opts.pids || opts.milliCpus) c.setLimits(opts.memory, opts.pids, opts.milliCpus);
+ if (opts.mounts) c.setMounts(*opts.mounts);
};
if (fs::exists(dir / "meta", ec)) {
diff --git a/src/container.hpp b/src/container.hpp
index a8838b7..6f4cd2c 100644
--- a/src/container.hpp
+++ b/src/container.hpp
@@ -15,6 +15,15 @@
namespace doos {
+// One --mount SRC:DST[:ro]: SRC is bind-mounted at DST inside the container, in addition to the
+// usual overlay/image root. Not subject to the --size cap: it is host storage (or whatever the
+// host mounted at SRC, e.g. a WebDAV share via rclone), not the container's own writable layer.
+struct MountSpec {
+ std::string src; // host path, must exist as a directory when the container starts
+ std::string dst; // absolute path inside the container
+ bool readOnly = false;
+};
+
// What the user asked for. Unset fields mean "default on create, no constraint on existing".
struct Options {
std::optional net;
@@ -25,6 +34,7 @@ struct Options {
std::optional pids;
std::optional milliCpus; // 0 = all cores
std::optional relaxed; // --hardening relaxed
+ std::optional> mounts; // --mount, replaces the whole list when given
};
@@ -56,8 +66,11 @@ public:
std::uint32_t pidsCap() const { return pids_; }
std::uint32_t milliCpus() const { return milliCpus_; }
bool relaxed() const { return relaxed_; }
+ const std::vector& mounts() const { return mounts_; }
// Limits take effect the next time the container starts.
void setLimits(std::optional memory, std::optional pids, std::optional milliCpus);
+ // Replaces the whole mount list; takes effect the next time the container starts.
+ void setMounts(const std::vector& mounts);
void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved);
void readMeta(); // meta only, no side effects
@@ -89,6 +102,7 @@ private:
std::uint32_t pids_ = 0;
std::uint32_t milliCpus_ = 0;
bool relaxed_ = false;
+ std::vector mounts_;
};
class Store {
diff --git a/src/sandbox.cpp b/src/sandbox.cpp
index 83bda8a..07edb4a 100644
--- a/src/sandbox.cpp
+++ b/src/sandbox.cpp
@@ -166,6 +166,19 @@ void mountImageRoot(const Container& c, const fs::path& root, const fs::path& im
for (auto d : spec::kEmptyDirs) fsutil::mkdirs(sub(root, d));
}
+// --mount: extra bind mounts beyond the overlay/image root, outside the --size cap.
+void mountExtra(const Container& c, const fs::path& root) {
+ for (const auto& m : c.mounts()) {
+ if (!fs::is_directory(m.src)) {
+ throw std::runtime_error("mount source '" + m.src + "' is not a directory (mount it on the host first)");
+ }
+ std::string target = sub(root, m.dst);
+ fsutil::mkdirs(target);
+ doMount(m.src.c_str(), target, nullptr, MS_BIND | MS_REC);
+ if (m.readOnly) remountReadOnly(target);
+ }
+}
+
void setupRoot(const Container& c, const fs::path& imageRoot) {
fs::path root = c.rootfs();
doMount(nullptr, "/", nullptr, MS_REC | MS_PRIVATE);
@@ -202,6 +215,8 @@ void setupRoot(const Container& c, const fs::path& imageRoot) {
for (auto p : spec::kProcReadOnly) remountReadOnly(sub(root, p));
for (auto p : spec::kProcMasked) doMount((root / "dev/null").c_str(), sub(root, p), nullptr, MS_BIND);
+ mountExtra(c, root);
+
// Switch root, then drop the old one.
if (::chdir(root.c_str()) != 0) throw SysError("chdir newroot");
if (::syscall(SYS_pivot_root, ".", ".") != 0) throw SysError("pivot_root");
diff --git a/src/spec.hpp b/src/spec.hpp
index 55b2a9c..719397f 100644
--- a/src/spec.hpp
+++ b/src/spec.hpp
@@ -12,6 +12,7 @@ constexpr std::size_t kMaxContainers = 64;
constexpr std::size_t kMaxNameLen = 32;
constexpr std::size_t kMaxCommandArgs = 256;
constexpr std::size_t kMaxTreeDepth = 256; // removal recursion cap
+constexpr std::size_t kMaxMounts = 8; // extra --mount bind mounts per container
constexpr unsigned long kMaxOpenFiles = 4096; // RLIMIT_NOFILE ceiling inside
constexpr int kNetReadyTimeoutMs = 5000; // slirp4netns must be ready by then
constexpr int kLockTimeoutMs = 5000; // per-container lock wait
diff --git a/tests/run.sh b/tests/run.sh
index 7942662..1739084 100755
--- a/tests/run.sh
+++ b/tests/run.sh
@@ -72,6 +72,21 @@ check "ids fixed at creation" bash -c "! '$DOOS' get_or_create --ids subui
check "bad size rejected" bash -c "! '$DOOS' get_or_create --size 1x z1"
check "size below minimum rejected" bash -c "! '$DOOS' get_or_create --size 1m z2"
check "size overflow rejected, not silently wrapped" bash -c "! '$DOOS' get_or_create --size 16777217t z3"
+
+# --mount: extra bind mounts
+mntd=$(mktemp -d); echo host-data > "$mntd/f"
+"$DOOS" get_or_create --mount "$mntd:/data" m >/dev/null
+[ "$("$DOOS" attach m -- cat /data/f)" = "host-data" ] && ok "mount: readable inside" || bad "mount: readable inside"
+"$DOOS" attach m -- sh -c 'echo from-container > /data/g'
+[ "$(cat "$mntd/g" 2>/dev/null)" = "from-container" ] && ok "mount: writes land on host" || bad "mount: writes land on host"
+"$DOOS" delete --yes m >/dev/null
+"$DOOS" get_or_create --mount "$mntd:/data:ro" mro >/dev/null
+"$DOOS" attach mro -- sh -c 'echo x > /data/should-fail' 2>/dev/null && bad "mount: ro rejects writes" || ok "mount: ro rejects writes"
+"$DOOS" delete --yes mro >/dev/null
+check "mount: missing source rejected" bash -c "'$DOOS' get_or_create --mount /no/such/dir:/data mbad >/dev/null && ! '$DOOS' attach mbad -- true"
+"$DOOS" delete --yes mbad >/dev/null 2>&1
+check "mount: too many rejected" bash -c "! '$DOOS' get_or_create $(for i in 1 2 3 4 5 6 7 8 9; do printf -- '--mount %s:/m%s ' "$mntd" "$i"; done) mtoomany"
+rm -rf "$mntd"
"$DOOS" attach s -- sh -c 'apt-get update >/dev/null 2>&1; apt-get install -y --no-install-recommends cowsay >/dev/null 2>&1; dpkg --audit && /usr/games/cowsay ok >/dev/null' && ok "host mode: apt install completes" || bad "host mode: apt install completes"
[ ! -e /usr/games/cowsay ] && ok "host untouched by apt install" || bad "host untouched by apt install"
"$DOOS" delete --yes s >/dev/null; "$DOOS" delete --yes s1 >/dev/null