4 Commits
Author SHA1 Message Date
retoorandClaude Sonnet 5 d1541b77e6 Add --mount/--davmount WebDAV auto-mount via a managed, verified rclone
ci / test (push) Waiting to run
ci / audit (push) Waiting to run
ci / build (push) Successful in 1m25s
ci / valgrind (push) Successful in 2m37s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-25 08:05:02 +01:00
retoorandClaude Sonnet 5 7adf43dd03 Add --mount for host-dir bind mounts (e.g. a WebDAV share via rclone)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-24 07:35:30 +02:00
retoorandClaude Sonnet 5 4c754b20da Add Gitea CI, fix size overflow and helper-path inconsistency from review2.md
ci / build (push) Successful in 54s
ci / valgrind (push) Successful in 1m42s
ci / test (push) Canceled after 0s
ci / audit (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-24 01:20:18 +02:00
retoorandClaude Sonnet 5 ded4e7f1a1 Mark gap G8 fixed in docs, matching 1.3.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-24 00:51:00 +02:00
32 changed files with 1608 additions and 324 deletions
+69
View File
@@ -0,0 +1,69 @@
# Gitea Actions. See docs/CI.md for what each job needs, the one-time runner setup, and the
# known gaps between Gitea Actions and GitHub Actions this file was written around (notably:
# timeout-minutes and concurrency are parsed but not enforced by Gitea as of the 1.25 docs, so
# the real cap on every step is the `timeout <seconds>` wrapped around it, not the YAML key).
name: ci
on:
push:
branches: ["**"]
pull_request:
jobs:
# Needs only a C++20 toolchain and CMake: runs on any Docker-mode act_runner.
build:
runs-on: ubuntu-latest
timeout-minutes: 10 # label only on Gitea (see docs/CI.md); the real cap is the `timeout` below
steps:
- uses: actions/checkout@v4
- name: install build dependencies
run: timeout 180 sh -c 'apt-get update && apt-get install -y --no-install-recommends build-essential cmake'
- name: build (-Werror)
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
- name: docs cross-link check
run: timeout 60 scripts/check-docs.sh
# No special runner privilege needed: scripts/valgrind.sh only fails on what valgrind itself
# reports, never on doos's own exit code, so it is still a real check even when this container's
# own policy blocks the user namespace doos tries to create (see docs/CI.md).
valgrind:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: install build and valgrind dependencies
run: timeout 180 sh -c 'apt-get update && apt-get install -y --no-install-recommends build-essential cmake valgrind'
- name: build
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
- name: memcheck against the fixed invocation set
run: timeout 300 scripts/valgrind.sh "$(pwd)/build/doos"
- name: keep the per-step logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: valgrind-reports
path: build/valgrind-reports/
# Real overlayfs, real user/mount/pid namespaces, real cgroup v2 limits, real downloads: needs
# the self-hosted host-mode runner set up per docs/CI.md (label "doos-host"), not a container.
test:
runs-on: [self-hosted, doos-host]
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: build
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
- name: end-to-end suite
run: timeout 600 tests/run.sh "$(pwd)/build/doos"
# Named CVE regression set against the binary this run just built. Same runner requirement as
# `test`, for the same reason (several checks attach a real container).
audit:
runs-on: [self-hosted, doos-host]
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: build
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
- name: CVE regression checks
run: timeout 300 make -C audit -j8 test DOOS="$(pwd)/build/doos"
+19 -6
View File
@@ -1,6 +1,6 @@
# AGENTS.md # AGENTS.md
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) **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)
Rules for any AI agent (or human) changing this repository. The user's global rules 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 also apply; this file adds the project-specific ones. Keep this file current: it is
@@ -54,7 +54,9 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea
| Image catalog, sources, verification, pinned keys | `SPEC.md` Images, `README.md`, `docs/RESEARCH.md` | | Image catalog, sources, verification, pinned keys | `SPEC.md` Images, `README.md`, `docs/RESEARCH.md` |
| Seccomp list, capability set, `/etc` allowlist | `SPEC.md` Hardening and filesystem, `README.md`, `docs/RESEARCH.md` | | Seccomp list, capability set, `/etc` allowlist | `SPEC.md` Hardening and filesystem, `README.md`, `docs/RESEARCH.md` |
| Limits, cgroup layout | `SPEC.md` Resource limits, `docs/ARCHITECTURE.md` | | Limits, cgroup layout | `SPEC.md` Resource limits, `docs/ARCHITECTURE.md` |
| Anything above, in every release | `README.md` version line and `CHANGELOG.md` entry | | Anything above, in every release | `CHANGELOG.md` entry (`README.md` points at `VERSION`, nothing to edit there) |
| The CI workflow or what a job needs | `.gitea/workflows/ci.yml`, `docs/CI.md` |
| The davmount flow, pinned rclone version/key, or what `--mount`/`--davmount` accepts | `SPEC.md` (CLI options, davmount section, limits), `docs/ARCHITECTURE.md` davmount flow, `docs/RESEARCH.md` G13 |
Docs describe what the system does today. No roadmap text. A gap is listed next to 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. what would close it. A number in the docs needs a run behind it, else say it is unmeasured.
@@ -80,9 +82,18 @@ the script.
`tests/run.sh [path/to/doos]` runs the real path: real overlayfs, namespaces, `tests/run.sh [path/to/doos]` runs the real path: real overlayfs, namespaces,
slirp4netns, filesystem, real downloads of the Alpine and Ubuntu images, real apt/apk installs, the size slirp4netns, filesystem, real downloads of the Alpine and Ubuntu images, real apt/apk installs, the size
freeze (about 4 minutes in total, needs network, `pasta`, `gpg` and cgroup v2 delegation). Add a case for every behavior change. A green run is required freeze, a real WebDAV round trip through a disposable local `rclone serve webdav` fixture (about 4-5
before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be minutes in total, needs network, `pasta`, `gpg`, `unzip` and cgroup v2 delegation). Add a case for every
installed once for the binary being tested (`doos apparmor`; needs sudo, ask the user, do not do it yourself). behavior change. A green run is required before release. On Ubuntu 24.04+ the AppArmor profile in
`apparmor/doos` must be installed once for the binary being tested (`doos apparmor`; needs sudo, ask the
user, do not do it yourself).
## CI
`.gitea/workflows/ci.yml` runs `build`, `valgrind`, `test` and `audit` on every push (Gitea Actions).
`test` and `audit` need the self-hosted host-mode runner described in [docs/CI.md](docs/CI.md) (real
namespaces, cgroup v2 delegation, the AppArmor profile); the workflow itself never runs `sudo`, same
rule as everywhere else. See that file before changing the workflow or what a job needs.
## Make targets ## Make targets
@@ -97,4 +108,6 @@ The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/`,
- State dir: `$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`. - 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. Tests use a temp `DOOS_HOME`; never point tests at the user's real store.
- Build: `cmake -S . -B build && cmake --build build`. - Build: `cmake -S . -B build && cmake --build build`.
- Requirements: Linux >= 5.11, `slirp4netns` for the default network mode. - Requirements: Linux >= 5.11, `slirp4netns` for the default network mode. `unzip` and `fusermount3`
(or `fusermount`) are needed only for `--mount`/`--davmount` against a `dav://`/`davs://`/`http(s)://`
source; doos installs its own `rclone` under `$DOOS_HOME/tools/`, never the system's.
+25 -1
View File
@@ -1,6 +1,30 @@
# Changelog # Changelog
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) **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.6.0 - 2026-10-25
- `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source: auto-mounts WebDAV. doos downloads, GPG-verifies against a pinned key and installs `rclone` once under `$DOOS_HOME/tools/` (never the system, never `PATH`, same trust model as the catalog images), then drives it on the host with an on-the-fly connection string (no config file, password passed through `rclone obscure`, never on the mount's command line) before the container's namespaces exist. The mount is deduplicated by target (url + credentials + vendor), reused across containers and attaches, and runs detached so it outlives the `attach` that created it. `--dav-user`, `--dav-pass`, `--dav-vendor` and `--dav-insecure` configure a target without putting credentials in the URL. Tracked as gap G13 in [RESEARCH](docs/RESEARCH.md); measured there against a local `rclone serve webdav` fixture (install, reuse at ~40 ms, write-back durability, and each failure mode: wrong credentials, unreachable server, non-WebDAV endpoint).
- `doos davmount list` / `doos davmount umount [--yes] ID`: inspect or tear down a managed WebDAV mount; dry-run by default like `delete`/`image delete`.
- Fix (found while building the above): the classic `--mount SRC:DST[:ro]` could not take credentials with `:`/`;` in them because mounts were serialized as one delimited line; container `meta` now stores mounts as indexed keys (one field per line), with the old single-line format still read for containers created under 1.5.0.
- Refactor: `curl`/gpg download-and-verify helpers moved from `image.cpp` into `util.{hpp,cpp}` (`fetchText`, `fetchFile`, `TempDir`, `isHex64`, `verifyDetachedSignature`, `verifyClearsignedSignature`) so `davmount.cpp` reuses the exact same, already-tested verification code instead of a second copy; no behavior change (full image signature test suite re-passes).
- New dependency, only for davmount: `unzip` (to unpack rclone's release archive) and `fusermount3`/`fusermount` (to unmount it); documented in README/AGENTS.
- Tests: 10 new end-to-end checks against a real local WebDAV server (read, write durability, idempotent reuse, `--davmount` with `--dav-user`/`--dav-pass`, read-only, wrong-password classification, unknown vendor rejected, `davmount list`/`umount`).
- Docs: [COMPARISON](COMPARISON.md) section 4's "Where Docker is better" no longer lists bind mounts as a doos gap (stale since 1.5.0); charts and generated numbers re-measured.
## 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.
- Docs: the `setns`-before-hardening ordering invariant in `Sandbox::join` and `Sandbox::start` (seccomp denies `setns`, and a filter is inherited for life) is now a comment at both call sites, not only in reviewer heads. The hand-rolled Alpine/Debian catalog parsers are logged as an accepted trade-off, gap G11, rather than left undocumented.
- Added `LICENSE` (MIT).
- Docs: README's two overlapping, partly stale intro blocks (a leftover `currently 1.1.3` version line, a narrower duplicate usage example) collapsed into one accurate one.
## 1.3.1 - 2026-10-24
- Docs: gap G8 (`fsutil::mkdirs` following a planted symlink, [RESEARCH](docs/RESEARCH.md)) marked fixed, re-measured with `make -C audit/CVE-2021-30465 test` (PASS); removed from [COMPARISON](COMPARISON.md)'s gaps list. The fix itself shipped in 1.3.0 as part of the security-review hardening; this corrects the docs to match.
## 1.3.0 - 2026-10-24 ## 1.3.0 - 2026-10-24
- Security review applied ([review.md](review.md)): `slirp4netns`, `pasta`, `newuidmap` and `newgidmap` are never searched on `PATH`; they exec via absolute paths with availability checked up front, and the helpers run with a scrubbed `PATH`. - Security review applied ([review.md](review.md)): `slirp4netns`, `pasta`, `newuidmap` and `newgidmap` are never searched on `PATH`; they exec via absolute paths with availability checked up front, and the helpers run with a scrubbed `PATH`.
+1 -1
View File
@@ -9,7 +9,7 @@ if(NOT CMAKE_BUILD_TYPE)
endif() endif()
add_executable(doos add_executable(doos
src/main.cpp src/hardening.cpp src/idmap.cpp src/image.cpp src/cgroup.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp) src/main.cpp src/hardening.cpp src/idmap.cpp src/image.cpp src/cgroup.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp src/davmount.cpp)
target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic -Werror -D_FORTIFY_SOURCE=3 -fstack-protector-strong -O2) target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic -Werror -D_FORTIFY_SOURCE=3 -fstack-protector-strong -O2)
target_link_options(doos PRIVATE -Wl,-z,relro -Wl,-z,now) target_link_options(doos PRIVATE -Wl,-z,relro -Wl,-z,now)
target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}") target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}")
+21 -22
View File
@@ -1,6 +1,6 @@
# doos compared with Docker, Podman and others # doos compared with Docker, Podman and others
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) **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)
This page puts doos next to the tools people usually reach for instead. It separates three kinds of claims, and every row says which one it is: This page puts doos next to the tools people usually reach for instead. It separates three kinds of claims, and every row says which one it is:
@@ -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 | | 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] | | 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] | | 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; a `dav(s)://`/`http(s)://` source auto-mounts WebDAV via a managed `rclone` [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)] | | 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)] | | 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)] | | 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)] | | 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] | | 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 | | Background containers, logs, `stop` | none: a container lives while its first `attach` command lives [D] | yes [K] | yes | yes |
| Binary size | <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M] | CLI <!--bench:docker_size-->29.9 MiB<!--/bench-->, plus `dockerd` and `containerd` [M][K] | same | <!--bench:podman_size-->41.2 MiB<!--/bench--> [M] | | Binary size | <!--bench:doos_size-->688 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->4,413<!--/bench--> lines of C++ [M] | CLI <!--bench:docker_size-->29.9 MiB<!--/bench-->, plus `dockerd` and `containerd` [M][K] | same | <!--bench:podman_size-->41.2 MiB<!--/bench--> [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] | | 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 | | 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 ## 3. Measurements
Method: a trivial command (`true`) started <!--bench:runs-->10<!--/bench--> 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 <!--bench:bench_date-->2026-10-23<!--/bench-->. Versions: <!--bench:versions-->doos 1.1.2, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1<!--/bench-->. 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 <!--bench:runs-->10<!--/bench--> 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 <!--bench:bench_date-->2026-10-25<!--/bench-->. Versions: <!--bench:versions-->doos 1.5.0, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1<!--/bench-->. 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.
![Horizontal bar chart of the time to start a trivial command for doos, bubblewrap, Podman and Docker, with and without network. doos is highlighted. Exact values are in the table below.](docs/charts/startup.svg) ![Horizontal bar chart of the time to start a trivial command for doos, bubblewrap, Podman and Docker, with and without network. doos is highlighted. Exact values are in the table below.](docs/charts/startup.svg)
<!-- bench:startup:begin --> <!-- bench:startup:begin -->
| Setup | Network | Time per run | | Setup | Network | Time per run |
|---|---|---| |---|---|---|
| bubblewrap (host /usr) | none | 8.4 ms | | bubblewrap (host /usr) | none | 9.2 ms |
| doos Alpine image | none | 13.8 ms | | doos Alpine image | none | 14.7 ms |
| doos host mode | none | 18.7 ms | | doos host mode | none | 16.2 ms |
| Podman Alpine | none | 208 ms | | Podman Alpine | none | 216 ms |
| Docker Alpine (rootful) | none | 344 ms | | Docker Alpine (rootful) | none | 346 ms |
| bubblewrap (host /usr) | default | 7.4 ms | | bubblewrap (host /usr) | default | 8 ms |
| doos Alpine image | default | 52.1 ms | | doos Alpine image | default | 56.2 ms |
| doos host mode | default | 56.9 ms | | doos host mode | default | 60 ms |
| doos Alpine, pasta | default | 64.6 ms | | doos Alpine, pasta | default | 66 ms |
| Podman Alpine | default | 241 ms | | Podman Alpine | default | 233 ms |
| Docker Alpine (rootful) | default | 436 ms | | Docker Alpine (rootful) | default | 449 ms |
<!-- bench:startup:end --> <!-- bench:startup:end -->
How to read this honestly: How to read this honestly:
@@ -82,7 +82,7 @@ How to read this honestly:
|---|---| |---|---|
| bubblewrap | 79 KiB (80,424 bytes) | | bubblewrap | 79 KiB (80,424 bytes) |
| crun | 472 KiB (483,392 bytes) | | crun | 472 KiB (483,392 bytes) |
| doos | 552 KiB (565,680 bytes) | | doos | 688 KiB (704,512 bytes) |
| docker CLI | 29.9 MiB (31,374,336 bytes) | | docker CLI | 29.9 MiB (31,374,336 bytes) |
| podman | 41.2 MiB (43,180,896 bytes) | | podman | 41.2 MiB (43,180,896 bytes) |
<!-- bench:sizes:end --> <!-- bench:sizes:end -->
@@ -105,8 +105,8 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
**Where doos is better** **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. - 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: <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening. - Tiny and auditable: <!--bench:doos_size-->688 KiB<!--/bench-->, <!--bench:src_lines-->4,413<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening.
- Instant: <!--bench:doos_range-->14 to 65 ms<!--/bench--> against <!--bench:docker_range-->344 to 436 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->208 to 241 ms<!--/bench--> for Podman [M]. - Instant: <!--bench:doos_range-->15 to 66 ms<!--/bench--> against <!--bench:docker_range-->346 to 449 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->216 to 233 ms<!--/bench--> 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]. - 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 "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/)]. - 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/)].
@@ -114,7 +114,7 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
**Where Docker is better** **Where Docker is better**
- Everything around images: registries, `docker build`, layer caching, multi-arch, signing and scanning ecosystems [K]. doos cannot pull an arbitrary `nginx:1.27` from a registry. - Everything around images: registries, `docker build`, layer caching, multi-arch, signing and scanning ecosystems [K]. doos cannot pull an arbitrary `nginx:1.27` from a registry.
- Host directories and devices into the container (`-v`, `--device`, GPUs). doos has no bind-mount option [D]. - Devices and GPUs (`--device`, `--gpus`); doos has neither [D]. Host directories do bind-mount (`--mount SRC:DST[:ro]`), and so does remote storage: `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source auto-installs and drives `rclone` to mount a WebDAV share, something Docker itself has no built-in equivalent for either [D].
- Long-running services: background containers, restart policies, healthchecks, logs, `exec`, `stop` [K]. In doos a container exists only while its first `attach` command runs [D]. - Long-running services: background containers, restart policies, healthchecks, logs, `exec`, `stop` [K]. In doos a container exists only while its first `attach` command runs [D].
- Networking: user-defined networks, DNS between containers, published ports on any interface [K]. - Networking: user-defined networks, DNS between containers, published ports on any interface [K].
- Compose, Swarm and the Kubernetes ecosystem [K]. - Compose, Swarm and the Kubernetes ecosystem [K].
@@ -175,7 +175,7 @@ These are the gaps that matter when comparing, each next to where it lives. They
| Gap | Where | | Gap | Where |
|---|---| |---|---|
| No OCI registry pull, no layers, no build | `src/image.cpp` (`Catalog`, `ImageStore`) | | 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 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 user-defined networks, no `-p` | `src/network.cpp` |
| No Compose or Kubernetes integration | not present | | No Compose or Kubernetes integration | not present |
@@ -183,11 +183,10 @@ These are the gaps that matter when comparing, each next to where it lives. They
| Linux x86_64 tested only | `src/hardening.cpp` guards aarch64, never run there | | Linux x86_64 tested only | `src/hardening.cpp` guards aarch64, never run there |
| seccomp is a denylist | `deniedSyscalls` in `src/hardening.cpp` | | seccomp is a denylist | `deniedSyscalls` in `src/hardening.cpp` |
| Test coverage: one machine | `tests/run.sh` | | Test coverage: one machine | `tests/run.sh` |
| `fsutil::mkdirs` follows a planted symlink at a container's path, so a symlink at `$DOOS_HOME/containers/<name>` redirects where that container's state is written (low severity on a single-user `$DOOS_HOME`; found by [`audit/CVE-2021-30465`](audit/CVE-2021-30465/README.md)) | `fsutil::mkdirs` in `src/util.cpp` |
## 8. Conclusion ## 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 <!--bench:doos_size-->552 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->14 to 65 ms<!--/bench--> [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 <!--bench:doos_size-->688 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->15 to 66 ms<!--/bench--> [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: Choose by the job:
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 retoor <retoor@molodetz.nl>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+28 -13
View File
@@ -1,6 +1,6 @@
# doos # doos
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) **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)
User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns or pasta networking, optional images (Alpine, Ubuntu, Debian, or your own rootfs), memory/cpu/pid/size limits, default hardening (capabilities, seccomp). No root at runtime. User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns or pasta networking, optional images (Alpine, Ubuntu, Debian, or your own rootfs), memory/cpu/pid/size limits, default hardening (capabilities, seccomp). No root at runtime.
@@ -10,23 +10,15 @@ doos get_or_create [OPTIONS] NAME
doos attach [OPTIONS] NAME [-- CMD...] doos attach [OPTIONS] NAME [-- CMD...]
doos delete [--yes] NAME doos delete [--yes] NAME
doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME
doos davmount list | umount [--yes] ID
doos apparmor # profile text for this binary (Ubuntu 24.04+) doos apparmor # profile text for this binary (Ubuntu 24.04+)
``` ```
Version: see `VERSION` (currently 1.1.3). Release history: `CHANGELOG.md`. Version: see `VERSION`. Release history: [CHANGELOG.md](CHANGELOG.md). License: [MIT](LICENSE).
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.
```
doos list
doos get_or_create [--net slirp|host|none] NAME
doos attach [--net slirp|host|none] NAME [-- CMD...]
doos delete [--yes] NAME
```
Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`. Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`.
Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `pasta` (only for `--net pasta`), `gnupg` (signature checks; optional), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images). On Ubuntu 24.04+ (`kernel.apparmor_restrict_unprivileged_userns=1`) AppArmor must allow user namespaces for the Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `pasta` (only for `--net pasta`), `gnupg` (signature checks; optional), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images), `unzip` and `fusermount3` (or `fusermount`; only for `--mount`/`--davmount` against a `dav://`/`davs://`/`http(s)://` source). On Ubuntu 24.04+ (`kernel.apparmor_restrict_unprivileged_userns=1`) AppArmor must allow user namespaces for the
binary. Generate a profile for the exact path doos is installed at, once: binary. Generate a profile for the exact path doos is installed at, once:
``` ```
@@ -123,9 +115,26 @@ Notes, all measured:
| `--memory S` | memory cap | 8g | changeable | | `--memory S` | memory cap | 8g | changeable |
| `--pids N` | process/thread cap | 4096 | changeable | | `--pids N` | process/thread cap | 4096 | changeable |
| `--cpus N` | cpu cap, e.g. `0.5`, `2` | all cores | 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 |
| `--mount URL DST[:ro]` / `--davmount URL DST[:ro]` | auto-mount a WebDAV `URL` (`dav://`, `davs://`, `http://`, `https://`) at `DST`, see below | none | changeable |
| `--dav-user USER` / `--dav-pass PASS` | credentials for the `--mount`/`--davmount` entry just given | none | changeable |
| `--dav-vendor NAME` | `other`\|`nextcloud`\|`owncloud`\|`sharepoint`\|`sharepoint-ntlm` for that entry | other | changeable |
| `--dav-insecure` | skip TLS certificate verification for that entry | off | 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`. 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`. For a plain host directory: `doos attach --mount /some/host/dir:/data NAME` (`SRC` must exist and be a directory when the container starts; add `:ro` for read-only).
For remote storage, give a WebDAV URL instead of a host path and doos does the rest: it downloads, verifies and installs `rclone` once into its own managed directory under `$DOOS_HOME` (never the system, never your `PATH`), then drives it to mount the share on the host before the container starts:
```
doos attach --mount davs://user:pass@cloud.example.com/remote.php/webdav /data NAME
# or, keeping the password out of the URL and shell history:
doos attach --davmount https://cloud.example.com/remote.php/webdav /data --dav-user user --dav-pass pass --dav-vendor nextcloud NAME
```
`dav://` and `davs://` are shorthand for plain-http and https WebDAV; a bare `http://`/`https://` URL works the same way. The mount is deduplicated by target (URL + credentials + vendor) and reused across containers and attaches instead of remounting every time; it lives on the host, outside any container, and keeps running after the container exits (writes go through a short write-back cache, about a second, before they reach the server). See `doos davmount list` and `doos davmount umount [--yes] ID` to inspect or tear one down, and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for how it is verified and supervised.
## Limits, size cap and hardening ## 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. - **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.
@@ -159,6 +168,11 @@ Changeable options apply the next time the container starts. Examples: `doos att
| `state path must not contain ',' or ':'` | Set `DOOS_HOME` to a plain path. | | `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 'x' is running` on delete | Exit the attached command first. |
| `container limit reached (64)` | Delete one first. | | `container limit reached (64)` | Delete one first. |
| `davmount failed for URL: authentication failed` | Wrong `--dav-user`/`--dav-pass` (or credentials embedded in the URL); fix and retry. |
| `davmount failed for URL: cannot reach the server` | Check the URL, the server, and network/DNS from the host. |
| `davmount failed for URL: TLS certificate error` | Self-signed server: pass `--dav-insecure` if you trust it. |
| `davmount failed for URL: the server does not look like a WebDAV endpoint` | Wrong path or `--dav-vendor`; check the URL against the server's WebDAV docs. |
| `davmount needs 'unzip' installed` | Install `unzip` (only needed the first time rclone is installed). |
## Safety summary ## Safety summary
@@ -176,7 +190,7 @@ Details: [COMPARISON](COMPARISON.md) section 6.1.
## Development ## Development
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make audit` (named CVE regression checks, see [audit/README.md](audit/README.md)), `make valgrind` (memcheck against a fixed set of invocations, see [valgrind/README.md](valgrind/README.md)), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`. `make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make audit` (named CVE regression checks, see [audit/README.md](audit/README.md)), `make valgrind` (memcheck against a fixed set of invocations, see [valgrind/README.md](valgrind/README.md)), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`. CI (`.gitea/workflows/ci.yml`) runs `build`, `valgrind`, `test` and `audit` on every push; see [docs/CI.md](docs/CI.md) for what each needs and the one-time runner setup.
## Project docs ## Project docs
@@ -190,5 +204,6 @@ Details: [COMPARISON](COMPARISON.md) section 6.1.
| [`CHANGELOG.md`](CHANGELOG.md) | Release history | | [`CHANGELOG.md`](CHANGELOG.md) | Release history |
| [`audit/README.md`](audit/README.md) | Named CVE regression checks run against `build/doos` (`make audit`) | | [`audit/README.md`](audit/README.md) | Named CVE regression checks run against `build/doos` (`make audit`) |
| [`valgrind/README.md`](valgrind/README.md) | Memcheck against a fixed set of invocations (`make valgrind`) | | [`valgrind/README.md`](valgrind/README.md) | Memcheck against a fixed set of invocations (`make valgrind`) |
| [`docs/CI.md`](docs/CI.md) | Gitea Actions: what runs where, runner setup |
Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first. Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first.
+31 -3
View File
@@ -1,6 +1,6 @@
# doos specification # doos specification
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) **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)
Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together. How the code implements it: [ARCHITECTURE](docs/ARCHITECTURE.md). Measurements and remaining limits: [RESEARCH](docs/RESEARCH.md). How it differs from other tools: [COMPARISON](COMPARISON.md). Usage: [README](README.md). Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together. How the code implements it: [ARCHITECTURE](docs/ARCHITECTURE.md). Measurements and remaining limits: [RESEARCH](docs/RESEARCH.md). How it differs from other tools: [COMPARISON](COMPARISON.md). Usage: [README](README.md).
@@ -16,6 +16,8 @@ Authoritative description of behavior and limits. Code constants live in `src/sp
| `doos image add [--sha256 HEX] SOURCE [NAME]` | Installs an image. See Images. | | `doos image add [--sha256 HEX] SOURCE [NAME]` | Installs an image. See Images. |
| `doos apparmor` | Prints an AppArmor profile (named after the binary's path) that lets this binary use user namespaces. A start that fails with EPERM/EACCES while `apparmor_restrict_unprivileged_userns=1` prints the command to install it. | | `doos apparmor` | Prints an AppArmor profile (named after the binary's path) that lets this binary use user namespaces. A start that fails with EPERM/EACCES while `apparmor_restrict_unprivileged_userns=1` prints the command to install it. |
| `doos image delete [--yes] NAME` | Dry run unless `--yes`. Refuses an image used by any container, even with `--yes`. | | `doos image delete [--yes] NAME` | Dry run unless `--yes`. Refuses an image used by any container, even with `--yes`. |
| `doos davmount list` | Table of davmount targets this host currently manages: ID, STATE (mounted/stale), VENDOR, URL. |
| `doos davmount umount [--yes] ID` | Dry run unless `--yes`. Unmounts and removes that managed target; stops storage for any container still using it. |
`get-or-create` is accepted as an alias. `get-or-create` is accepted as an alias.
@@ -32,8 +34,23 @@ Container options (accepted by `get_or_create` and `attach`):
| `--memory SIZE` | Memory cap (cgroup `memory.max`, swap 0, whole-container OOM kill). | 8g | yes | | `--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 | | `--pids N` | Process and thread cap (`pids.max`). | 4096 | yes |
| `--cpus N` | CPU cap, decimals allowed (`0.5`, `2`); `0` = all cores. | 0 | 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 |
| `--mount URL DST[:ro]` / `--davmount URL DST[:ro]` | `URL` starting `dav://`, `davs://`, `http://` or `https://`: davmount, see below. Same list and cap as `--mount`. | none | yes |
| `--dav-user USER`, `--dav-pass PASS` | Credentials for the davmount entry just given (overrides any `user[:pass]@` in the URL). | none | yes |
| `--dav-vendor NAME` | `other`, `nextcloud`, `owncloud`, `sharepoint` or `sharepoint-ntlm` for that entry. | other | yes |
| `--dav-insecure` | Skip TLS certificate verification for that entry. | off | 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. A plain `--mount`'s `SRC` must exist and be a directory at the time the container starts; a missing `SRC` fails the start loudly. A davmount's URL is resolved and mounted automatically, see below.
## davmount
`--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source turns into a managed WebDAV mount instead of a plain bind: doos downloads, verifies and installs a pinned `rclone` build under `$DOOS_HOME/tools/rclone-VERSION/rclone` (never the system, never the caller's `PATH`; same trust model as the catalog images: a fixed version, fetched over https, checked against a GPG-signed `SHA256SUMS` and a key pinned by fingerprint), then drives it to mount the target on the host, before the container's namespaces are created, in `Sandbox::start`.
- **Identity and reuse**: a target is identified by URL + user + pass + vendor + the insecure flag (not by destination), hashed into a 16-hex-char id under `$DOOS_HOME/davmounts/<id>/{mnt,log,pid,meta,lock}`. The same target reused by another `--mount`/`--davmount` entry, in the same or a different container, is mounted once and reused; a dead or unreachable mount is detected (process liveness plus an active directory listing, since a FUSE mount answers `stat` on its root without ever reaching the backend) and remounted automatically.
- **On-the-fly, no config file**: rclone's connection-string syntax (`:webdav,url=...,vendor=...,user=...,pass=...:`), every field quoted; the password is passed through `rclone obscure` first (rclone's own documented requirement), never in plain text to the process, and never on its command line in a form `ps` would show in full (quoted into one argv element doos builds itself).
- **Lifecycle**: the mount runs as a detached, double-forked process (`setsid`, reparented to init) so it outlives the `doos attach` that created it; writes go through rclone's VFS write-back cache (`--vfs-cache-mode=writes`, required for WebDAV, which cannot stream; `--vfs-write-back=1s`, shortened from rclone's 5 s default) and reach the server shortly after a file is closed, independent of whether the container that wrote it has already exited.
- **Readiness and failure**: a fresh mount is given up to 20 s to answer a real directory listing (not just `stat`, which a FUSE root answers without a backend round trip); failure is classified from the log into one of: authentication, unreachable server, TLS certificate, not a WebDAV endpoint, or a generic fallback naming the log file. A failed attempt is unmounted but its log and directory are left for `doos davmount list`/`umount` to inspect or clear.
- **Not covered**: doos never implements WebDAV itself, and does not audit rclone's own implementation (see `audit/CVE-2020-7039` for the equivalent libslirp caveat on the networking side); a self-signed or misconfigured TLS endpoint needs `--dav-insecure` (an explicit, logged choice, not a default).
## Limits (hard caps) ## Limits (hard caps)
@@ -59,6 +76,10 @@ Changeable options apply at the next start of the container. Naming a fixed opti
| slirp4netns ready wait | 5 s | | slirp4netns ready wait | 5 s |
| Lock wait | 5 s | | Lock wait | 5 s |
| Tree recursion depth | 256 | | Tree recursion depth | 256 |
| `--mount`/`--davmount` entries | 8 per container |
| davmount targets managed at once | 32 |
| davmount ready wait | 20 s |
| rclone download | 128 MiB |
## State layout ## State layout
@@ -67,9 +88,14 @@ Changeable options apply at the next start of the container. Naming a fixed opti
``` ```
containers/NAME/{meta,pid,lock,seeded.*,rootfs/,layers/<dir>/{upper,work},private/{root,var,etc}} containers/NAME/{meta,pid,lock,seeded.*,rootfs/,layers/<dir>/{upper,work},private/{root,var,etc}}
images/NAME/{meta,rootfs/} images/NAME/{meta,rootfs/}
tools/rclone-VERSION/rclone
davmounts/ID/{mnt/,log,pid,meta,lock}
``` ```
`meta` keys: `net created version image ids size`. Container `meta` keys: `net created version image ids size memory pids cpus hardening`, then `mounts.count` and,
per entry, `mounts.N.kind mounts.N.src mounts.N.dst mounts.N.ro` (bind), plus `mounts.N.user mounts.N.pass
mounts.N.vendor mounts.N.insecure` (webdav; `pass` is plain text, see Safety rules). A container created under
1.5.0's single-line `mounts=` format is still read, bind entries only.
## Container filesystem, host mode (no `--image`) ## Container filesystem, host mode (no `--image`)
@@ -135,3 +161,5 @@ User, mount, pid, uts, ipc namespaces always; network namespace unless `host`. I
- Unverifiable input is rejected: URL images without sha256, hash mismatches, bad names, bad state paths, missing slirp4netns or newuidmap when requested. - Unverifiable input is rejected: URL images without sha256, hash mismatches, bad names, bad state paths, missing slirp4netns or newuidmap when requested.
- Seeding and scans report what they skipped (`unreadable skipped: N`). - Seeding and scans report what they skipped (`unreadable skipped: N`).
- A failed start kills the half-built container (no orphans); a failed image add or container create leaves nothing behind. - A failed start kills the half-built container (no orphans); a failed image add or container create leaves nothing behind.
- `--dav-pass` (or a password embedded in a davmount URL) is stored in plain text in the container's `meta` file (0700 directory, owner-only, same trust level as everything else under `$DOOS_HOME`); it is never written to a log, never passed to `rclone` on its command line, and is run through `rclone obscure` only immediately before the mount process execs.
- `doos davmount umount` is dry-run by default, scoped to `$DOOS_HOME/davmounts`, never follows symlinks; unmounting a target in use stops storage for any container still mounting it (not prevented, only named in the dry-run output).
+1 -1
View File
@@ -1 +1 @@
1.3.0 1.6.0
+41 -6
View File
@@ -1,6 +1,6 @@
# Architecture # Architecture
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) **Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
How doos works internally. The behavior contract is [SPEC](../SPEC.md); this explains the code. Why it was built this way and what was measured: [RESEARCH](RESEARCH.md); how it compares with other tools: [COMPARISON](../COMPARISON.md). How doos works internally. The behavior contract is [SPEC](../SPEC.md); this explains the code. Why it was built this way and what was measured: [RESEARCH](RESEARCH.md); how it compares with other tools: [COMPARISON](../COMPARISON.md).
@@ -9,15 +9,16 @@ How doos works internally. The behavior contract is [SPEC](../SPEC.md); this exp
| File | Class / role | | File | Class / role |
|---|---| |---|---|
| `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. | | `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. |
| `src/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `base64Decode`, `runProcess` (fork/exec, capped capture), `fsutil` (mkdirs, read/write, bounded `removeTree`, `mirrorDirs` skeleton, `copyTree`/`copyEntries` (with skip predicate), `copyFiles`, `diskUsage` with hardlink dedupe). | | `src/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `base64Decode`, `runProcess` (fork/exec, capped capture), `fetchText`/`fetchFile` (curl, https only, capped), `TempDir` (scratch dir under the state dir, removed on scope exit), `isHex64`, `verifyDetachedSignature`/`verifyClearsignedSignature` (gpg with a throwaway home, pinned fingerprint; shared by `image.cpp` and `davmount.cpp`), `fsutil` (mkdirs, read/write, bounded `removeTree`, `mirrorDirs` skeleton, `copyTree`/`copyEntries` (with skip predicate), `copyFiles`, `diskUsage` with hardlink dedupe). |
| `src/idmap.{hpp,cpp}` | `IdMap` (single or subuid mapping via newuidmap/newgidmap, subuid range lookup), `UserNsJob` (run a function as root of a user namespace with the same map: image extraction, deleting subuid-owned trees). | | `src/idmap.{hpp,cpp}` | `IdMap` (single or subuid mapping via newuidmap/newgidmap, subuid range lookup), `UserNsJob` (run a function as root of a user namespace with the same map: image extraction, deleting subuid-owned trees). |
| `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu, debian resolution from publishers), `verifySignature` (gpg with a throwaway home, pinned fingerprint), `ImageStore` (add with download, sha256 and signature check, extraction; list; delete). | | `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu, debian resolution from publishers), `ImageStore` (add with download, sha256 and signature check via `util::verifyDetachedSignature`, extraction; list; delete). |
| `src/davmount.{hpp,cpp}` | `DavMount`: downloads/verifies/installs a pinned `rclone` under the state dir, resolves a `--mount`/`--davmount` WebDAV entry to a ready, reused, managed FUSE mount (on-the-fly connection string, double-fork daemonize, mountinfo + active-listing readiness, failure classification), `list`/`umount` for `doos davmount`. |
| `src/cgroup.{hpp,cpp}` | `Limits`, `Cgroup`: `doos/<name>/{main,shell}` under the user's delegated cgroup; limits on the parent, freeze on `main`, later attaches in `shell`, OOM and pid-limit counters. | | `src/cgroup.{hpp,cpp}` | `Limits`, `Cgroup`: `doos/<name>/{main,shell}` under the user's delegated cgroup; limits on the parent, freeze on `main`, later attaches in `shell`, OOM and pid-limit counters. |
| `src/hardening.{hpp,cpp}` | `Hardening::apply`: capability bounding set to the Docker default, seccomp-bpf denylist built at runtime (arch check, x32 guard, clone3 ENOSYS, CLONE_NEWUSER filter). | | `src/hardening.{hpp,cpp}` | `Hardening::apply`: capability bounding set to the Docker default, seccomp-bpf denylist built at runtime (arch check, x32 guard, clone3 ENOSYS, CLONE_NEWUSER filter). |
| `src/network.{hpp,cpp}` | `NetMode` (slirp, pasta, host, none) + `Network` (parse, resolv.conf per mode), `SlirpSession`, `PastaSession` (own the helper processes). | | `src/network.{hpp,cpp}` | `NetMode` (slirp, pasta, host, none) + `Network` (parse, resolv.conf per mode), `SlirpSession`, `PastaSession` (own the helper processes). |
| `src/container.{hpp,cpp}` | `Options` (what the user asked for), `Container` (paths, meta, pid file, layout, one-time seeding, usage), `Store` (list, get_or_create with on-demand image fetch, remove). | | `src/container.{hpp,cpp}` | `Options` (what the user asked for), `Container` (paths, meta, pid file, layout, one-time seeding, usage), `Store` (list, get_or_create with on-demand image fetch, remove). |
| `src/sandbox.{hpp,cpp}` | `Sandbox`: start a container (clone + id map + mounts + pivot_root + init), watch it (size cap), or join a running one (setns). | | `src/sandbox.{hpp,cpp}` | `Sandbox`: resolves davmounts (`DavMount::resolveAll`), starts a container (clone + id map + mounts + pivot_root + init), watches it (size cap), or joins a running one (setns). |
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands and `image list/add/delete`. | | `src/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands, `image list/add/delete` and `davmount list/umount`. |
| `src/main.cpp` | Catches exceptions, prints `doos: <message>`, exit 1. | | `src/main.cpp` | Catches exceptions, prints `doos: <message>`, exit 1. |
| `tests/run.sh` | End-to-end suite. | | `tests/run.sh` | End-to-end suite. |
| `scripts/release.sh` | Bump, build, test, commit, tag. | | `scripts/release.sh` | Bump, build, test, commit, tag. |
@@ -33,6 +34,7 @@ Cli::cmdAttach
runningPid()? yes -> join() no -> start() runningPid()? yes -> join() no -> start()
start(): start():
DavMount::resolveAll(mounts) before clone: webdav entries become ready, reused Bind entries
clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1 clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1
IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap
slirp mode: SlirpSession.start(child) waits for ready-fd slirp mode: SlirpSession.start(child) waits for ready-fd
@@ -71,7 +73,9 @@ Image mode:
2. Mountpoints (`/proc /dev /tmp /run ...`) created inside the merged tree. 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); 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. unmount of the old root.
## Seeding (host mode, once per container) ## Seeding (host mode, once per container)
@@ -84,6 +88,37 @@ unmount of the old root.
Reasons are in `SPEC.md` (host root-owned files appear as `nobody`). Reasons are in `SPEC.md` (host root-owned files appear as `nobody`).
## davmount flow
```
DavMount::resolveAll(mounts) (Sandbox::start, before clone)
for each WebDav entry:
id = fnv1a(url, user, pass, vendor, insecure) dst excluded: shared across containers
dir = $DOOS_HOME/davmounts/<id>/, mkdirs mnt/, FileLock dir/lock
already in mountinfo (fuse.*) and pidfile's pid alive and an active listing succeeds?
-> reuse mnt/ as is (sub-second)
-> else unmountQuiet (fusermount3 -u, then -uz if still mounted) and fall through
ensureRclone(): $DOOS_HOME/tools/rclone-VERSION/rclone; `rclone version` match -> reuse,
else download+verify (util::fetchFile, util::verifyClearsignedSignature against the
pinned key, sha256 against the signed SHA256SUMS), unzip, atomic rename into place
rclone obscure <pass> (only if a password was given)
connection string ":webdav,url=\"...\",vendor=\"...\"[,user=...][,pass=...][,no_check_certificate=true]:"
spawnRcloneMount: pipe, fork, fork again, setsid + redirect to dir/log, execve(rclone, mount, ...)
first fork writes the second fork's pid down the pipe, then exits (reaped immediately);
the real daemon is now a child of init, outliving this doos process entirely
poll up to kDavMountReadyTimeoutMs: daemon pid alive? mountinfo shows it AND a real directory
listing succeeds (stat() alone answers without touching the backend; only listing does)?
-> ready, write dir/pid + dir/meta (never the password)
-> daemon died or never answers in time: unmountQuiet, classify dir/log's tail
(401/Unauthorized, x509/certificate, 501/Unsupported method, dial/host/refused/timeout,
else "see the log"), throw
Bind entries pass through unchanged; every resolved entry becomes a plain Bind MountSpec
pointing at dir/mnt, so mountExtra() (sandbox.cpp) never has to know davmount exists.
```
`doos davmount list`/`umount` walk/remove `$DOOS_HOME/davmounts/*` directly; `umount` is dry-run unless
`--yes`, same pattern as `delete`/`image delete`.
## Images ## Images
`ImageStore::add`: lock-free staging dir `images/.tmp-NAME`, download with curl (https only, size and time caps), `ImageStore::add`: lock-free staging dir `images/.tmp-NAME`, download with curl (https only, size and time caps),
+87
View File
@@ -0,0 +1,87 @@
# CI: Gitea Actions
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
The workflow is [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). It needs two kinds of
runner, because the thing doos tests is the thing most CI containers are built to prevent: a
process creating its own user, mount and pid namespaces and an overlayfs mount.
## What runs where
| Job | Runner | Needs | Proves |
|---|---|---|---|
| `build` | any `docker://` runner (label `ubuntu-latest`) | a C++20 toolchain and CMake | the binary still compiles with `-Werror` and the documentation set is still fully cross-linked |
| `valgrind` | any `docker://` runner (label `ubuntu-latest`) | the above, plus `valgrind` | no new memory error or leak in doos's own code (see [valgrind/README.md](../valgrind/README.md)); this still gives a real answer even when the container's own AppArmor/seccomp policy blocks the user namespace doos tries to create, because `scripts/valgrind.sh` only fails on what valgrind itself reports, never on doos's own exit code |
| `test` | self-hosted, host mode, label `doos-host` | see below | the real end-to-end suite (`tests/run.sh`, currently 88 checks): real overlayfs, real namespaces, real downloads, real cgroup v2 limits |
| `audit` | self-hosted, host mode, label `doos-host` | see below | the named CVE regression set in [`audit/`](../audit/README.md) against the binary this run just built |
`build` and `valgrind` run in an ephemeral Docker-mode act_runner container and need nothing this
repository does not already ask of any build machine. `test` and `audit` need a real Linux host
(or VM) that act_runner reaches in *host mode* (a plain label, not a `docker://` one), because the
things under test are precisely the things a container runtime's own default policy restricts: an
unprivileged user namespace that creates an overlayfs mount, a systemd user manager with cgroup v2
delegated to it, and (on Ubuntu 24.04+) an AppArmor profile that permits user namespaces for this
one binary path. Running those two jobs inside another container would either need that container
privileged (defeating the point of isolating the runner) or would silently test less than it claims
to, the same vacuous-pass risk `review2.md` section 2 warns about for the audit script's own design.
## One-time host runner setup (by whoever administers the runner, not by the workflow)
The workflow never runs anything with `sudo`, on purpose (same rule as everywhere else in this
project: AppArmor and system changes are requested from a human, never done by an agent or a CI
job on its own). Before registering the `doos-host` runner:
```
sudo apt-get install -y build-essential cmake slirp4netns passt gnupg uidmap curl tar
# /etc/subuid and /etc/subgid must list the runner's own user; uidmap's postinst usually does this.
grep "^$(whoami):" /etc/subuid /etc/subgid
# cgroup v2 must be delegated to this user's systemd manager (loginctl enable-linger helps it
# survive without an interactive session):
loginctl enable-linger "$(whoami)"
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers
# Build doos once at the exact path the runner will use, then install its AppArmor profile
# (needed on Ubuntu 24.04+; see README's Troubleshooting table for the symptom this fixes):
cmake -S . -B build && cmake --build build
build/doos apparmor | sudo tee /etc/apparmor.d/doos-runner >/dev/null
sudo apparmor_parser -r /etc/apparmor.d/doos-runner
```
Then register the runner itself (see
[Gitea's act_runner docs](https://docs.gitea.com/usage/actions/act-runner) for the current command
and token source):
```
./act_runner register --instance https://<your-gitea> --token <runner-token> \
--name doos-host --labels doos-host
./act_runner daemon
```
A `docker://`-labelled runner for `build` and `valgrind` needs no special setup; any act_runner in
its default Docker mode works (the Gitea docs' own suggested registration labels, such as
`ubuntu-latest:docker://gitea/runner-images:ubuntu-latest`, are enough).
## Known Gitea Actions gaps versus GitHub Actions (checked against the 1.25 docs)
- **`timeout-minutes` and `concurrency` are parsed but ignored.** The workflow still sets
`timeout-minutes` on every job for readability and for anyone who later runs the same file
under real GitHub Actions, but the actual, enforced cap in each job is a shell-level
`timeout <seconds>` wrapped around the one command that matters, consistent with this project's
own rule that every automated process gets a hard, enforced ceiling, not a cap that only works
if the runner happens to honor it.
- **`permissions` support is version-dependent** and GitHub-only scopes (`checks`, `id-token`,
`security-events`, `pages`, ...) are not supported at all. The workflow does not rely on it.
- **`uses: actions/checkout@v4`** is resolved the same way GitHub Actions resolves it (Gitea
Actions proxies to GitHub by default); an instance configured without internet access to
GitHub needs `https://gitea.com/actions/checkout` instead, or a plain `git clone` step.
## Running it without Gitea
Every job is one or two `make` targets with a `timeout` in front; none of it depends on Gitea
Actions syntax beyond `on`/`jobs`/`runs-on`/`steps`, so the same file works, unmodified, as a
GitHub Actions workflow (just move it to `.github/workflows/`) with the one exception that
`timeout-minutes` would then actually be enforced by the platform instead of being a label only
(harmless either way).
Back to [README](../README.md), [AGENTS](../AGENTS.md), [SPEC](../SPEC.md).
+15 -2
View File
@@ -1,6 +1,6 @@
# Research: user-level containers with overlayfs # Research: user-level containers with overlayfs
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) **Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime. Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime.
@@ -62,7 +62,20 @@ removed afterwards. Rows marked "unmeasured" have no run behind them.
| G5 | `--net host` shares abstract sockets | pasta mode | **done in 1.1.0** (`--net pasta`); `host` stays as an explicit, noted opt-in | `network.cpp` | | G5 | `--net host` shares abstract sockets | pasta mode | **done in 1.1.0** (`--net pasta`); `host` stays as an explicit, noted opt-in | `network.cpp` |
| G6 | No seccomp, nested userns allowed | Capability drop + seccomp-bpf denylist | **done in 1.1.0** (`--hardening`) | `hardening.cpp` | | G6 | No seccomp, nested userns allowed | Capability drop + seccomp-bpf denylist | **done in 1.1.0** (`--hardening`) | `hardening.cpp` |
| G7 | Host-owned lower dirs unwritable, `chown` of host files fails, empty `/var` | Image layer + subuid; skeleton and seeding for host mode | **done in 1.0.0** | `image.cpp`, `container.cpp` | | G7 | Host-owned lower dirs unwritable, `chown` of host files fails, empty `/var` | Image layer + subuid; skeleton and seeding for host mode | **done in 1.0.0** | `image.cpp`, `container.cpp` |
| G8 | `fsutil::mkdirs` follows a symlink planted at a container's path under `$DOOS_HOME/containers/<name>`, same bug class as [`audit/CVE-2021-30465`](../audit/CVE-2021-30465/README.md) | refuse or unlink-and-recreate when a path component is a pre-existing symlink | **unmeasured fix, confirmed gap** (found by `make audit`, 2026-10-23) | `fsutil::mkdirs` in `src/util.cpp` | | G8 | `fsutil::mkdirs` followed a symlink planted at a container's path under `$DOOS_HOME/containers/<name>`, same bug class as [`audit/CVE-2021-30465`](../audit/CVE-2021-30465/README.md) | walk the path component by component from a pinned base fd with `mkdirat`/`openat(O_NOFOLLOW\|O_DIRECTORY)`, refusing any symlink instead of following it | **done in 1.3.0**, re-measured: `make -C audit/CVE-2021-30465 test` now PASSes | `fsutil::mkdirs` in `src/util.cpp` |
| 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: a plain `--mount` bind needs the host path mounted already; see G13 for WebDAV specifically | **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` |
| G13 | G12's `--mount` still needed the user to mount WebDAV themselves (e.g. `rclone mount`) before pointing `--mount` at the result; no in-box path to remote storage | `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source: doos downloads, GPG-verifies and installs a pinned `rclone` build under `$DOOS_HOME/tools/` (never the system, never PATH), then drives it on the host (on-the-fly connection string, no config file; detached double-fork daemon so the mount outlives the `attach` that created it) and bind-mounts the result exactly like a G12 bind. Deduplicated and reused by target (url+creds+vendor), not by destination | **done**, measured below under "davmount" | `src/davmount.{hpp,cpp}`, `DavMount::resolveAll` called from `Sandbox::start` |
### davmount (G13, measured against a local `rclone serve webdav` fixture)
- First use (fresh `$DOOS_HOME`, no managed rclone yet): `rclone-v1.75.2-linux-amd64.zip` (~20 MiB) downloaded, `SHA256SUMS` GPG-verified against the pinned fingerprint (`gpg` here accepts the DSA/SHA-1 signature rclone's release process still uses; it is a key-import plus `GOODSIG`/`VALIDSIG` fingerprint match, the same check as Ubuntu's image signature, not the web-of-trust warning gpg also prints), sha256 matched, unzipped, installed; the whole install plus the first mount completed in a few seconds and only ever happens once per `$DOOS_HOME`.
- Reuse (second, unrelated container against the same target): measured at 41 ms and 45 ms for `attach -- true`, i.e. no remount cost at all; the existing FUSE mount is confirmed healthy (pid alive, mountinfo entry present, a real directory listing succeeds) and reused as is.
- Write durability: `--vfs-cache-mode=writes` (rclone's own recommendation for WebDAV, which cannot stream) queues a write for upload after `--vfs-write-back` (shortened here to 1 s from rclone's 5 s default). Measured: a file written from inside a container appeared on the backend about 5 s after the *default* write-back elapsed in one run; with `--vfs-write-back=1s` the same write reached the backend within about 1-2 s of the writing process exiting, confirmed independent of the container's own lifetime (the container had already exited; the detached rclone daemon kept running and finished the upload on its own).
- Failure classification, each reproduced against the same local fixture: wrong `--dav-pass` surfaced the server's `401 Unauthorized` and was reported as "authentication failed"; an unresolvable/unreachable address and a non-WebDAV endpoint (plain HTTP server, `501 Unsupported method ('PROPFIND')`) were reproduced in manual testing with the matching classifications. Every case failed within a few seconds, never the full 20 s readiness budget, because rclone mounts eagerly and only a real directory listing (not `stat` on the FUSE root, which always answers without touching the backend) forces the round trip that surfaces the error.
- `doos davmount list`/`umount --yes`: verified showing a live target's id/vendor/url and state, dry-run output naming the path and warning it stops storage for any container still using it, and `--yes` actually unmounting (`fusermount3 -u`) and removing the managed directory.
### G1: memory, cpu and pids limits (cgroup v2) ### G1: memory, cpu and pids limits (cgroup v2)
+6 -110
View File
@@ -1,121 +1,17 @@
{ {
"meta": { "meta": {
"date": "2026-10-23", "date": "2026-10-25",
"kernel": "7.0.0-38-generic", "kernel": "7.0.0-38-generic",
"cpus": 12, "cpus": 12,
"runs": 10, "runs": 10,
"doos": "1.1.2", "doos": "1.5.0",
"podman": "podman version 5.7.0", "podman": "podman version 5.7.0",
"docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1", "docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1",
"bubblewrap": "bubblewrap 0.11.1", "bubblewrap": "bubblewrap 0.11.1",
"src_lines": 3445, "src_lines": 4413,
"shared_libs": 6 "shared_libs": 6
}, },
"startup": [ "startup": [{"id":"bwrap_none","label":"bubblewrap (host /usr)","group":"none","kind":"other","ms":9.2},{"id":"bwrap_net","label":"bubblewrap (host /usr)","group":"default","kind":"other","ms":8.0},{"id":"doos_host_none","label":"doos host mode","group":"none","kind":"doos","ms":16.2},{"id":"doos_alpine_none","label":"doos Alpine image","group":"none","kind":"doos","ms":14.7},{"id":"doos_host_slirp","label":"doos host mode","group":"default","kind":"doos","ms":60.0},{"id":"doos_alpine_slirp","label":"doos Alpine image","group":"default","kind":"doos","ms":56.2},{"id":"doos_alpine_pasta","label":"doos Alpine, pasta","group":"default","kind":"doos","ms":66.0},{"id":"podman_none","label":"Podman Alpine","group":"none","kind":"other","ms":216.0},{"id":"podman_net","label":"Podman Alpine","group":"default","kind":"other","ms":232.9},{"id":"docker_none","label":"Docker Alpine (rootful)","group":"none","kind":"other","ms":345.8},{"id":"docker_net","label":"Docker Alpine (rootful)","group":"default","kind":"other","ms":449.1}],
{ "sizes": [{"id":"bwrap","bytes":80424},{"id":"crun","bytes":483392},{"id":"doos","bytes":704512},{"id":"docker","bytes":31374336},{"id":"podman","bytes":43180896}],
"id": "bwrap_none", "cpu": {"wall_seconds": 4.2, "busy_loops": 4, "unlimited": 16.32, "half_cpu": 2.29}
"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
}
} }
+2 -2
View File
@@ -39,8 +39,8 @@
<line class="grid" x1="670.0" y1="84" x2="670.0" y2="146"/> <line class="grid" x1="670.0" y1="84" x2="670.0" y2="146"/>
<text class="tick" x="670.0" y="162" text-anchor="middle">20</text> <text class="tick" x="670.0" y="162" text-anchor="middle">20</text>
<text class="lab" x="160" y="105.0" text-anchor="end">no limit</text> <text class="lab" x="160" y="105.0" text-anchor="end">no limit</text>
<path class="oth" d="M170.0,92.0 h402.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-402.5 z"><title>no limit: 16.3 CPU-seconds</title></path> <path class="oth" d="M170.0,92.0 h404.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-404.0 z"><title>no limit: 16.3 CPU-seconds</title></path>
<text class="val" x="584.5" y="105.0">16.3 CPU-s</text> <text class="val" x="586.0" y="105.0">16.3 CPU-s</text>
<text class="lab" x="160" y="135.0" text-anchor="end">--cpus 0.5</text> <text class="lab" x="160" y="135.0" text-anchor="end">--cpus 0.5</text>
<path class="acc" d="M170.0,122.0 h53.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-53.2 z"><title>--cpus 0.5: 2.3 CPU-seconds</title></path> <path class="acc" d="M170.0,122.0 h53.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-53.2 z"><title>--cpus 0.5: 2.3 CPU-seconds</title></path>
<text class="val" x="235.2" y="135.0">2.3 CPU-s</text> <text class="val" x="235.2" y="135.0">2.3 CPU-s</text>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 316" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 316" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
<title id="t">Size of the main executable</title> <title id="t">Size of the main executable</title>
<desc id="d">Horizontal bars on a logarithmic axis: bubblewrap 79 KiB, crun (OCI runtime) 472 KiB, doos 552 KiB, docker CLI 29.9 MiB, podman 41.2 MiB.</desc> <desc id="d">Horizontal bars on a logarithmic axis: bubblewrap 79 KiB, crun (OCI runtime) 472 KiB, doos 688 KiB, docker CLI 29.9 MiB, podman 41.2 MiB.</desc>
<style> <style>
.bg { fill: var(--surface); } .bg { fill: var(--surface); }
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; } .t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
@@ -42,8 +42,8 @@
<path class="oth" d="M170.0,140.0 h140.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-140.3 z"><title>crun (OCI runtime): 472 KiB</title></path> <path class="oth" d="M170.0,140.0 h140.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-140.3 z"><title>crun (OCI runtime): 472 KiB</title></path>
<text class="val" x="322.3" y="153.0">472 KiB</text> <text class="val" x="322.3" y="153.0">472 KiB</text>
<text class="lab" x="160" y="183.0" text-anchor="end">doos</text> <text class="lab" x="160" y="183.0" text-anchor="end">doos</text>
<path class="acc" d="M170.0,170.0 h150.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-150.4 z"><title>doos: 552 KiB</title></path> <path class="acc" d="M170.0,170.0 h164.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-164.5 z"><title>doos: 688 KiB</title></path>
<text class="val" x="332.4" y="183.0">552 KiB</text> <text class="val" x="346.5" y="183.0">688 KiB</text>
<text class="lab" x="160" y="213.0" text-anchor="end">docker CLI</text> <text class="lab" x="160" y="213.0" text-anchor="end">docker CLI</text>
<path class="oth" d="M170.0,200.0 h408.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-408.5 z"><title>docker CLI: 29.9 MiB</title></path> <path class="oth" d="M170.0,200.0 h408.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-408.5 z"><title>docker CLI: 29.9 MiB</title></path>
<text class="val" x="590.5" y="213.0">29.9 MiB</text> <text class="val" x="590.5" y="213.0">29.9 MiB</text>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+24 -24
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 502" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 502" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
<title id="t">Time to start a trivial command</title> <title id="t">Time to start a trivial command</title>
<desc id="d">Horizontal bars, milliseconds, lower is better. doos: 14 to 65 ms; Podman: 208 to 241 ms; Docker: 344 to 436 ms; bubblewrap: 7 to 8 ms. Exact values are in the table in COMPARISON.md.</desc> <desc id="d">Horizontal bars, milliseconds, lower is better. doos: 15 to 66 ms; Podman: 216 to 233 ms; Docker: 346 to 449 ms; bubblewrap: 8 to 9 ms. Exact values are in the table in COMPARISON.md.</desc>
<style> <style>
.bg { fill: var(--surface); } .bg { fill: var(--surface); }
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; } .t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
@@ -21,7 +21,7 @@
</style> </style>
<rect class="bg" x="0" y="0" width="760" height="502" rx="8"/> <rect class="bg" x="0" y="0" width="760" height="502" rx="8"/>
<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text> <text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>
<text class="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-23, kernel 7.0.0-38-generic, 12 cores. Docker is a rootful daemon.</text> <text class="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-25, kernel 7.0.0-38-generic, 12 cores. Docker is a rootful daemon.</text>
<rect class="acc" x="20" y="55" width="12" height="12" rx="2"/> <rect class="acc" x="20" y="55" width="12" height="12" rx="2"/>
<text class="t2" x="38" y="65">doos</text> <text class="t2" x="38" y="65">doos</text>
<rect class="oth" x="88" y="55" width="12" height="12" rx="2"/> <rect class="oth" x="88" y="55" width="12" height="12" rx="2"/>
@@ -34,20 +34,20 @@
<line class="grid" x1="576.0" y1="90" x2="576.0" y2="242"/> <line class="grid" x1="576.0" y1="90" x2="576.0" y2="242"/>
<line class="grid" x1="670.0" y1="90" x2="670.0" y2="242"/> <line class="grid" x1="670.0" y1="90" x2="670.0" y2="242"/>
<text class="lab" x="190" y="111.0" text-anchor="end">bubblewrap (host /usr)</text> <text class="lab" x="190" y="111.0" text-anchor="end">bubblewrap (host /usr)</text>
<path class="oth" d="M200.0,98.0 h3.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.9 z"><title>bubblewrap (host /usr): 8.4 ms</title></path> <path class="oth" d="M200.0,98.0 h4.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-4.6 z"><title>bubblewrap (host /usr): 9.2 ms</title></path>
<text class="val" x="215.9" y="111.0">8.4 ms</text> <text class="val" x="216.6" y="111.0">9.2 ms</text>
<text class="lab" x="190" y="141.0" text-anchor="end">doos Alpine image</text> <text class="lab" x="190" y="141.0" text-anchor="end">doos Alpine image</text>
<path class="acc" d="M200.0,128.0 h9.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.0 z"><title>doos Alpine image: 13.8 ms</title></path> <path class="acc" d="M200.0,128.0 h9.8 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.8 z"><title>doos Alpine image: 14.7 ms</title></path>
<text class="val" x="221.0" y="141.0">13.8 ms</text> <text class="val" x="221.8" y="141.0">14.7 ms</text>
<text class="lab" x="190" y="171.0" text-anchor="end">doos host mode</text> <text class="lab" x="190" y="171.0" text-anchor="end">doos host mode</text>
<path class="acc" d="M200.0,158.0 h13.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-13.6 z"><title>doos host mode: 18.7 ms</title></path> <path class="acc" d="M200.0,158.0 h11.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-11.2 z"><title>doos host mode: 16.2 ms</title></path>
<text class="val" x="225.6" y="171.0">18.7 ms</text> <text class="val" x="223.2" y="171.0">16.2 ms</text>
<text class="lab" x="190" y="201.0" text-anchor="end">Podman Alpine</text> <text class="lab" x="190" y="201.0" text-anchor="end">Podman Alpine</text>
<path class="oth" d="M200.0,188.0 h191.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-191.3 z"><title>Podman Alpine: 208 ms</title></path> <path class="oth" d="M200.0,188.0 h199.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-199.0 z"><title>Podman Alpine: 216 ms</title></path>
<text class="val" x="403.3" y="201.0">208 ms</text> <text class="val" x="411.0" y="201.0">216 ms</text>
<text class="lab" x="190" y="231.0" text-anchor="end">Docker Alpine (rootful)</text> <text class="lab" x="190" y="231.0" text-anchor="end">Docker Alpine (rootful)</text>
<path class="oth" d="M200.0,218.0 h319.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-319.3 z"><title>Docker Alpine (rootful): 344 ms</title></path> <path class="oth" d="M200.0,218.0 h321.1 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-321.1 z"><title>Docker Alpine (rootful): 346 ms</title></path>
<text class="val" x="531.3" y="231.0">344 ms</text> <text class="val" x="533.1" y="231.0">346 ms</text>
<line class="axis" x1="200" y1="90" x2="200" y2="242"/> <line class="axis" x1="200" y1="90" x2="200" y2="242"/>
<text class="tick" x="200.0" y="256" text-anchor="middle">0</text> <text class="tick" x="200.0" y="256" text-anchor="middle">0</text>
<text class="tick" x="294.0" y="256" text-anchor="middle">100</text> <text class="tick" x="294.0" y="256" text-anchor="middle">100</text>
@@ -63,23 +63,23 @@
<line class="grid" x1="576.0" y1="292" x2="576.0" y2="474"/> <line class="grid" x1="576.0" y1="292" x2="576.0" y2="474"/>
<line class="grid" x1="670.0" y1="292" x2="670.0" y2="474"/> <line class="grid" x1="670.0" y1="292" x2="670.0" y2="474"/>
<text class="lab" x="190" y="313.0" text-anchor="end">bubblewrap (host /usr)</text> <text class="lab" x="190" y="313.0" text-anchor="end">bubblewrap (host /usr)</text>
<path class="oth" d="M200.0,300.0 h3.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.0 z"><title>bubblewrap (host /usr): 7.4 ms</title></path> <path class="oth" d="M200.0,300.0 h3.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.5 z"><title>bubblewrap (host /usr): 8 ms</title></path>
<text class="val" x="215.0" y="313.0">7.4 ms</text> <text class="val" x="215.5" y="313.0">8 ms</text>
<text class="lab" x="190" y="343.0" text-anchor="end">doos Alpine image</text> <text class="lab" x="190" y="343.0" text-anchor="end">doos Alpine image</text>
<path class="acc" d="M200.0,330.0 h45.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-45.0 z"><title>doos Alpine image: 52.1 ms</title></path> <path class="acc" d="M200.0,330.0 h48.8 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-48.8 z"><title>doos Alpine image: 56.2 ms</title></path>
<text class="val" x="257.0" y="343.0">52.1 ms</text> <text class="val" x="260.8" y="343.0">56.2 ms</text>
<text class="lab" x="190" y="373.0" text-anchor="end">doos host mode</text> <text class="lab" x="190" y="373.0" text-anchor="end">doos host mode</text>
<path class="acc" d="M200.0,360.0 h49.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-49.5 z"><title>doos host mode: 56.9 ms</title></path> <path class="acc" d="M200.0,360.0 h52.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-52.4 z"><title>doos host mode: 60 ms</title></path>
<text class="val" x="261.5" y="373.0">56.9 ms</text> <text class="val" x="264.4" y="373.0">60 ms</text>
<text class="lab" x="190" y="403.0" text-anchor="end">doos Alpine, pasta</text> <text class="lab" x="190" y="403.0" text-anchor="end">doos Alpine, pasta</text>
<path class="acc" d="M200.0,390.0 h56.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-56.7 z"><title>doos Alpine, pasta: 64.6 ms</title></path> <path class="acc" d="M200.0,390.0 h58.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-58.0 z"><title>doos Alpine, pasta: 66 ms</title></path>
<text class="val" x="268.7" y="403.0">64.6 ms</text> <text class="val" x="270.0" y="403.0">66 ms</text>
<text class="lab" x="190" y="433.0" text-anchor="end">Podman Alpine</text> <text class="lab" x="190" y="433.0" text-anchor="end">Podman Alpine</text>
<path class="oth" d="M200.0,420.0 h222.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-222.7 z"><title>Podman Alpine: 241 ms</title></path> <path class="oth" d="M200.0,420.0 h214.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-214.9 z"><title>Podman Alpine: 233 ms</title></path>
<text class="val" x="434.7" y="433.0">241 ms</text> <text class="val" x="426.9" y="433.0">233 ms</text>
<text class="lab" x="190" y="463.0" text-anchor="end">Docker Alpine (rootful)</text> <text class="lab" x="190" y="463.0" text-anchor="end">Docker Alpine (rootful)</text>
<path class="oth" d="M200.0,450.0 h405.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-405.5 z"><title>Docker Alpine (rootful): 436 ms</title></path> <path class="oth" d="M200.0,450.0 h418.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-418.2 z"><title>Docker Alpine (rootful): 449 ms</title></path>
<text class="val" x="617.5" y="463.0">436 ms</text> <text class="val" x="630.2" y="463.0">449 ms</text>
<line class="axis" x1="200" y1="292" x2="200" y2="474"/> <line class="axis" x1="200" y1="292" x2="200" y2="474"/>
<text class="tick" x="200.0" y="488" text-anchor="middle">0</text> <text class="tick" x="200.0" y="488" text-anchor="middle">0</text>
<text class="tick" x="294.0" y="488" text-anchor="middle">100</text> <text class="tick" x="294.0" y="488" text-anchor="middle">100</text>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

+150
View File
@@ -0,0 +1,150 @@
# doos: full review
Date: 2026-10-24 | Scope: all of `src/` (3,558 lines, read in full), `CMakeLists.txt`, `Makefile`,
`scripts/*.sh`, `tests/run.sh`, `audit/`, `valgrind/`, and every document (`README.md`, `SPEC.md`,
`docs/ARCHITECTURE.md`, `docs/RESEARCH.md`, `COMPARISON.md`, `AGENTS.md`, `CHANGELOG.md`).
Method: read every source file top to bottom (not a diff), ran the real e2e suite, the CVE audit,
and valgrind against the current binary, and tested two inputs by hand that looked suspicious while
reading. This is a second, independent pass; [`review.md`](review.md) is an earlier review whose
findings are checked against the current code, not reused.
**State at review time:** `v1.3.1`, working tree clean, `tests/run.sh` 88/88, `make audit` 18/18,
`make valgrind` 6/6 (see those tools for what they do and do not cover).
## 1. Summary
doos is a small, coherent, unusually well-specified codebase for what it is: a single C++20 binary
that builds a user-namespace container by hand (no runc, no libcontainer, no OCI anything) and
backs it with overlayfs, cgroup v2 and a seccomp denylist. The code reads like it was written by one
disciplined author following one set of rules consistently: every constant lives in `spec.hpp`, every
destructive path is capped, every external process is run from a fixed path, every error throws with
a message that says what to do about it. The project-level discipline (docs that state gaps next to
the code that closes them, a changelog per release, a `make audit`/`make valgrind` pair that produces
actual evidence instead of claims) is better than most projects an order of magnitude larger.
The flip side of "one author, one style" is that nothing here has been read by a second pair of eyes
until review.md and this review. I found one confirmed, reproducible bug (an integer overflow that
silently accepts a nonsensical `--size`) and a handful of smaller inconsistencies and gaps, listed
below. Nothing I found is a new security hole in the model doos is actually built on; the shared-kernel,
denylist-seccomp, unaudited-code risks are already disclosed in `COMPARISON.md` section 6 and
`docs/RESEARCH.md`, and I did not find reason to soften or harden that disclosure.
**Resolution (1.3.2, same day):** 2.1, 2.2 and 2.4 are fixed (see `CHANGELOG.md`); 2.3 is logged as
an accepted trade-off (gap G11 in `docs/RESEARCH.md`) rather than fixed; 2.5's CI half is resolved
by `.gitea/workflows/ci.yml` and `docs/CI.md`, and its LICENSE half by the new `LICENSE` file. The
findings below are left exactly as first written, for the record of what was found and how.
## 2. Findings
Ordered most to least severe. Each is something I read or reproduced, not a hypothetical.
### 2.1 [MEDIUM] `parseSize` silently overflows on an absurd input, instead of rejecting it
`src/util.cpp:54-71`. The function accepts up to 15 digits, then left-shifts by up to 40 bits with no
overflow check. `--size 16777217t` (16.7 million exabytes, obvious garbage) wraps around 64-bit
arithmetic to exactly `2^40`, which is `1.0T`, which is a *valid* size, so it is accepted silently:
```
$ doos get_or_create --size 16777217t sizetest2
created sizetest2
$ doos list
NAME STATE NET IMAGE SIZE AGE
sizetest2 stopped slirp host 1.0T 0s
```
Reproduced against `build/doos` at the time of this review. The same shift pattern exists for `k`/`m`/`g`
too, just with smaller multipliers that need more digits to wrap; `t` wraps with an 8-digit input. Every
caller does bound-check the *result*, which is why most overflowing inputs are still rejected (the
earlier example, `16777217t`+1 lands outside range and throws): it is specifically the inputs that
wrap into the valid `[64m, 1t]` window that go through unnoticed. Fix: check `v > (kMaxSizeBytes >>
shift)` before shifting, or reject more than 4-5 digits before a `t`/`g` suffix.
### 2.2 [LOW] Two different "no PATH lookup" strategies for external helpers, not one
`spec.hpp:52-53,75-76` hardcode `/usr/bin/newuidmap`, `/usr/bin/newgidmap`, `/usr/bin/slirp4netns`,
`/usr/bin/pasta` as single absolute paths. `resolveBinary()` (`src/util.cpp:397-417`) instead checks a
state-dir override then a list of six system directories, and is used for `curl`, `tar`, `sha256sum`
and `gpg`. Both avoid a `PATH`-poisoning attack (the actual goal, and both reach it), but the first
group breaks if a distribution installs those four tools somewhere other than exactly `/usr/bin` (for
example Fedora's `/usr/sbin` for some setuid helpers, or a non-standard prefix), while the second group
would still find them. This is not a security gap, it is an inconsistency that will show up as a
confusing "helper not found" on a distro the project has not been tested on, where the real fix (run
`resolveBinary()` for all five, or document the four as intentionally pinned) is a few lines either way.
### 2.3 [LOW] Catalog YAML/JSON parsing is hand-rolled string search, not a parser
`src/image.cpp:160-173` (Alpine's `latest-releases.yaml`) and `src/image.cpp:192-204` (Debian's
`index.json`, including a base64-embedded manifest) are read with `line.find(...)` and fixed byte
offsets, not a YAML/JSON library. This keeps the dependency count at zero, which fits the project's
"fewer moving parts" rule, and it is already tested against the real Alpine and Debian endpoints in
`tests/run.sh`. The risk is narrow but real: if either publisher reformats their file (reorders keys,
changes indentation, wraps a line), the parser fails closed with a thrown error (good: `docs/RESEARCH.md`
already calls this an accepted trade-off for Debian specifically) rather than silently extracting the
wrong artifact. Worth a one-line gap entry next to the code, same as the project's own convention asks.
### 2.4 [LOW] `Sandbox::join`'s ordering is a correctness invariant that is not stated in code
`src/sandbox.cpp:521-581`. Joining a running container does six `setns()` calls in a plain (not yet
seccomp-filtered) forked process, then forks again and *only there* calls `applyLimitsAndEnv`, which
installs the seccomp filter that denies `setns` (`src/hardening.cpp:184-186`). This is correct and
I could not find a bug in it, but the correctness depends entirely on hardening being applied after
the `setns` loop and in the right (inner) fork, a constraint that lives only in the order of statements,
not in a comment or an assertion. A future edit that moves `applyLimitsAndEnv` earlier "to simplify"
would silently break every second `attach`, and the e2e suite's "second attach joins" case would need
to be the thing that catches it rather than a comment telling the next editor not to do that.
### 2.5 [LOW] No CI, no license file
`find . -iname "*.yml"` turns up nothing; there is no `.github/workflows`, so the 88-check e2e suite,
`make audit` and `make valgrind` only run when someone remembers to run them by hand. There is also no
`LICENSE` file, which for a "commercial and scientific" project (the user's own standing rule) means
the legal default is "all rights reserved" by anyone who is not the author, worth a deliberate choice
rather than an omission.
### 2.6 [INFO] Everything else I checked was either already documented or held up
I specifically went looking for bugs in the capability-drop/seccomp-install ordering relative to
`execve`, in the overlayfs mount option construction, in the `--size`/`--memory`/`--pids`/`--cpus`
range validation (other than 2.1), in the container/image name validators against path traversal, in
`Store::remove`'s delete-outside-the-store guard, and in the symlink handling added for the PATH and
TOCTOU fixes (`src/util.cpp:329-354`, `src/sandbox.cpp:130-153`). All of it matched what the comments
claim, and the specific "mkdirs follows a symlink" class of bug this project itself found via
`audit/CVE-2021-30465` is closed the right way (per-component `openat(O_NOFOLLOW)`), not patched over.
## 3. Grades
| Category | Grade | Why |
|---|---|---|
| Architecture & code organization | 8.5/10 | One class per responsibility, matches `docs/ARCHITECTURE.md` exactly; every constant in one file; consistent error style throughout 3,558 lines. Loses points only for 2.4's unstated invariant. |
| Correctness & input validation | 7/10 | Careful everywhere except one real bug (2.1). CLI parsing, name validation, bounds checks on memory/pids/cpus are all solid; size parsing is the one spot that was not. |
| Isolation design (namespaces, overlayfs, mounts) | 9/10 | TOCTOU-safe symlink handling, `O_NOFOLLOW` directory creation, correct remount-flag preservation, correct setns/seccomp ordering. This is the part of the codebase that most clearly got the most attention, and it shows. |
| Resource controls (cgroup, size cap) | 8.5/10 | Adaptive scan-duty throttling, freeze/thaw hysteresis, whole-container OOM kill, hard caps on scan size and tree depth. Honestly scoped: documented as advisory without cgroup v2 delegation rather than pretending to enforce. |
| Supply chain (image verification) | 8/10 | Real signature verification against pinned fingerprints for two of three catalog sources, sha256 for all three, URL inputs refused without a hash. Loses points for the hand-rolled parsers (2.3) and for Debian having no publisher signature (disclosed, not doos's fault, but still the weakest link). |
| Hardening (capabilities, seccomp) | 8/10 | Matches Docker's default capability mask (measured, not assumed), denylist seccomp with the known evasions (x32, clone3, nested userns) explicitly closed. A denylist is inherently weaker than an allowlist by design, which the project says itself in `COMPARISON.md`; grade reflects execution of the chosen design, not the design choice. |
| Testing & verification tooling | 8.5/10 | 88 end-to-end checks through the real path (real overlayfs, real downloads, real cgroups), plus `audit/` (18 named CVEs with working reproducers) and `valgrind/` (6 checks, suppressions individually justified) are well above what a project this size usually has. One machine, one architecture, no CI (2.5) is the honest ceiling. |
| Documentation | 9/10 | Seven cross-linked documents that state what exists today, with gaps listed next to the code that closes them; generated numbers and charts instead of hand-typed ones; a changelog that actually matches releases. Among the best-documented small C++ projects I have reviewed. |
| Build & release automation | 8.5/10 | `scripts/release.sh` bumps, builds, tests, checks docs, commits and tags in one gated command and aborts on any failure; hardened compiler/linker flags. Loses half a point for no CI to run any of it automatically. |
| CLI/UX | 7.5/10 | Clear, narrow-terminal-friendly usage text; sensible fixed-vs-changeable option model; good error messages that say what to do next. `--i-understand` as a deliberate confirmation gate for reduced sandboxing is a good pattern. |
| Project hygiene | 6/10 | No LICENSE, no CI (2.5); otherwise clean (`.gitignore` is thorough, no stray build artifacts tracked, commit messages are consistent and attributed). |
**Overall: 8/10.** A small, honest, carefully built tool with one real bug found by testing it rather
than reading it, and a short, specific list of things worth fixing. Nothing found here changes the
conclusion already in `COMPARISON.md`: fine for your own tools, not a substitute for an audited,
multi-tenant-hardened runtime.
## 4. Recommended order of fixes
1. Fix the `parseSize` overflow (2.1): cheapest fix, only confirmed bug, trivial to test.
2. Add a one-line comment above `applyLimitsAndEnv`'s two call sites in `sandbox.cpp` stating the
ordering invariant from 2.4, so the next edit does not have to rediscover it the hard way.
3. Decide and document the helper-resolution split (2.2): either move the four pinned paths through
`resolveBinary()`, or add one line to `SPEC.md` saying why they are intentionally narrower.
4. Add a LICENSE file (2.5); pick whichever the user already uses elsewhere.
5. CI is the only item here that is genuinely more than a few lines of work (namespaces, cgroup v2
delegation and AppArmor all need to exist on the runner); worth a separate, explicit decision
rather than a drive-by addition.
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) ·
[RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) ·
[CHANGELOG](CHANGELOG.md) · [audit](audit/README.md) · [valgrind](valgrind/README.md) ·
[review.md](review.md) (earlier security-only review)
+3 -3
View File
@@ -4,7 +4,7 @@
set -u set -u
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md) DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md docs/CI.md)
bad=0 bad=0
fail() { echo "docs: $*"; bad=$((bad+1)); } fail() { echo "docs: $*"; bad=$((bad+1)); }
@@ -12,11 +12,11 @@ fail() { echo "docs: $*"; bad=$((bad+1)); }
while IFS= read -r f; do while IFS= read -r f; do
f=${f#./} f=${f#./}
printf '%s\n' "${DOCS[@]}" | grep -qxF "$f" || fail "$f is not in the DOCS list of scripts/check-docs.sh" printf '%s\n' "${DOCS[@]}" | grep -qxF "$f" || fail "$f is not in the DOCS list of scripts/check-docs.sh"
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md' -not -path './valgrind/*') done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md' -not -path './review2.md' -not -path './valgrind/*')
# prompt.md: the original task statement, not documentation. # prompt.md: the original task statement, not documentation.
# audit/*, valgrind/*: separate, self-contained doc sets (one README per CVE check, one for the # audit/*, valgrind/*: separate, self-contained doc sets (one README per CVE check, one for the
# valgrind setup); linked from the main docs but not forced into their full cross-link mesh. # valgrind setup); linked from the main docs but not forced into their full cross-link mesh.
# review.md: a standalone external security review, not part of the maintained doc set. # review.md, review2.md: standalone one-off reviews, not part of the maintained doc set.
for f in "${DOCS[@]}"; do for f in "${DOCS[@]}"; do
[ -f "$f" ] || { fail "$f is missing"; continue; } [ -f "$f" ] || { fail "$f is missing"; continue; }
+142 -1
View File
@@ -7,6 +7,7 @@
#include <optional> #include <optional>
#include "container.hpp" #include "container.hpp"
#include "davmount.hpp"
#include "sandbox.hpp" #include "sandbox.hpp"
#include "spec.hpp" #include "spec.hpp"
#include "util.hpp" #include "util.hpp"
@@ -18,6 +19,86 @@ namespace {
std::uint32_t parseCount(const std::string& text); std::uint32_t parseCount(const std::string& text);
std::uint32_t parseCpus(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<std::string> 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;
}
bool hasDavScheme(const std::string& s) {
for (const char* p : {"dav://", "davs://", "http://", "https://"}) {
if (s.rfind(p, 0) == 0) return true;
}
return false;
}
// "DST" or "DST:ro"/"DST:rw" (the URL itself is a separate token, so this never sees a scheme's own ':').
void applyDstSuffix(MountSpec& m, const std::string& distSpec) {
auto c = distSpec.find(':');
if (c == std::string::npos) { m.dst = distSpec; return; }
m.dst = distSpec.substr(0, c);
std::string suf = distSpec.substr(c + 1);
if (suf != "ro" && suf != "rw") {
throw std::runtime_error("mount destination suffix must be ':ro' or ':rw' (got '" + distSpec + "')");
}
m.readOnly = suf == "ro";
}
// URL contains ':' (scheme, maybe a port), so it cannot share a single colon-delimited token with
// DST the way the classic bind-mount form does: --mount/--davmount takes the URL and DST as two
// separate argv tokens instead. Credentials embedded as user[:pass]@host are pulled out of the URL
// (a password containing '@' will not round-trip through this; use --dav-user/--dav-pass instead).
MountSpec parseDavMountFlag(const std::string& url, const std::string& distSpec) {
MountSpec m;
m.kind = MountKind::WebDav;
m.davVendor = "other";
std::string rest;
if (url.rfind("dav://", 0) == 0) rest = "http://" + url.substr(6);
else if (url.rfind("davs://", 0) == 0) rest = "https://" + url.substr(7);
else rest = url; // http:// or https://, used as given
std::size_t schemeEnd = rest.find("://");
schemeEnd = schemeEnd == std::string::npos ? 0 : schemeEnd + 3;
std::size_t pathStart = rest.find('/', schemeEnd);
std::string authority = rest.substr(schemeEnd, pathStart == std::string::npos ? std::string::npos : pathStart - schemeEnd);
std::size_t at = authority.rfind('@');
if (at != std::string::npos) {
std::string userinfo = authority.substr(0, at), hostport = authority.substr(at + 1);
std::size_t colon = userinfo.find(':');
if (colon == std::string::npos) m.davUser = userinfo;
else { m.davUser = userinfo.substr(0, colon); m.davPass = userinfo.substr(colon + 1); }
rest = rest.substr(0, schemeEnd) + hostport + (pathStart == std::string::npos ? "" : rest.substr(pathStart));
}
m.src = rest;
applyDstSuffix(m, distSpec);
return m;
}
MountSpec& lastDavMount(Options& opts, const char* flag) {
if (!opts.mounts || opts.mounts->empty() || opts.mounts->back().kind != MountKind::WebDav) {
throw std::runtime_error(std::string(flag) + " must follow a --mount dav://... (or --davmount) entry");
}
return opts.mounts->back();
}
// Leading --options, then the first positional, then everything else untouched. // Leading --options, then the first positional, then everything else untouched.
struct Parsed { struct Parsed {
Options opts; Options opts;
@@ -59,6 +140,27 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed"); if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
p.opts.relaxed = (v == "relaxed"); p.opts.relaxed = (v == "relaxed");
} }
else if (allow.container && (is(a, "--mount") || is(a, "--davmount"))) {
bool explicitDav = is(a, "--davmount");
std::string flagName = explicitDav ? "--davmount" : "--mount";
std::string first = value(i, flagName);
if (!p.opts.mounts) p.opts.mounts = std::vector<MountSpec>{};
if (p.opts.mounts->size() >= spec::kMaxMounts) {
throw std::runtime_error("too many --mount/--davmount flags (max " + std::to_string(spec::kMaxMounts) + ")");
}
if (hasDavScheme(first)) {
if (i + 1 >= rest.size()) throw std::runtime_error(flagName + " needs a destination after the URL");
p.opts.mounts->push_back(parseDavMountFlag(first, rest[++i]));
} else if (explicitDav) {
throw std::runtime_error("--davmount needs a dav://, davs://, http:// or https:// URL (got '" + first + "')");
} else {
p.opts.mounts->push_back(parseMountFlag(first));
}
}
else if (allow.container && is(a, "--dav-user")) lastDavMount(p.opts, "--dav-user").davUser = value(i, "--dav-user");
else if (allow.container && is(a, "--dav-pass")) lastDavMount(p.opts, "--dav-pass").davPass = value(i, "--dav-pass");
else if (allow.container && is(a, "--dav-vendor")) lastDavMount(p.opts, "--dav-vendor").davVendor = value(i, "--dav-vendor");
else if (allow.container && a == "--dav-insecure") lastDavMount(p.opts, "--dav-insecure").davInsecure = true;
else if (allow.yes && a == "--yes") p.yes = true; else if (allow.yes && a == "--yes") p.yes = true;
else if (a == "--i-understand") p.iUnderstand = true; else if (a == "--i-understand") p.iUnderstand = true;
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256"); else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
@@ -129,6 +231,8 @@ void Cli::usage(std::FILE* out) {
" doos image list\n" " doos image list\n"
" doos image add [--sha256 HEX] SOURCE [NAME]\n" " doos image add [--sha256 HEX] SOURCE [NAME]\n"
" doos image delete [--yes] NAME\n" " doos image delete [--yes] NAME\n"
" doos davmount list\n"
" doos davmount umount [--yes] ID\n"
" doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n" " doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n"
"\n" "\n"
"options:\n" "options:\n"
@@ -141,10 +245,20 @@ void Cli::usage(std::FILE* out) {
" --memory SIZE memory cap, 64m..1t, default 8g\n" " --memory SIZE memory cap, 64m..1t, default 8g\n"
" --pids N process cap, 16..4194304, default 4096\n" " --pids N process cap, 16..4194304, default 4096\n"
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\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"
" --mount URL DST[:ro] URL starting dav://, davs://, http:// or https:// auto-mounts WebDAV via a\n"
" managed rclone (downloaded and verified once); same as --davmount\n"
" --davmount URL DST[:ro] explicit WebDAV mount: URL DST[:ro], credentials as user[:pass]@host or below\n"
" --dav-user USER WebDAV username for the --mount/--davmount entry just given\n"
" --dav-pass PASS WebDAV password for it (use this instead of embedding '@'/':' in the URL)\n"
" --dav-vendor NAME other|nextcloud|owncloud|sharepoint|sharepoint-ntlm, default other\n"
" --dav-insecure skip TLS certificate verification for that WebDAV target\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; a bind SRC must exist and be a directory when the container starts.\n"
"\n" "\n"
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\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" "attach creates NAME when missing. delete only reports unless --yes is given.\n"
"davmount umount stops storage for any container still using that target; see 'doos davmount list' for its ID.\n"
"State lives in $DOOS_HOME or ~/.local/share/doos.\n", "State lives in $DOOS_HOME or ~/.local/share/doos.\n",
out); out);
} }
@@ -163,6 +277,7 @@ int Cli::run() {
if (cmd == "attach") return cmdAttach(rest); if (cmd == "attach") return cmdAttach(rest);
if (cmd == "delete") return cmdDelete(rest); if (cmd == "delete") return cmdDelete(rest);
if (cmd == "image") return cmdImage(rest); if (cmd == "image") return cmdImage(rest);
if (cmd == "davmount") return cmdDavmount(rest);
if (cmd == "apparmor") return cmdApparmor(); if (cmd == "apparmor") return cmdApparmor();
std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str()); std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str());
usage(stderr); usage(stderr);
@@ -289,4 +404,30 @@ int Cli::cmdImageDelete(std::vector<std::string> rest) {
return 0; return 0;
} }
int Cli::cmdDavmount(std::vector<std::string> rest) {
if (rest.empty()) throw std::runtime_error("davmount needs a subcommand: list, umount");
std::string sub = rest[0];
std::vector<std::string> tail(rest.begin() + 1, rest.end());
if (sub == "list") return cmdDavmountList();
if (sub == "umount") return cmdDavmountUmount(tail);
throw std::runtime_error("unknown davmount subcommand '" + sub + "' (list, umount)");
}
int Cli::cmdDavmountList() {
auto all = DavMount::list();
if (all.empty()) { std::puts("no davmounts"); return 0; }
std::printf("%-*s %-7s %-10s %s\n", 16, "ID", "STATE", "VENDOR", "URL");
for (const auto& s : all) {
std::printf("%-*s %-7s %-10s %s\n", 16, s.id.c_str(), s.mounted ? "mounted" : "stale", s.vendor.c_str(), s.url.c_str());
}
return 0;
}
int Cli::cmdDavmountUmount(std::vector<std::string> rest) {
Parsed p = parse(rest, {false, true, false}, 1);
if (!p.tail.empty()) throw std::runtime_error("davmount umount takes only an ID");
std::puts(DavMount::umount(p.positional[0], p.yes).c_str());
return 0;
}
} // namespace doos } // namespace doos
+3
View File
@@ -20,6 +20,9 @@ private:
int cmdImageList(); int cmdImageList();
int cmdImageAdd(std::vector<std::string> rest); int cmdImageAdd(std::vector<std::string> rest);
int cmdImageDelete(std::vector<std::string> rest); int cmdImageDelete(std::vector<std::string> rest);
int cmdDavmount(std::vector<std::string> rest);
int cmdDavmountList();
int cmdDavmountUmount(std::vector<std::string> rest);
static void usage(std::FILE* out); static void usage(std::FILE* out);
std::vector<std::string> args_; std::vector<std::string> args_;
+114 -1
View File
@@ -26,6 +26,100 @@ std::string slug(std::string_view hostDir) {
return s; return s;
} }
bool noNewline(const std::string& s) { return s.find('\n') == std::string::npos; }
void validateMount(const MountSpec& m) {
if (m.dst.empty() || m.dst[0] != '/' || m.dst == "/" || !noNewline(m.dst) || m.dst.find(':') != std::string::npos) {
throw std::runtime_error("--mount destination must be an absolute path, not '/', without ':' or a newline: '" + m.dst + "'");
}
if (m.kind == MountKind::Bind) {
if (m.src.empty() || m.src[0] != '/' || !noNewline(m.src) || m.src.find(':') != std::string::npos) {
throw std::runtime_error("--mount source must be an absolute path without ':' or a newline: '" + m.src + "'");
}
return;
}
bool https = m.src.rfind("https://", 0) == 0, http = m.src.rfind("http://", 0) == 0;
if (m.src.empty() || (!https && !http) || !noNewline(m.src)) {
throw std::runtime_error("davmount source must be an http:// or https:// URL: '" + m.src + "'");
}
if (!noNewline(m.davUser) || !noNewline(m.davPass) || !noNewline(m.davVendor)) {
throw std::runtime_error("davmount user/pass/vendor must not contain a newline");
}
bool knownVendor = false;
for (auto v : spec::kDavVendors) knownVendor = knownVendor || v == m.davVendor;
if (!knownVendor) {
std::string known;
for (auto v : spec::kDavVendors) known += (known.empty() ? "" : ", ") + std::string(v);
throw std::runtime_error("unknown --dav-vendor '" + m.davVendor + "' (known: " + known + ")");
}
}
// Current format: one key per field, indexed, so credentials may contain any character but a
// newline (unlike the legacy single-line format below). `mounts.count=N` then, per i: `.kind`,
// `.src`, `.dst`, `.ro`, and for webdav also `.user`, `.pass`, `.vendor`, `.insecure`.
std::string serializeMounts(const std::vector<MountSpec>& mounts) {
std::string out = "mounts.count=" + std::to_string(mounts.size()) + "\n";
for (std::size_t i = 0; i < mounts.size(); ++i) {
const MountSpec& m = mounts[i];
std::string p = "mounts." + std::to_string(i) + ".";
out += p + "kind=" + (m.kind == MountKind::WebDav ? "webdav" : "bind") + "\n";
out += p + "src=" + m.src + "\n";
out += p + "dst=" + m.dst + "\n";
out += p + "ro=" + (m.readOnly ? "1" : "0") + "\n";
if (m.kind == MountKind::WebDav) {
out += p + "user=" + m.davUser + "\n";
out += p + "pass=" + m.davPass + "\n";
out += p + "vendor=" + m.davVendor + "\n";
out += p + "insecure=" + (m.davInsecure ? "1" : "0") + "\n";
}
}
return out;
}
// Legacy format (1.5.0): a single "mounts=" line, entries separated by ';', fields by ':'
// ("src:dst:ro"), bind mounts only. Still read so a container created under that release loads.
std::vector<MountSpec> parseLegacyMounts(const std::string& text) {
std::vector<MountSpec> 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::vector<MountSpec> parseMounts(const std::map<std::string, std::string>& kv) {
if (!kv.count("mounts.count")) return kv.count("mounts") ? parseLegacyMounts(kv.at("mounts")) : std::vector<MountSpec>{};
std::size_t n = std::stoul(kv.at("mounts.count"));
if (n > spec::kMaxMounts) throw std::runtime_error("corrupt meta: too many mounts");
std::vector<MountSpec> out;
for (std::size_t i = 0; i < n; ++i) {
std::string p = "mounts." + std::to_string(i) + ".";
auto get = [&](const char* field, const std::string& def = "") {
auto it = kv.find(p + field);
return it == kv.end() ? def : it->second;
};
MountSpec m;
m.kind = get("kind") == "webdav" ? MountKind::WebDav : MountKind::Bind;
m.src = get("src");
m.dst = get("dst");
m.readOnly = get("ro") == "1";
m.davUser = get("user");
m.davPass = get("pass");
m.davVendor = get("vendor", "other");
m.davInsecure = get("insecure") == "1";
out.push_back(std::move(m));
}
return out;
}
std::map<std::string, std::string> parseKv(const std::string& text) { std::map<std::string, std::string> parseKv(const std::string& text) {
std::map<std::string, std::string> kv; std::map<std::string, std::string> kv;
std::istringstream in(text); std::istringstream in(text);
@@ -240,7 +334,8 @@ void Container::writeMeta() const {
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) + "\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) + "\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) + "\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n"); "\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n" +
serializeMounts(mounts_));
} }
void Container::setSizeCap(std::uint64_t bytes) { void Container::setSizeCap(std::uint64_t bytes) {
@@ -252,6 +347,15 @@ void Container::setSizeCap(std::uint64_t bytes) {
writeMeta(); writeMeta();
} }
void Container::setMounts(const std::vector<MountSpec>& 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<std::uint64_t> memory, std::optional<std::uint32_t> pids, void Container::setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids,
std::optional<std::uint32_t> milliCpus) { std::optional<std::uint32_t> milliCpus) {
if (memory) { if (memory) {
@@ -287,6 +391,13 @@ void Container::create(const NetMode& net, const std::string& image, IdMode ids,
pids_ = spec::kDefaultPids; pids_ = spec::kDefaultPids;
milliCpus_ = 0; milliCpus_ = 0;
if (o.memory || o.pids || o.milliCpus) setLimits(o.memory, o.pids, o.milliCpus); 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); created_ = std::time(nullptr);
fsutil::mkdirs(dir_); fsutil::mkdirs(dir_);
prepareLayout(); prepareLayout();
@@ -309,6 +420,7 @@ void Container::readMeta() {
pids_ = kv.count("pids") ? static_cast<std::uint32_t>(std::stoul(kv["pids"])) : spec::kDefaultPids; pids_ = kv.count("pids") ? static_cast<std::uint32_t>(std::stoul(kv["pids"])) : spec::kDefaultPids;
milliCpus_ = kv.count("cpus") ? static_cast<std::uint32_t>(std::stoul(kv["cpus"])) : 0; milliCpus_ = kv.count("cpus") ? static_cast<std::uint32_t>(std::stoul(kv["cpus"])) : 0;
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed"; relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
mounts_ = parseMounts(kv);
} }
std::optional<pid_t> Container::runningPid() const { std::optional<pid_t> Container::runningPid() const {
@@ -399,6 +511,7 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
if (opts.relaxed) fixed("hardening", c.relaxed() ? "relaxed" : "default", *opts.relaxed ? "relaxed" : "default"); 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.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.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)) { if (fs::exists(dir / "meta", ec)) {
+29
View File
@@ -15,6 +15,26 @@
namespace doos { namespace doos {
// One --mount entry: 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, not the container's own
// writable layer.
// Bind: --mount SRC:DST[:ro] SRC is an existing host directory.
// WebDav: --mount URL DST[:ro] --davmount URL DST[:ro] (URL: dav://, davs://, http://
// or https://). `src` holds the http(s) URL rclone mounts on demand (see davmount.hpp);
// doos never speaks WebDAV itself, it only drives a managed rclone binary and then
// bind-mounts the result exactly like a Bind entry.
enum class MountKind { Bind, WebDav };
struct MountSpec {
MountKind kind = MountKind::Bind;
std::string src; // Bind: host path. WebDav: the http(s) URL (credentials never embedded here).
std::string dst; // absolute path inside the container
bool readOnly = false;
std::string davUser; // WebDav only, optional
std::string davPass; // WebDav only, optional, plaintext in meta (see SPEC.md); obscured just before rclone runs
std::string davVendor; // WebDav only, one of spec::kDavVendors, default "other"
bool davInsecure = false; // WebDav only: skip TLS certificate verification
};
// What the user asked for. Unset fields mean "default on create, no constraint on existing". // What the user asked for. Unset fields mean "default on create, no constraint on existing".
struct Options { struct Options {
std::optional<NetMode> net; std::optional<NetMode> net;
@@ -25,6 +45,7 @@ struct Options {
std::optional<std::uint32_t> pids; std::optional<std::uint32_t> pids;
std::optional<std::uint32_t> milliCpus; // 0 = all cores std::optional<std::uint32_t> milliCpus; // 0 = all cores
std::optional<bool> relaxed; // --hardening relaxed std::optional<bool> relaxed; // --hardening relaxed
std::optional<std::vector<MountSpec>> mounts; // --mount, replaces the whole list when given
}; };
@@ -56,8 +77,15 @@ public:
std::uint32_t pidsCap() const { return pids_; } std::uint32_t pidsCap() const { return pids_; }
std::uint32_t milliCpus() const { return milliCpus_; } std::uint32_t milliCpus() const { return milliCpus_; }
bool relaxed() const { return relaxed_; } bool relaxed() const { return relaxed_; }
const std::vector<MountSpec>& mounts() const { return mounts_; }
// Limits take effect the next time the container starts. // Limits take effect the next time the container starts.
void setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids, std::optional<std::uint32_t> milliCpus); void setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids, std::optional<std::uint32_t> milliCpus);
// Replaces the whole mount list; takes effect the next time the container starts.
void setMounts(const std::vector<MountSpec>& mounts);
// Runtime-only: swaps in already-resolved Bind entries (WebDav resolved to its managed mount
// directory) right before the container starts. Never persisted; the configured list (with the
// original URL) is untouched on disk. Used only by Sandbox::start.
void primeRuntimeMounts(std::vector<MountSpec> resolved) { mounts_ = std::move(resolved); }
void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved); void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved);
void readMeta(); // meta only, no side effects void readMeta(); // meta only, no side effects
@@ -89,6 +117,7 @@ private:
std::uint32_t pids_ = 0; std::uint32_t pids_ = 0;
std::uint32_t milliCpus_ = 0; std::uint32_t milliCpus_ = 0;
bool relaxed_ = false; bool relaxed_ = false;
std::vector<MountSpec> mounts_;
}; };
class Store { class Store {
+413
View File
@@ -0,0 +1,413 @@
#include "davmount.hpp"
#include <dirent.h>
#include <fcntl.h>
#include <poll.h>
#include <sys/stat.h>
#include <sys/utsname.h>
#include <sys/wait.h>
#include <unistd.h>
#include <algorithm>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <map>
#include <sstream>
#include "spec.hpp"
#include "util.hpp"
namespace fs = std::filesystem;
namespace doos {
namespace {
fs::path stateHome() {
if (const char* h = std::getenv("DOOS_HOME"); h && *h) return h;
if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) return fs::path(x) / "doos";
if (const char* home = std::getenv("HOME"); home && *home) return fs::path(home) / ".local/share/doos";
throw std::runtime_error("HOME is not set; set DOOS_HOME");
}
fs::path davRoot() { return stateHome() / "davmounts"; }
std::string hostArch() {
utsname u;
if (::uname(&u) != 0) throw SysError("uname");
std::string m = u.machine;
if (m == "x86_64") return "amd64";
if (m == "aarch64") return "arm64";
throw std::runtime_error("no rclone release for architecture " + m + "; davmount needs x86_64 or aarch64");
}
std::string trimmed(std::string s) {
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back();
return s;
}
// Stable short id for a WebDAV target (url + credentials + vendor + insecure flag), not including
// the container-specific destination: the same target reuses one mount across containers.
std::string mountId(const MountSpec& m) {
std::string key = m.src + "\x01" + m.davUser + "\x01" + m.davPass + "\x01" + m.davVendor + "\x01" +
(m.davInsecure ? "1" : "0");
std::uint64_t h = 1469598103934665603ULL; // FNV-1a 64-bit offset basis
for (unsigned char c : key) {
h ^= c;
h *= 1099511628211ULL;
}
char buf[17];
std::snprintf(buf, sizeof buf, "%016llx", static_cast<unsigned long long>(h));
return buf;
}
// rclone's on-the-fly connection-string syntax quotes a value in "..." with internal quotes
// doubled; always quoting is safe for every field we pass (url, vendor, user, pass).
std::string quoteConnVal(const std::string& v) {
std::string out = "\"";
for (char c : v) {
if (c == '"') out += "\"\"";
else out += c;
}
out += "\"";
return out;
}
std::string buildConnString(const MountSpec& m, const std::string& obscuredPass) {
std::string vendor = m.davVendor.empty() ? "other" : m.davVendor;
std::string conn = ":webdav,url=" + quoteConnVal(m.src) + ",vendor=" + quoteConnVal(vendor);
if (!m.davUser.empty()) conn += ",user=" + quoteConnVal(m.davUser);
if (!obscuredPass.empty()) conn += ",pass=" + quoteConnVal(obscuredPass);
if (m.davInsecure) conn += ",no_check_certificate=" + quoteConnVal("true");
conn += ":";
return conn;
}
// Mount point is always the 5th whitespace-separated field of a mountinfo line; the filesystem
// type is the token right after the first "-" separator (the optional-fields region before it has
// a variable length). Same technique as the CVE-2019-16884 audit check.
bool mountInfoHasReadyFuse(const fs::path& mnt) {
std::error_code ec;
std::string want = fs::exists(mnt, ec) ? fs::canonical(mnt, ec).string() : mnt.string();
std::ifstream in("/proc/self/mountinfo");
for (std::string line; std::getline(in, line);) {
std::istringstream ls(line);
std::vector<std::string> tok;
for (std::string t; ls >> t;) tok.push_back(t);
if (tok.size() < 5) continue;
if (tok[4] != want) continue;
auto it = std::find(tok.begin(), tok.end(), "-");
if (it == tok.end() || it + 1 == tok.end()) continue;
if (it[1].rfind("fuse", 0) == 0) return true;
}
return false;
}
// Root inode stat() always succeeds under FUSE without touching the backend; only a real listing
// forces the PROPFIND round trip and surfaces auth/connectivity failures as EIO.
bool probeReadable(const fs::path& mnt) {
DIR* d = ::opendir(mnt.c_str());
if (!d) return false;
errno = 0;
(void)::readdir(d); // only errno matters here: did the backend round trip succeed
bool ok = (errno == 0);
::closedir(d);
return ok;
}
void unmountQuiet(const fs::path& mnt) {
std::string bin;
try {
bin = resolveBinary("fusermount3");
} catch (const std::exception&) {
try {
bin = resolveBinary("fusermount");
} catch (const std::exception&) {
return;
}
}
runProcess({bin, "-u", mnt.string()}, false, true);
if (mountInfoHasReadyFuse(mnt)) runProcess({bin, "-uz", mnt.string()}, false, true);
}
std::string tailOfLog(const fs::path& log) {
std::error_code ec;
if (!fs::exists(log, ec)) return "";
try {
std::string content = fsutil::readFile(log);
if (content.size() > 2000) content = content.substr(content.size() - 2000);
return content;
} catch (const std::exception&) {
return "";
}
}
std::string classifyFailure(const std::string& log) {
auto has = [&](const char* s) { return log.find(s) != std::string::npos; };
if (has("401") || has("Unauthorized")) return "authentication failed (check --dav-user/--dav-pass)";
if (has("x509") || has("certificate")) return "TLS certificate error (pass --dav-insecure for a trusted self-signed server)";
if (has("Unsupported method") || has("501")) return "the server does not look like a WebDAV endpoint (check the URL and --dav-vendor)";
if (has("no such host") || has("dial tcp") || has("connection refused") || has("i/o timeout") || has("Temporary failure")) {
return "cannot reach the server (check the URL and network)";
}
return log.empty() ? "mount did not become ready in time" : "see the log for details";
}
std::map<std::string, std::string> readKv(const fs::path& p) {
std::map<std::string, std::string> kv;
std::error_code ec;
if (!fs::exists(p, ec)) return kv;
try {
std::istringstream in(fsutil::readFile(p));
for (std::string line; std::getline(in, line);) {
auto eq = line.find('=');
if (eq != std::string::npos) kv[line.substr(0, eq)] = line.substr(eq + 1);
}
} catch (const std::exception&) {
}
return kv;
}
// Downloads, verifies and installs rclone under the state dir on first use; a fast version check
// short-circuits every call after that. Never resolved via PATH or a system package: doos controls
// exactly which rclone build it drives.
std::string ensureRclone() {
std::string ver(spec::kRcloneVersion);
fs::path dir = stateHome() / "tools" / ("rclone-" + ver);
fs::path bin = dir / "rclone";
auto versionOk = [&]() -> bool {
struct stat st;
if (::stat(bin.c_str(), &st) != 0 || !S_ISREG(st.st_mode) || ::access(bin.c_str(), X_OK) != 0) return false;
ProcResult v = runProcess({bin.string(), "version"}, true, true);
return v.code == 0 && v.out.rfind("rclone " + ver, 0) == 0;
};
if (versionOk()) return bin.string();
fsutil::mkdirs(dir.parent_path(), 0700);
FileLock lock(dir.parent_path() / ".install.lock", 120000);
if (versionOk()) return bin.string(); // another process installed it while we waited for the lock
std::string arch = hostArch();
std::string base = std::string(spec::kRcloneBaseUrl) + ver + "/";
std::string file = "rclone-" + ver + "-linux-" + arch + ".zip";
TempDir tmp;
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
verifyClearsignedSignature(tmp.path() / "SHA256SUMS", std::string(spec::kRcloneKeyUrl), std::string(spec::kRcloneKeyFpr));
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
std::string wantSha;
{
std::istringstream in(sums);
for (std::string line; std::getline(in, line);) {
auto sp = line.find(" ");
if (sp == std::string::npos) continue;
std::string sha = line.substr(0, sp), fn = line.substr(sp + 2);
if (fn == file && isHex64(sha)) { wantSha = sha; break; }
}
}
if (wantSha.empty()) {
throw std::runtime_error("rclone " + ver + " publishes no checksum for " + file + " (unsupported architecture?)");
}
fetchFile(base + file, tmp.path() / "rclone.zip", spec::kMaxRcloneDownloadBytes);
ProcResult h = runProcess({resolveBinary("sha256sum"), (tmp.path() / "rclone.zip").string()}, true);
std::string got = h.out.substr(0, 64);
if (h.code != 0 || got != wantSha) {
throw std::runtime_error("sha256 mismatch for rclone " + ver + ": expected " + wantSha + ", got " + got);
}
fs::path extractDir = tmp.path() / "extract";
fsutil::mkdirs(extractDir);
std::string unzip;
try {
unzip = resolveBinary("unzip");
} catch (const std::exception&) {
throw std::runtime_error("davmount needs 'unzip' installed to unpack rclone's release archive");
}
ProcResult u = runProcess({unzip, "-q", "-o", (tmp.path() / "rclone.zip").string(), "-d", extractDir.string()});
if (u.code != 0) throw std::runtime_error("unzip failed for rclone " + ver + " (exit " + std::to_string(u.code) + ")");
fs::path extracted = extractDir / ("rclone-" + ver + "-linux-" + arch) / "rclone";
if (!fs::is_regular_file(extracted)) {
throw std::runtime_error("unexpected rclone archive layout: " + extracted.string() + " not found");
}
fs::path tmpDest = dir.parent_path() / (".tmp-rclone-" + ver);
std::error_code ec;
fs::remove_all(tmpDest, ec);
fsutil::mkdirs(tmpDest, 0755);
fs::copy_file(extracted, tmpDest / "rclone", fs::copy_options::overwrite_existing);
::chmod((tmpDest / "rclone").c_str(), 0755);
ProcResult check = runProcess({(tmpDest / "rclone").string(), "version"}, true, true);
if (check.code != 0 || check.out.rfind("rclone " + ver, 0) != 0) {
fs::remove_all(tmpDest, ec);
throw std::runtime_error("freshly installed rclone did not report version " + ver);
}
fs::remove_all(dir, ec);
if (::rename(tmpDest.c_str(), dir.c_str()) != 0) throw SysError("install rclone into " + dir.string());
std::fprintf(stderr, "doos: installed rclone %s into %s (for --mount dav://... / --davmount)\n", ver.c_str(), bin.string().c_str());
return bin.string();
}
// Double-fork daemonize: the grandchild (the real rclone mount process) is reparented to init and
// outlives this doos invocation, which is the point of a davmount (a persistent host-level mount,
// not tied to one container's lifetime). The intermediate child is reaped immediately.
pid_t spawnRcloneMount(const std::string& rclone, const std::string& conn, const fs::path& mnt, const fs::path& log) {
int pipefd[2];
if (::pipe2(pipefd, O_CLOEXEC) != 0) throw SysError("pipe");
Fd readEnd(pipefd[0]), writeEnd(pipefd[1]);
pid_t first = ::fork();
if (first < 0) throw SysError("fork");
if (first == 0) {
pid_t second = ::fork();
if (second == 0) {
::setsid();
int nul = ::open("/dev/null", O_RDONLY);
if (nul >= 0) ::dup2(nul, 0);
int lf = ::open(log.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0600);
if (lf >= 0) { ::dup2(lf, 1); ::dup2(lf, 2); }
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
// vfs-cache-mode=writes: WebDAV cannot stream, rclone's own docs recommend this. Its
// default 5s write-back is shortened to 1s so a write is not left unsent for long if
// the container that made it exits immediately after (the mount itself, and its
// pending uploads, run detached from any container and outlive it either way).
::execl(rclone.c_str(), rclone.c_str(), "mount", conn.c_str(), mnt.c_str(), "--allow-non-empty",
"--vfs-cache-mode=writes", "--vfs-write-back=1s", "--log-level=INFO", static_cast<char*>(nullptr));
::_exit(127);
}
if (second > 0) ::dprintf(writeEnd.get(), "%d\n", static_cast<int>(second));
::_exit(0);
}
writeEnd.reset();
int status = 0;
::waitpid(first, &status, 0); // reap the intermediate immediately; the daemon lives on under init
char buf[32] = {0};
ssize_t n = ::read(readEnd.get(), buf, sizeof buf - 1);
if (n <= 0) throw std::runtime_error("davmount: could not start rclone mount (no pid reported)");
return static_cast<pid_t>(std::atoi(buf));
}
fs::path ensureMounted(const MountSpec& m) {
std::string id = mountId(m);
fs::path dir = davRoot() / id;
fsutil::mkdirs(dir, 0700);
fs::path mnt = dir / "mnt";
fsutil::mkdirs(mnt, 0700);
FileLock lock(dir / "lock", spec::kDavMountReadyTimeoutMs + spec::kLockTimeoutMs);
auto readPid = [&]() -> pid_t {
std::error_code ec;
if (!fs::exists(dir / "pid", ec)) return -1;
try {
return static_cast<pid_t>(std::stol(trimmed(fsutil::readFile(dir / "pid"))));
} catch (const std::exception&) {
return -1;
}
};
if (mountInfoHasReadyFuse(mnt)) {
pid_t pid = readPid();
if (pid > 0 && ::kill(pid, 0) == 0 && probeReadable(mnt)) return mnt; // healthy, reuse as is
unmountQuiet(mnt); // stale: process dead or backend unreachable
}
std::string rclone = ensureRclone();
std::string obscured;
if (!m.davPass.empty()) {
ProcResult r = runProcess({rclone, "obscure", m.davPass}, true, true);
if (r.code != 0) throw std::runtime_error("rclone could not obscure the davmount password");
obscured = trimmed(r.out);
}
std::string conn = buildConnString(m, obscured);
fs::path log = dir / "log";
pid_t daemon = spawnRcloneMount(rclone, conn, mnt, log);
using Clock = std::chrono::steady_clock;
auto deadline = Clock::now() + std::chrono::milliseconds(spec::kDavMountReadyTimeoutMs);
bool ready = false;
std::string earlyFail;
while (Clock::now() < deadline) {
if (::kill(daemon, 0) != 0) { earlyFail = tailOfLog(log); break; }
if (mountInfoHasReadyFuse(mnt) && probeReadable(mnt)) { ready = true; break; }
::poll(nullptr, 0, spec::kDavMountPollIntervalMs);
}
if (!ready) {
if (::kill(daemon, 0) == 0) { ::kill(daemon, SIGTERM); ::poll(nullptr, 0, 200); }
unmountQuiet(mnt);
std::string reason = classifyFailure(earlyFail.empty() ? tailOfLog(log) : earlyFail);
throw std::runtime_error("davmount failed for " + m.src + ": " + reason + " (log: " + log.string() + ")");
}
fsutil::writeFile(dir / "pid", std::to_string(daemon) + "\n");
fsutil::writeFile(dir / "meta", "url=" + m.src + "\nvendor=" + (m.davVendor.empty() ? "other" : m.davVendor) +
"\nuser=" + m.davUser + "\ninsecure=" + (m.davInsecure ? "1" : "0") + "\n");
return mnt;
}
} // namespace
std::vector<MountSpec> DavMount::resolveAll(const std::vector<MountSpec>& mounts) {
std::vector<MountSpec> out;
for (const auto& m : mounts) {
if (m.kind == MountKind::Bind) {
out.push_back(m);
continue;
}
fs::path mnt = ensureMounted(m);
MountSpec resolved;
resolved.kind = MountKind::Bind;
resolved.src = mnt.string();
resolved.dst = m.dst;
resolved.readOnly = m.readOnly;
out.push_back(resolved);
}
return out;
}
std::vector<DavMount::Status> DavMount::list() {
std::vector<Status> out;
std::error_code ec;
fs::path root = davRoot();
if (!fs::exists(root, ec)) return out;
for (const auto& e : fs::directory_iterator(root, ec)) {
if (ec || !e.is_directory()) continue;
std::string id = e.path().filename().string();
if (id.rfind(".", 0) == 0) continue;
auto kv = readKv(e.path() / "meta");
Status s;
s.id = id;
s.url = kv.count("url") ? kv["url"] : "?";
s.vendor = kv.count("vendor") ? kv["vendor"] : "other";
s.mountpoint = (e.path() / "mnt").string();
s.mounted = mountInfoHasReadyFuse(e.path() / "mnt");
out.push_back(std::move(s));
if (out.size() >= spec::kMaxDavTargets) break;
}
std::sort(out.begin(), out.end(), [](const Status& a, const Status& b) { return a.id < b.id; });
return out;
}
std::string DavMount::umount(const std::string& id, bool confirmed) {
fs::path root = davRoot();
fs::path dir = root / id;
std::error_code ec;
if (!fs::is_directory(dir, ec)) throw std::runtime_error("no such davmount '" + id + "'");
if (fs::canonical(dir, ec) != fs::canonical(root, ec) / id) {
throw std::runtime_error("refusing to act outside the davmounts store: " + dir.string());
}
if (!confirmed) {
return "dry run: would unmount and remove " + dir.string() + " (stops storage for any container still using it). Add --yes to confirm.";
}
FileLock lock(dir / "lock", spec::kLockTimeoutMs);
unmountQuiet(dir / "mnt");
fsutil::removeTree(dir);
return "unmounted " + id;
}
} // namespace doos
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <string>
#include <vector>
#include "container.hpp"
namespace doos {
// Turns a --mount/--davmount WebDAV entry into a ready, already-mounted host directory, then hands
// back a plain Bind entry pointing at it; everything downstream (Sandbox, mountExtra) treats it
// exactly like an ordinary --mount SRC:DST bind, unaware that rclone is involved at all.
//
// doos never speaks WebDAV itself: it drives a managed rclone binary, downloaded once, verified
// against a pinned signing key and installed under the state dir (never the system or the
// caller's PATH), the same trust model as Alpine/Ubuntu images. Mounts are deduplicated by target
// (url + credentials + vendor + insecure flag) and reused across containers and attaches: the FUSE
// mount lives on the host, outside any container's namespaces, and outlives the doos process that
// created it.
class DavMount {
public:
// Resolves every WebDav entry in `mounts`; Bind entries pass through unchanged. Installs rclone
// and mounts on demand; reuses an already-healthy mount. Throws a specific, human-readable
// reason (auth, connectivity, TLS, protocol) on failure, never a bare "mount failed".
static std::vector<MountSpec> resolveAll(const std::vector<MountSpec>& mounts);
struct Status {
std::string id; // directory name under $DOOS_HOME/davmounts, stable per target
std::string url;
std::string vendor;
std::string mountpoint;
bool mounted = false;
};
// `doos davmount list`.
static std::vector<Status> list();
// `doos davmount umount [--yes] ID`. Dry-run unless confirmed, like `delete`/`image delete`.
static std::string umount(const std::string& id, bool confirmed);
};
} // namespace doos
+16 -5
View File
@@ -20,6 +20,16 @@ namespace doos {
namespace { namespace {
// Same trusted-path resolution as every other external helper (resolveBinary in util.cpp): the
// state dir's bin/ override, then a fixed list of system directories, never the caller's PATH.
std::string findBinary(const char* name) {
try {
return resolveBinary(name);
} catch (const std::exception&) {
return {};
}
}
void writeProcFile(pid_t pid, const char* file, const std::string& content) { void writeProcFile(pid_t pid, const char* file, const std::string& content) {
std::string path = "/proc/" + std::to_string(pid) + "/" + file; std::string path = "/proc/" + std::to_string(pid) + "/" + file;
int fd = ::open(path.c_str(), O_WRONLY | O_CLOEXEC); int fd = ::open(path.c_str(), O_WRONLY | O_CLOEXEC);
@@ -61,8 +71,8 @@ std::optional<SubRange> IdMap::range(const char* file) {
} }
bool IdMap::subuidAvailable() { bool IdMap::subuidAvailable() {
return range("/etc/subuid") && range("/etc/subgid") && ::access(std::string(spec::kNewUidMap).c_str(), X_OK) == 0 && return range("/etc/subuid") && range("/etc/subgid") && !findBinary("newuidmap").empty() &&
::access(std::string(spec::kNewGidMap).c_str(), X_OK) == 0; !findBinary("newgidmap").empty();
} }
IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; } IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; }
@@ -77,15 +87,16 @@ void IdMap::apply(pid_t pid, IdMode mode) {
} }
auto u = range("/etc/subuid"); auto u = range("/etc/subuid");
auto g = range("/etc/subgid"); auto g = range("/etc/subgid");
if (!u || !g || ::access(std::string(spec::kNewUidMap).c_str(), X_OK) != 0 || ::access(std::string(spec::kNewGidMap).c_str(), X_OK) != 0) { std::string newuidmap = findBinary("newuidmap"), newgidmap = findBinary("newgidmap");
if (!u || !g || newuidmap.empty() || newgidmap.empty()) {
throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap " throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap "
"(install uidmap, or use --ids single)"); "(install uidmap, or use --ids single)");
} }
auto n = [](const SubRange& r) { return std::to_string(std::min<unsigned long>(r.count, spec::kMaxIdRange - 1)); }; auto n = [](const SubRange& r) { return std::to_string(std::min<unsigned long>(r.count, spec::kMaxIdRange - 1)); };
std::string p = std::to_string(pid); std::string p = std::to_string(pid);
ProcResult a = runProcess({std::string(spec::kNewUidMap), p, "0", uid, "1", "1", std::to_string(u->start), n(*u)}); ProcResult a = runProcess({newuidmap, p, "0", uid, "1", "1", std::to_string(u->start), n(*u)});
if (a.code != 0) throw std::runtime_error("newuidmap failed (exit " + std::to_string(a.code) + ")"); if (a.code != 0) throw std::runtime_error("newuidmap failed (exit " + std::to_string(a.code) + ")");
ProcResult b = runProcess({std::string(spec::kNewGidMap), p, "0", gid, "1", "1", std::to_string(g->start), n(*g)}); ProcResult b = runProcess({newgidmap, p, "0", gid, "1", "1", std::to_string(g->start), n(*g)});
if (b.code != 0) throw std::runtime_error("newgidmap failed (exit " + std::to_string(b.code) + ")"); if (b.code != 0) throw std::runtime_error("newgidmap failed (exit " + std::to_string(b.code) + ")");
} }
+2 -102
View File
@@ -30,106 +30,6 @@ std::string cpuArch(bool debianStyle) {
throw std::runtime_error("no catalog images for architecture " + m + "; use a file or URL with --sha256"); throw std::runtime_error("no catalog images for architecture " + m + "; use a file or URL with --sha256");
} }
std::string fetchText(const std::string& url) {
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
"--proto", "=https", "--max-time", "60", url},
true);
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
return r.out;
}
void fetchFile(const std::string& url, const fs::path& out) {
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
"--proto", "=https", "--max-time", "60", "--max-filesize",
std::to_string(spec::kMaxCaptureBytes), "--output", out.string(), url});
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
}
// Private scratch directory, removed on scope exit.
class TempDir {
public:
TempDir() {
fs::path base;
if (const char* h = std::getenv("DOOS_HOME"); h && *h) base = h;
else if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) base = fs::path(x) / "doos";
else if (const char* home = std::getenv("HOME"); home && *home) base = fs::path(home) / ".local/share/doos";
else base = fs::temp_directory_path();
fs::path scratch = base / "tmp";
std::error_code ec;
fs::create_directories(scratch, ec);
if (ec) scratch = fs::temp_directory_path();
std::string tmpl = (scratch / "doos-XXXXXX").string();
if (!::mkdtemp(tmpl.data())) throw SysError("mkdtemp");
path_ = tmpl;
::chmod(path_.c_str(), 0700);
}
~TempDir() {
std::error_code ec;
fs::remove_all(path_, ec);
}
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
const fs::path& path() const { return path_; }
private:
fs::path path_;
};
std::string upper(std::string s) {
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return s;
}
// Verifies a detached signature against a publisher key that is fetched over https and accepted only when its
// fingerprint equals the pinned one. Returns false (with a loud note) when gpg is not installed; throws when the
// signature is bad, missing, or made by another key.
bool verifySignature(const fs::path& data, const fs::path& sig, const std::string& keyUrl, const std::string& fpr) {
std::string gpg;
try {
gpg = resolveBinary("gpg");
} catch (const std::exception&) {
std::fprintf(stderr, "note: signature NOT checked (gpg is not installed); relying on https and the publisher's checksum\n");
return false;
}
TempDir home;
::chmod(home.path().c_str(), 0700);
fs::path keyFile = home.path() / "key.asc";
fetchFile(keyUrl, keyFile);
std::string h = home.path().string();
if (runProcess({gpg, "--batch", "--quiet", "--homedir", h, "--import", keyFile.string()}, false, true).code != 0) {
throw std::runtime_error("cannot import the publisher key from " + keyUrl);
}
ProcResult fp = runProcess({gpg, "--batch", "--homedir", h, "--with-colons", "--fingerprint"}, true, true);
if (fp.out.find(":::::::::" + upper(fpr) + ":") == std::string::npos) {
throw std::runtime_error("the key at " + keyUrl + " does not have the pinned fingerprint " + fpr);
}
ProcResult v = runProcess({gpg, "--batch", "--homedir", h, "--status-fd", "1", "--verify", sig.string(), data.string()}, true, true);
bool good = false, valid = false;
std::istringstream in(v.out);
for (std::string line; std::getline(in, line);) {
if (line.find("BADSIG") != std::string::npos || line.find("ERRSIG") != std::string::npos ||
line.find("EXPKEYSIG") != std::string::npos || line.find("REVKEYSIG") != std::string::npos) {
throw std::runtime_error("bad signature on " + data.filename().string());
}
if (line.find("[GNUPG:] GOODSIG") == 0) good = true;
if (line.find("[GNUPG:] VALIDSIG") == 0) {
std::istringstream ls(line);
std::vector<std::string> tok;
for (std::string t; ls >> t;) tok.push_back(upper(t));
if (tok.size() > 2 && (tok[2] == upper(fpr) || tok.back() == upper(fpr))) valid = true;
}
}
if (v.code != 0 || !good || !valid) {
throw std::runtime_error("signature check failed for " + data.filename().string() + " (not signed by " + fpr + ")");
}
std::fprintf(stderr, "signature verified (key %s)\n", fpr.c_str());
return true;
}
bool isHex64(const std::string& s) {
return s.size() == 64 && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isxdigit(c); });
}
std::vector<unsigned long> versionParts(const std::string& v) { std::vector<unsigned long> versionParts(const std::string& v) {
std::vector<unsigned long> out; std::vector<unsigned long> out;
std::stringstream ss(v); std::stringstream ss(v);
@@ -214,7 +114,7 @@ ImageSource Catalog::resolve(const std::string& n) {
TempDir tmp; TempDir tmp;
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS"); fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
fetchFile(base + "SHA256SUMS.gpg", tmp.path() / "SHA256SUMS.gpg"); fetchFile(base + "SHA256SUMS.gpg", tmp.path() / "SHA256SUMS.gpg");
verifySignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl), verifyDetachedSignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl),
std::string(spec::kUbuntuKeyFpr)); std::string(spec::kUbuntuKeyFpr));
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS"); std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
std::string suffix = "-base-" + arch + ".tar.gz"; std::string suffix = "-base-" + arch + ".tar.gz";
@@ -343,7 +243,7 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
if (!src.sigUrl.empty()) { if (!src.sigUrl.empty()) {
TempDir sigDir; TempDir sigDir;
fetchFile(src.sigUrl, sigDir.path() / "file.asc"); fetchFile(src.sigUrl, sigDir.path() / "file.asc");
verifySignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr); verifyDetachedSignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr);
} }
} else { } else {
std::fprintf(stderr, "note: local file used without --sha256, not verified\n"); std::fprintf(stderr, "note: local file used without --sha256, not verified\n");
+12 -6
View File
@@ -50,9 +50,12 @@ std::string Network::resolvConf(NetMode m) {
} }
void SlirpSession::start(pid_t targetPid) { void SlirpSession::start(pid_t targetPid) {
const std::string bin(spec::kSlirpBinary); std::string bin;
if (::access(bin.c_str(), X_OK) != 0) throw std::runtime_error("slirp4netns helper not found at " + bin + try {
" (install slirp4netns or use --net none)"); bin = resolveBinary("slirp4netns");
} catch (const std::exception&) {
throw std::runtime_error("slirp4netns helper not found (install slirp4netns or use --net none)");
}
int ready[2]; int ready[2];
if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe"); if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe");
Fd readEnd(ready[0]), writeEnd(ready[1]); Fd readEnd(ready[0]), writeEnd(ready[1]);
@@ -97,9 +100,12 @@ void SlirpSession::stop() {
namespace doos { namespace doos {
void PastaSession::start(pid_t targetPid) { void PastaSession::start(pid_t targetPid) {
const std::string bin(spec::kPastaBinary); std::string bin;
if (::access(bin.c_str(), X_OK) != 0) throw std::runtime_error("pasta helper not found at " + bin + try {
" (install pasta or use --net none)"); bin = resolveBinary("pasta");
} catch (const std::exception&) {
throw std::runtime_error("pasta helper not found (install pasta or use --net none)");
}
pid_ = ::fork(); pid_ = ::fork();
if (pid_ < 0) throw SysError("fork pasta"); if (pid_ < 0) throw SysError("fork pasta");
if (pid_ == 0) { if (pid_ == 0) {
+30 -1
View File
@@ -23,6 +23,7 @@
#include <chrono> #include <chrono>
#include "cgroup.hpp" #include "cgroup.hpp"
#include "davmount.hpp"
#include "hardening.hpp" #include "hardening.hpp"
#include "spec.hpp" #include "spec.hpp"
#include "util.hpp" #include "util.hpp"
@@ -166,6 +167,19 @@ void mountImageRoot(const Container& c, const fs::path& root, const fs::path& im
for (auto d : spec::kEmptyDirs) fsutil::mkdirs(sub(root, d)); 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) { void setupRoot(const Container& c, const fs::path& imageRoot) {
fs::path root = c.rootfs(); fs::path root = c.rootfs();
doMount(nullptr, "/", nullptr, MS_REC | MS_PRIVATE); doMount(nullptr, "/", nullptr, MS_REC | MS_PRIVATE);
@@ -202,6 +216,8 @@ void setupRoot(const Container& c, const fs::path& imageRoot) {
for (auto p : spec::kProcReadOnly) remountReadOnly(sub(root, p)); 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); 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. // Switch root, then drop the old one.
if (::chdir(root.c_str()) != 0) throw SysError("chdir newroot"); if (::chdir(root.c_str()) != 0) throw SysError("chdir newroot");
if (::syscall(SYS_pivot_root, ".", ".") != 0) throw SysError("pivot_root"); if (::syscall(SYS_pivot_root, ".", ".") != 0) throw SysError("pivot_root");
@@ -279,6 +295,10 @@ int containerInit(void* arg) {
setupRoot(c, ctx->imageRoot); setupRoot(c, ctx->imageRoot);
if (::sethostname(c.name().c_str(), c.name().size()) != 0) throw SysError("sethostname"); if (::sethostname(c.name().c_str(), c.name().size()) != 0) throw SysError("sethostname");
if (Network::isolated(c.net())) bringLoopbackUp(); if (Network::isolated(c.net())) bringLoopbackUp();
// Hardening (inside applyLimitsAndEnv) must be the last thing before the worker is forked
// and exec'd: everything this process still needs to do for itself (mounts, hostname,
// loopback) has to happen first, since its seccomp filter and dropped capabilities are
// inherited by every descendant from here on. See the matching note in Sandbox::join.
applyLimitsAndEnv(c); applyLimitsAndEnv(c);
pid_t worker = ::fork(); pid_t worker = ::fork();
@@ -378,6 +398,9 @@ int Sandbox::start(FileLock& lock) {
throw std::runtime_error("image '" + container_.image() + "' is missing; add it again with: doos image add"); throw std::runtime_error("image '" + container_.image() + "' is missing; add it again with: doos image add");
} }
} }
// davmount entries are resolved here, on the host, before any namespace exists: the FUSE mount
// this installs (or reuses) lives outside the container entirely and must outlast it.
container_.primeRuntimeMounts(DavMount::resolveAll(container_.mounts()));
int flags = CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD; int flags = CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD;
if (Network::isolated(net)) flags |= CLONE_NEWNET; if (Network::isolated(net)) flags |= CLONE_NEWNET;
@@ -542,6 +565,11 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
if (cgroup) { if (cgroup) {
try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ } try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ }
} }
// Ordering invariant: this setns loop must run before Hardening::apply() (inside
// applyLimitsAndEnv below), never after. The seccomp filter it installs denies
// SYS_setns outright (hardening.cpp's deniedSyscalls), and a seccomp filter is
// inherited by every descendant for the rest of its life. Moving the hardening call
// earlier would make every second `attach` fail to join a running container.
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 6; ++i) {
if (fds[i].valid() && ::setns(fds[i].get(), kinds[i]) != 0) { if (fds[i].valid() && ::setns(fds[i].get(), kinds[i]) != 0) {
throw SysError(std::string("setns ") + names[i]); throw SysError(std::string("setns ") + names[i]);
@@ -549,7 +577,8 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
} }
if (::fchdir(rootFd.get()) != 0 || ::chroot(".") != 0) throw SysError("chroot into container"); if (::fchdir(rootFd.get()) != 0 || ::chroot(".") != 0) throw SysError("chroot into container");
if (::chdir(std::string(spec::kHome).c_str()) != 0) throw SysError("chdir home"); if (::chdir(std::string(spec::kHome).c_str()) != 0) throw SysError("chdir home");
// setns(pid) only affects children, so fork once more. // setns(pid) only affects children, so fork once more. Hardening (which the setns
// loop above must predate) is applied only in this inner child, right before exec.
pid_t inner = ::fork(); pid_t inner = ::fork();
if (inner < 0) throw SysError("fork"); if (inner < 0) throw SysError("fork");
if (inner == 0) { if (inner == 0) {
+21 -6
View File
@@ -12,6 +12,23 @@ constexpr std::size_t kMaxContainers = 64;
constexpr std::size_t kMaxNameLen = 32; constexpr std::size_t kMaxNameLen = 32;
constexpr std::size_t kMaxCommandArgs = 256; constexpr std::size_t kMaxCommandArgs = 256;
constexpr std::size_t kMaxTreeDepth = 256; // removal recursion cap constexpr std::size_t kMaxTreeDepth = 256; // removal recursion cap
constexpr std::size_t kMaxMounts = 8; // extra --mount bind mounts per container
// davmount: --mount/--davmount with a dav://, davs://, http:// or https:// source. rclone is never
// taken from the caller's PATH or a system package; it is downloaded once, verified, and installed
// under the state dir, exactly like curl/tar/gpg are only ever resolved via resolveBinary(), never
// trusted from an ambient install. Pinned the same way Alpine/Ubuntu images are: a fixed version,
// fetched over https, checked against a signed checksum file and a key pinned by fingerprint.
constexpr std::string_view kRcloneVersion = "v1.75.2";
constexpr std::string_view kRcloneBaseUrl = "https://downloads.rclone.org/";
constexpr std::string_view kRcloneKeyUrl = "https://rclone.org/KEYS";
constexpr std::string_view kRcloneKeyFpr = "FBF737ECE9F8AB18604BD2AC93935E02FF3B54FA";
constexpr std::uint64_t kMaxRcloneDownloadBytes = 128ULL << 20;
constexpr int kDavMountReadyTimeoutMs = 20000; // bounded wait for a webdav mount to answer
constexpr int kDavMountPollIntervalMs = 100;
constexpr std::size_t kMaxDavTargets = 32; // distinct webdav mounts doos manages at once
constexpr std::array<std::string_view, 5> kDavVendors = {"other", "nextcloud", "owncloud", "sharepoint",
"sharepoint-ntlm"};
constexpr unsigned long kMaxOpenFiles = 4096; // RLIMIT_NOFILE ceiling inside constexpr unsigned long kMaxOpenFiles = 4096; // RLIMIT_NOFILE ceiling inside
constexpr int kNetReadyTimeoutMs = 5000; // slirp4netns must be ready by then constexpr int kNetReadyTimeoutMs = 5000; // slirp4netns must be ready by then
constexpr int kLockTimeoutMs = 5000; // per-container lock wait constexpr int kLockTimeoutMs = 5000; // per-container lock wait
@@ -47,10 +64,9 @@ constexpr std::size_t kMaxEtcCopyFiles = 50000;
constexpr std::uint64_t kMaxEtcCopyBytes = 256ULL << 20; constexpr std::uint64_t kMaxEtcCopyBytes = 256ULL << 20;
// Id mapping: container ids 0..kMaxIdRange. 0 maps to the invoking user, the rest to the subuid range. // Id mapping: container ids 0..kMaxIdRange. 0 maps to the invoking user, the rest to the subuid range.
// Helpers are invoked by absolute path (no PATH lookup) to avoid RCE via a poisoned PATH. // newuidmap/newgidmap, like every other external helper, are found via resolveBinary() (util.cpp):
// the state dir's bin/ override, then a fixed list of system directories, never the caller's PATH.
constexpr std::uint32_t kMaxIdRange = 65536; constexpr std::uint32_t kMaxIdRange = 65536;
constexpr std::string_view kNewUidMap = "/usr/bin/newuidmap";
constexpr std::string_view kNewGidMap = "/usr/bin/newgidmap";
// Images // Images
constexpr std::size_t kMaxImages = 16; constexpr std::size_t kMaxImages = 16;
@@ -71,9 +87,8 @@ constexpr std::string_view kUbuntuKeyUrl =
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x843938DF228D22F7B3742BC0D94AA3F0EFE21092"; "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x843938DF228D22F7B3742BC0D94AA3F0EFE21092";
constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092"; constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092";
// Networking // Networking. slirp4netns and pasta are found via resolveBinary() (util.cpp), same as every other
constexpr std::string_view kSlirpBinary = "/usr/bin/slirp4netns"; // external helper: the state dir's bin/ override, then a fixed list of system directories.
constexpr std::string_view kPastaBinary = "/usr/bin/pasta";
constexpr std::string_view kSlirpDns = "10.0.2.3"; constexpr std::string_view kSlirpDns = "10.0.2.3";
constexpr std::string_view kSlirpTapName = "tap0"; constexpr std::string_view kSlirpTapName = "tap0";
+131 -4
View File
@@ -8,10 +8,12 @@
#include <dirent.h> #include <dirent.h>
#include <unistd.h> #include <unistd.h>
#include <algorithm>
#include <csignal> #include <csignal>
#include <cstring> #include <cstring>
#include <cctype> #include <cctype>
#include <fstream> #include <fstream>
#include <limits>
#include <unordered_set> #include <unordered_set>
#include <sstream> #include <sstream>
@@ -61,11 +63,19 @@ std::uint64_t parseSize(const std::string& text) {
if (unit == "b" || unit.empty()) { if (unit == "b" || unit.empty()) {
} else { } else {
if (unit.size() == 2 && unit[1] == 'b') unit.pop_back(); if (unit.size() == 2 && unit[1] == 'b') unit.pop_back();
if (unit == "k") v <<= 10; int shift;
else if (unit == "m") v <<= 20; if (unit == "k") shift = 10;
else if (unit == "g") v <<= 30; else if (unit == "m") shift = 20;
else if (unit == "t") v <<= 40; else if (unit == "g") shift = 30;
else if (unit == "t") shift = 40;
else throw std::runtime_error("invalid size unit in '" + text + "' (use k, m, g or t)"); else throw std::runtime_error("invalid size unit in '" + text + "' (use k, m, g or t)");
// A 64-bit shift wraps silently on a large enough value instead of producing a huge
// number; reject anything that would not survive the shift intact rather than let it
// land, by chance, inside whatever range the caller validates against next.
if (v > (std::numeric_limits<std::uint64_t>::max() >> shift)) {
throw std::runtime_error("size '" + text + "' is too large");
}
v <<= shift;
} }
return v; return v;
} }
@@ -148,6 +158,123 @@ ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool q
return r; return r;
} }
std::string fetchText(const std::string& url) {
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
"--proto", "=https", "--max-time", "60", url},
true);
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
return r.out;
}
void fetchFile(const std::string& url, const fs::path& out, std::uint64_t maxBytes) {
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
"--proto", "=https", "--max-time", "60", "--max-filesize",
std::to_string(maxBytes), "--output", out.string(), url});
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
}
TempDir::TempDir() {
fs::path base;
if (const char* h = std::getenv("DOOS_HOME"); h && *h) base = h;
else if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) base = fs::path(x) / "doos";
else if (const char* home = std::getenv("HOME"); home && *home) base = fs::path(home) / ".local/share/doos";
else base = fs::temp_directory_path();
fs::path scratch = base / "tmp";
std::error_code ec;
fs::create_directories(scratch, ec);
if (ec) scratch = fs::temp_directory_path();
std::string tmpl = (scratch / "doos-XXXXXX").string();
if (!::mkdtemp(tmpl.data())) throw SysError("mkdtemp");
path_ = tmpl;
::chmod(path_.c_str(), 0700);
}
TempDir::~TempDir() {
std::error_code ec;
fs::remove_all(path_, ec);
}
bool isHex64(const std::string& s) {
return s.size() == 64 && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isxdigit(c); });
}
namespace {
std::string upperStr(std::string s) {
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return s;
}
// Imports the publisher key from `keyUrl` into a fresh gpg homedir and checks its fingerprint is
// exactly `fpr`. Returns the gpg binary path, or "" (with a printed note) when gpg is not installed.
std::string importPinnedKey(const fs::path& homedir, const std::string& keyUrl, const std::string& fpr) {
std::string gpg;
try {
gpg = resolveBinary("gpg");
} catch (const std::exception&) {
std::fprintf(stderr, "note: signature NOT checked (gpg is not installed); relying on https and the publisher's checksum\n");
return "";
}
::chmod(homedir.c_str(), 0700);
fs::path keyFile = homedir / "key.asc";
fetchFile(keyUrl, keyFile);
std::string h = homedir.string();
if (runProcess({gpg, "--batch", "--quiet", "--homedir", h, "--import", keyFile.string()}, false, true).code != 0) {
throw std::runtime_error("cannot import the publisher key from " + keyUrl);
}
ProcResult fp = runProcess({gpg, "--batch", "--homedir", h, "--with-colons", "--fingerprint"}, true, true);
if (fp.out.find(":::::::::" + upperStr(fpr) + ":") == std::string::npos) {
throw std::runtime_error("the key at " + keyUrl + " does not have the pinned fingerprint " + fpr);
}
return gpg;
}
// Shared GOODSIG/VALIDSIG parsing for both --verify forms (detached and clearsigned produce the
// same --status-fd protocol lines).
void requireGoodSignature(const ProcResult& v, const std::string& fpr, const std::string& what) {
bool good = false, valid = false;
std::istringstream in(v.out);
for (std::string line; std::getline(in, line);) {
if (line.find("BADSIG") != std::string::npos || line.find("ERRSIG") != std::string::npos ||
line.find("EXPKEYSIG") != std::string::npos || line.find("REVKEYSIG") != std::string::npos) {
throw std::runtime_error("bad signature on " + what);
}
if (line.find("[GNUPG:] GOODSIG") == 0) good = true;
if (line.find("[GNUPG:] VALIDSIG") == 0) {
std::istringstream ls(line);
std::vector<std::string> tok;
for (std::string t; ls >> t;) tok.push_back(upperStr(t));
if (tok.size() > 2 && (tok[2] == upperStr(fpr) || tok.back() == upperStr(fpr))) valid = true;
}
}
if (v.code != 0 || !good || !valid) {
throw std::runtime_error("signature check failed for " + what + " (not signed by " + fpr + ")");
}
std::fprintf(stderr, "signature verified (key %s)\n", fpr.c_str());
}
} // namespace
bool verifyDetachedSignature(const fs::path& data, const fs::path& sig, const std::string& keyUrl, const std::string& fpr) {
TempDir home;
std::string gpg = importPinnedKey(home.path(), keyUrl, fpr);
if (gpg.empty()) return false;
ProcResult v = runProcess({gpg, "--batch", "--homedir", home.path().string(), "--status-fd", "1", "--verify",
sig.string(), data.string()},
true, true);
requireGoodSignature(v, fpr, data.filename().string());
return true;
}
bool verifyClearsignedSignature(const fs::path& file, const std::string& keyUrl, const std::string& fpr) {
TempDir home;
std::string gpg = importPinnedKey(home.path(), keyUrl, fpr);
if (gpg.empty()) return false;
ProcResult v = runProcess({gpg, "--batch", "--homedir", home.path().string(), "--status-fd", "1", "--verify",
file.string()},
true, true);
requireGoodSignature(v, fpr, file.filename().string());
return true;
}
namespace fsutil { namespace fsutil {
namespace { namespace {
+29
View File
@@ -61,6 +61,35 @@ struct ProcResult {
// fork+exec without a shell. Captured output is capped; stderr is inherited unless quiet. // fork+exec without a shell. Captured output is capped; stderr is inherited unless quiet.
ProcResult runProcess(const std::vector<std::string>& argv, bool capture = false, bool quiet = false); ProcResult runProcess(const std::vector<std::string>& argv, bool capture = false, bool quiet = false);
// https-only fetch via curl (resolveBinary("curl")); throws on a non-2xx result or a transfer error.
std::string fetchText(const std::string& url);
void fetchFile(const std::string& url, const std::filesystem::path& out, std::uint64_t maxBytes = 1 << 20);
// Private scratch directory under the state dir's tmp/ (falls back to the system temp dir),
// removed on scope exit. Used for anything downloaded and verified before it is trusted.
class TempDir {
public:
TempDir();
~TempDir();
TempDir(const TempDir&) = delete;
TempDir& operator=(const TempDir&) = delete;
const std::filesystem::path& path() const { return path_; }
private:
std::filesystem::path path_;
};
bool isHex64(const std::string& s);
// Verifies a detached signature (sig is a separate file from data) against a publisher key fetched
// over https and accepted only when its fingerprint equals the pinned one. Returns false (and prints
// a note) when gpg is not installed; throws when the signature is bad, missing, or by another key.
bool verifyDetachedSignature(const std::filesystem::path& data, const std::filesystem::path& sig,
const std::string& keyUrl, const std::string& fpr);
// Same, for a clearsigned single file (hashes and signature combined in one file, e.g. rclone's
// SHA256SUMS release artifact).
bool verifyClearsignedSignature(const std::filesystem::path& file, const std::string& keyUrl, const std::string& fpr);
namespace fsutil { namespace fsutil {
struct SeedStats { struct SeedStats {
std::size_t made = 0; std::size_t made = 0;
+80 -1
View File
@@ -9,7 +9,11 @@ ok() { pass=$((pass+1)); echo "ok $1"; }
bad() { fail=$((fail+1)); echo "FAIL $1"; } bad() { fail=$((fail+1)); echo "FAIL $1"; }
check() { local d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi; } check() { local d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi; }
out() { "$DOOS" attach t -- sh -c "$1" 2>&1; } out() { "$DOOS" attach t -- sh -c "$1" 2>&1; }
trap '"$DOOS" delete --yes t >/dev/null 2>&1; rm -rf "$DOOS_HOME"' EXIT trap '
for id in $(ls "$DOOS_HOME/davmounts" 2>/dev/null); do "$DOOS" davmount umount --yes "$id" >/dev/null 2>&1; done
"$DOOS" delete --yes t >/dev/null 2>&1
rm -rf "$DOOS_HOME"
' EXIT
check "list empty" "$DOOS" list check "list empty" "$DOOS" list
check "docs cross-linked, links resolve" "$(dirname "$0")/../scripts/check-docs.sh" check "docs cross-linked, links resolve" "$(dirname "$0")/../scripts/check-docs.sh"
@@ -71,6 +75,81 @@ outs() { "$DOOS" attach s -- sh -c "$1" 2>&1; }
check "ids fixed at creation" bash -c "! '$DOOS' get_or_create --ids subuid s1" check "ids fixed at creation" bash -c "! '$DOOS' get_or_create --ids subuid s1"
check "bad size rejected" bash -c "! '$DOOS' get_or_create --size 1x z1" 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 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"
# --mount/--davmount with a dav(s):// or http(s):// source: auto-installs a managed rclone and
# mounts a real WebDAV target. The target here is a disposable local server, run with its own
# throwaway rclone copy (not doos's managed one) so the two downloads stay independent.
echo "--- davmount (network)"
davd=$(mktemp -d); echo webdav-data > "$davd/f.txt"
davport=$(python3 -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.bind(('127.0.0.1',0));print(s.getsockname()[1]);s.close()" 2>/dev/null)
davtmp=$(mktemp -d)
davok=0
if [ -n "$davport" ] && curl -fsSL --proto '=https' https://downloads.rclone.org/v1.75.2/rclone-v1.75.2-linux-amd64.zip -o "$davtmp/rclone.zip" 2>/dev/null \
&& unzip -q -o "$davtmp/rclone.zip" -d "$davtmp/x" 2>/dev/null; then
davrclone="$davtmp/x/rclone-v1.75.2-linux-amd64/rclone"
chmod +x "$davrclone" 2>/dev/null
[ -x "$davrclone" ] && davok=1
fi
if [ "$davok" = 1 ]; then
"$davrclone" serve webdav "$davd" --addr "127.0.0.1:$davport" --user davuser --pass davpass123 --log-level=ERROR >/tmp/doos-davserve.$$ 2>&1 &
davserve=$!
sleep 1
davurl="http://davuser:davpass123@127.0.0.1:$davport/"
"$DOOS" get_or_create --mount "$davurl" /data dav1 >/dev/null
[ "$("$DOOS" attach dav1 -- cat /data/f.txt)" = "webdav-data" ] && ok "davmount: readable inside" || bad "davmount: readable inside"
"$DOOS" attach dav1 -- sh -c 'echo from-dav-container > /data/g.txt'
sleep 2
[ "$(cat "$davd/g.txt" 2>/dev/null)" = "from-dav-container" ] && ok "davmount: writes reach the webdav backend" || bad "davmount: writes reach the webdav backend"
"$DOOS" delete --yes dav1 >/dev/null
"$DOOS" get_or_create --mount "$davurl" /data dav2 >/dev/null
t0=$(date +%s%N); "$DOOS" attach dav2 -- true; t1=$(date +%s%N)
[ $(( (t1 - t0) / 1000000 )) -lt 3000 ] && ok "davmount: reused without remounting" || bad "davmount: reused without remounting"
"$DOOS" delete --yes dav2 >/dev/null
"$DOOS" get_or_create --davmount "http://127.0.0.1:$davport/" /data --dav-user davuser --dav-pass davpass123 dav3 >/dev/null
[ "$("$DOOS" attach dav3 -- cat /data/f.txt)" = "webdav-data" ] && ok "davmount: --davmount with --dav-user/--dav-pass" || bad "davmount: --davmount with --dav-user/--dav-pass"
"$DOOS" delete --yes dav3 >/dev/null
"$DOOS" get_or_create --mount "$davurl" /data:ro davro >/dev/null
"$DOOS" attach davro -- sh -c 'echo x > /data/should-fail' 2>/dev/null && bad "davmount: ro rejects writes" || ok "davmount: ro rejects writes"
"$DOOS" delete --yes davro >/dev/null
check "davmount: wrong password rejected with a clear error" bash -c "
'$DOOS' get_or_create --mount 'http://127.0.0.1:$davport/' /data --dav-user davuser --dav-pass wrongpass davbad >/dev/null
'$DOOS' attach davbad -- true 2>&1 | grep -q 'authentication failed'
"
"$DOOS" delete --yes davbad >/dev/null 2>&1
check "davmount: unknown vendor rejected" bash -c "! '$DOOS' get_or_create --mount 'http://127.0.0.1:$davport/' /data --dav-vendor notavendor davvendor"
"$DOOS" davmount list | grep -q "$davport" && ok "davmount list: shows an active target" || bad "davmount list: shows an active target"
david=$("$DOOS" davmount list | awk -v p="$davport" '$0 ~ p {print $1}' | head -1)
"$DOOS" davmount umount "$david" | grep -q "dry run" && ok "davmount umount: dry-run by default" || bad "davmount umount: dry-run by default"
"$DOOS" davmount umount --yes "$david" | grep -q unmounted && ok "davmount umount: --yes removes it" || bad "davmount umount: --yes removes it"
kill $davserve 2>/dev/null
else
echo "skip: davmount tests (no network or unzip for the test webdav server)"
fi
rm -rf "$davd" "$davtmp" /tmp/doos-davserve.$$ 2>/dev/null
"$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" "$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" [ ! -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 "$DOOS" delete --yes s >/dev/null; "$DOOS" delete --yes s1 >/dev/null