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>
This commit is contained in:
2026-10-25 08:05:02 +01:00
co-authored by Claude Sonnet 5
parent 7adf43dd03
commit d1541b77e6
25 changed files with 1096 additions and 220 deletions
+9 -4
View File
@@ -56,6 +56,7 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea
| Limits, cgroup layout | `SPEC.md` Resource limits, `docs/ARCHITECTURE.md` |
| 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
what would close it. A number in the docs needs a run behind it, else say it is unmeasured.
@@ -81,9 +82,11 @@ the script.
`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
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
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).
freeze, a real WebDAV round trip through a disposable local `rclone serve webdav` fixture (about 4-5
minutes in total, needs network, `pasta`, `gpg`, `unzip` and cgroup v2 delegation). Add a case for every
behavior change. A green run is required before release. On Ubuntu 24.04+ the AppArmor profile in
`apparmor/doos` must be installed once for the binary being tested (`doos apparmor`; needs sudo, ask the
user, do not do it yourself).
## CI
@@ -105,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`.
Tests use a temp `DOOS_HOME`; never point tests at the user's real store.
- Build: `cmake -S . -B build && cmake --build build`.
- Requirements: Linux >= 5.11, `slirp4netns` for the default network mode.
- 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.
+9
View File
@@ -2,6 +2,15 @@
**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 -1
View File
@@ -9,7 +9,7 @@ if(NOT CMAKE_BUILD_TYPE)
endif()
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_link_options(doos PRIVATE -Wl,-z,relro -Wl,-z,now)
target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}")
+19 -19
View File
@@ -22,7 +22,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
| Who can use it safely | any user | only trusted users: access to the daemon is root-equivalent [S: [Docker security](https://docs.docker.com/engine/security/), [iximiuz](https://labs.iximiuz.com/tutorials/docker-security-introduction-a859718d)] | any user | any user |
| Image source | tarball, https URL with sha256, small catalog (Alpine, Ubuntu, Debian) [D] | OCI registries, Dockerfile builds [K] | same as Docker | OCI registries, Containerfile builds [K] |
| Build images | no [D] | yes (BuildKit) [K] | yes | yes (Buildah) [K] |
| Host dirs into the container (`-v`) | yes, `--mount SRC:DST[:ro]`, not subject to the size cap [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)] |
| Rootless network backend | slirp4netns or pasta [D] | n/a | RootlessKit based [S: [Podman blog](https://blog.podman.io/2023/07/podman-performance-root-and-rootless/)] | pasta default since 5.0 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
| Resource limits | memory, pids, cpus, written data [D][M] | memory, cpu, pids, more [K] | needs cgroup v2 + systemd delegation [S: [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/)] | needs cgroup v2 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
@@ -30,7 +30,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
| Default capabilities | Docker's default set [D][M] | that set [S] | that set | a smaller set [S: [dev.to comparison](https://dev.to/mechcloud_academy/docker-vs-podman-an-in-depth-comparison-2025-2eia)] |
| Orchestration (Compose, Kubernetes) | none [D] | Compose, Swarm, Kubernetes via runtimes [K] | same | Compose, pods, `kube play`, systemd units [K] |
| Background containers, logs, `stop` | none: a container lives while its first `attach` command lives [D] | yes [K] | yes | yes |
| Binary size | <!--bench:doos_size-->532 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->3,712<!--/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] |
| Maturity | version 1.1, one test machine | very mature, huge ecosystem | mature | mature |
@@ -48,24 +48,24 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
## 3. Measurements
Method: a trivial command (`true`) started <!--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-24<!--/bench-->. Versions: <!--bench:versions-->doos 1.4.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.
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)
<!-- bench:startup:begin -->
| Setup | Network | Time per run |
|---|---|---|
| bubblewrap (host /usr) | none | 8.8 ms |
| doos Alpine image | none | 14.2 ms |
| doos host mode | none | 20.2 ms |
| Podman Alpine | none | 283 ms |
| Docker Alpine (rootful) | none | 463 ms |
| bubblewrap (host /usr) | default | 7.8 ms |
| doos Alpine image | default | 58 ms |
| doos host mode | default | 61.4 ms |
| doos Alpine, pasta | default | 82.5 ms |
| Podman Alpine | default | 313 ms |
| Docker Alpine (rootful) | default | 645 ms |
| bubblewrap (host /usr) | none | 9.2 ms |
| doos Alpine image | none | 14.7 ms |
| doos host mode | none | 16.2 ms |
| Podman Alpine | none | 216 ms |
| Docker Alpine (rootful) | none | 346 ms |
| bubblewrap (host /usr) | default | 8 ms |
| doos Alpine image | default | 56.2 ms |
| doos host mode | default | 60 ms |
| doos Alpine, pasta | default | 66 ms |
| Podman Alpine | default | 233 ms |
| Docker Alpine (rootful) | default | 449 ms |
<!-- bench:startup:end -->
How to read this honestly:
@@ -82,7 +82,7 @@ How to read this honestly:
|---|---|
| bubblewrap | 79 KiB (80,424 bytes) |
| crun | 472 KiB (483,392 bytes) |
| doos | 532 KiB (544,640 bytes) |
| doos | 688 KiB (704,512 bytes) |
| docker CLI | 29.9 MiB (31,374,336 bytes) |
| podman | 41.2 MiB (43,180,896 bytes) |
<!-- 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**
- 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-->532 KiB<!--/bench-->, <!--bench:src_lines-->3,712<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening.
- Instant: <!--bench:doos_range-->14 to 82 ms<!--/bench--> against <!--bench:docker_range-->463 to 645 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->283 to 313 ms<!--/bench--> for Podman [M].
- 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-->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].
- 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/)].
@@ -114,7 +114,7 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
**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.
- 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].
- Networking: user-defined networks, DNS between containers, published ports on any interface [K].
- Compose, Swarm and the Kubernetes ecosystem [K].
@@ -186,7 +186,7 @@ These are the gaps that matter when comparing, each next to where it lives. They
## 8. Conclusion
doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a <!--bench:doos_size-->532 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->14 to 82 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:
+22 -2
View File
@@ -10,6 +10,7 @@ doos get_or_create [OPTIONS] NAME
doos attach [OPTIONS] NAME [-- CMD...]
doos 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+)
```
@@ -17,7 +18,7 @@ Version: see `VERSION`. Release history: [CHANGELOG.md](CHANGELOG.md). License:
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:
```
@@ -115,10 +116,24 @@ Notes, all measured:
| `--pids N` | process/thread cap | 4096 | changeable |
| `--cpus N` | cpu cap, e.g. `0.5`, `2` | all cores | changeable |
| `--mount SRC:DST[:ro]` | bind-mount host dir `SRC` at `DST` inside, repeatable (max 8) | none | changeable |
| `--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`.
`--mount` gives a container extra storage without counting against `--size`: mount a remote filesystem on the host first (for example a WebDAV share with `rclone mount webdav-remote: /mnt/webdav`), then `doos attach --mount /mnt/webdav:/data NAME` to reach it inside. `SRC` must exist and be a directory when the container starts; it fails loudly otherwise. `--mount SRC:DST:ro` mounts it read-only.
`--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
@@ -153,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. |
| `container 'x' is running` on delete | Exit the attached command 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
+29 -3
View File
@@ -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 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 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.
@@ -33,8 +35,22 @@ Container options (accepted by `get_or_create` and `attach`):
| `--pids N` | Process and thread cap (`pids.max`). | 4096 | yes |
| `--cpus N` | CPU cap, decimals allowed (`0.5`, `2`); `0` = all cores. | 0 | yes |
| `--mount SRC:DST[:ro]` | Bind-mount host directory `SRC` at `DST` inside the container, repeatable (max 8). Not subject to `--size`: it is host storage, not the container's writable layer. Replaces the whole mount list when given. | none | yes |
| `--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. `--mount`'s `SRC` must exist and be a directory at the time the container starts (host responsibility, e.g. `rclone mount` a WebDAV share there first); a missing `SRC` fails the start loudly.
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)
@@ -60,7 +76,10 @@ Changeable options apply at the next start of the container. Naming a fixed opti
| slirp4netns ready wait | 5 s |
| Lock wait | 5 s |
| Tree recursion depth | 256 |
| `--mount` entries | 8 |
| `--mount`/`--davmount` entries | 8 per container |
| davmount targets managed at once | 32 |
| davmount ready wait | 20 s |
| rclone download | 128 MiB |
## State layout
@@ -69,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}}
images/NAME/{meta,rootfs/}
tools/rclone-VERSION/rclone
davmounts/ID/{mnt/,log,pid,meta,lock}
```
`meta` keys: `net created version image ids size memory pids cpus hardening mounts`.
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`)
@@ -137,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.
- 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.
- `--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.5.0
1.6.0
+37 -4
View File
@@ -9,15 +9,16 @@ How doos works internally. The behavior contract is [SPEC](../SPEC.md); this exp
| File | Class / role |
|---|---|
| `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. |
| `src/util.{hpp,cpp}` | `SysError`, `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/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/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/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/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands and `image list/add/delete`. |
| `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, `image list/add/delete` and `davmount list/umount`. |
| `src/main.cpp` | Catches exceptions, prints `doos: <message>`, exit 1. |
| `tests/run.sh` | End-to-end suite. |
| `scripts/release.sh` | Bump, build, test, commit, tag. |
@@ -33,6 +34,7 @@ Cli::cmdAttach
runningPid()? yes -> join() no -> 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
IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap
slirp mode: SlirpSession.start(child) waits for ready-fd
@@ -86,6 +88,37 @@ unmount of the old root.
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
`ImageStore::add`: lock-free staging dir `images/.tmp-NAME`, download with curl (https only, size and time caps),
+11 -2
View File
@@ -66,7 +66,16 @@ removed afterwards. Rows marked "unmeasured" have no run behind them.
| G9 | `parseSize` left-shifted without an overflow check: `--size 16777217t` wrapped to exactly `1.0T` and was accepted instead of rejected | check `v > (UINT64_MAX >> shift)` before shifting, reject instead of wrapping | **done in 1.3.2**, reproduced before the fix, re-run after: rejected | `parseSize` in `src/util.cpp` |
| G10 | `newuidmap`/`newgidmap`/`slirp4netns`/`pasta` were pinned to a single hardcoded `/usr/bin/...` path, unlike `curl`/`tar`/`sha256sum`/`gpg` which go through `resolveBinary()`'s state-dir-override-then-fixed-dirs search; a distro installing them elsewhere would fail even though PATH-poisoning was never possible either way | route all four through `resolveBinary()`, same as the other helpers | **done in 1.3.2** | `idmap.cpp`, `network.cpp` |
| G11 | Alpine's `latest-releases.yaml` and Debian's `index.json` (embedded base64 manifest) are read with fixed string/byte-offset search, not a YAML/JSON library; a publisher reformatting either file breaks extraction with a thrown error (fails closed) rather than silently misreading it | none: a dependency to parse two small, publisher-controlled files is a worse trade than failing loudly when their format changes, given the project's own "fewer dependencies" rule | **accepted, not fixed**; this row exists so the trade-off is written down, not rediscovered | `Catalog::resolve` in `src/image.cpp` |
| G12 | No way to give a container storage that does not count against `--size` (remote/network storage, e.g. a WebDAV share); every writable path was either the host overlay (capped) or a private dir (capped) | `--mount SRC:DST[:ro]`: bind-mount an already-mounted host path into the container, outside the size watchdog's scan set (`Container::usedBytes` only scans `layers/`/`private/`, never `--mount` targets). doos itself never speaks a network filesystem protocol: mount WebDAV/NFS/etc. on the host first (e.g. `rclone mount`), doos only bind-mounts what is already there | **done in 1.5.0** (`--mount`, max 8 per container, changeable, applies at next start); missing `SRC` fails the start loudly | `Container::mounts`, `mountExtra` in `src/sandbox.cpp` |
| 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)
@@ -272,7 +281,7 @@ delegated with `cpuset cpu io memory pids`). The end-to-end suite passed 83 chec
![Horizontal bar chart of CPU-seconds used by four busy loops in one container, without a limit and with --cpus 0.5. The limited container is highlighted. Values are in the sentence below.](charts/cpu.svg)
The chart above is generated from `docs/charts/bench.json` (`make bench`, then `make charts`); it shows <!--bench:cpu_free-->15.6<!--/bench--> CPU-seconds without a limit and <!--bench:cpu_half-->2.4<!--/bench--> with `--cpus 0.5`; the startup and size charts live in [COMPARISON](../COMPARISON.md).
The chart above is generated from `docs/charts/bench.json` (`make bench`, then `make charts`); it shows <!--bench:cpu_free-->16.3<!--/bench--> CPU-seconds without a limit and <!--bench:cpu_half-->2.3<!--/bench--> with `--cpus 0.5`; the startup and size charts live in [COMPARISON](../COMPARISON.md).
### What changed in behavior (and why it is not a loss)
+6 -6
View File
@@ -1,17 +1,17 @@
{
"meta": {
"date": "2026-10-24",
"date": "2026-10-25",
"kernel": "7.0.0-38-generic",
"cpus": 12,
"runs": 10,
"doos": "1.4.0",
"doos": "1.5.0",
"podman": "podman version 5.7.0",
"docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1",
"bubblewrap": "bubblewrap 0.11.1",
"src_lines": 3712,
"src_lines": 4413,
"shared_libs": 6
},
"startup": [{"id":"bwrap_none","label":"bubblewrap (host /usr)","group":"none","kind":"other","ms":8.8},{"id":"bwrap_net","label":"bubblewrap (host /usr)","group":"default","kind":"other","ms":7.8},{"id":"doos_host_none","label":"doos host mode","group":"none","kind":"doos","ms":20.2},{"id":"doos_alpine_none","label":"doos Alpine image","group":"none","kind":"doos","ms":14.2},{"id":"doos_host_slirp","label":"doos host mode","group":"default","kind":"doos","ms":61.4},{"id":"doos_alpine_slirp","label":"doos Alpine image","group":"default","kind":"doos","ms":58.0},{"id":"doos_alpine_pasta","label":"doos Alpine, pasta","group":"default","kind":"doos","ms":82.5},{"id":"podman_none","label":"Podman Alpine","group":"none","kind":"other","ms":282.9},{"id":"podman_net","label":"Podman Alpine","group":"default","kind":"other","ms":313.2},{"id":"docker_none","label":"Docker Alpine (rootful)","group":"none","kind":"other","ms":462.6},{"id":"docker_net","label":"Docker Alpine (rootful)","group":"default","kind":"other","ms":645.1}],
"sizes": [{"id":"bwrap","bytes":80424},{"id":"crun","bytes":483392},{"id":"doos","bytes":544640},{"id":"docker","bytes":31374336},{"id":"podman","bytes":43180896}],
"cpu": {"wall_seconds": 4.2, "busy_loops": 4, "unlimited": 15.56, "half_cpu": 2.38}
"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}],
"cpu": {"wall_seconds": 4.2, "busy_loops": 4, "unlimited": 16.32, "half_cpu": 2.29}
}
+15 -13
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 200" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
<title id="t">CPU time used by four busy loops</title>
<desc id="d">Two bars, CPU seconds consumed in 4.2 seconds of wall time. Without a limit 15.6, with --cpus 0.5 2.4.</desc>
<desc id="d">Two bars, CPU seconds consumed in 4.2 seconds of wall time. Without a limit 16.3, with --cpus 0.5 2.3.</desc>
<style>
.bg { fill: var(--surface); }
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
@@ -21,26 +21,28 @@
</style>
<rect class="bg" x="0" y="0" width="760" height="200" rx="8"/>
<text class="t1" x="20" y="28">CPU time used by 4 busy loops in one container (CPU-seconds)</text>
<text class="t2" x="20" y="46">4.2 s of wall time. Half a CPU allows about 2.1 CPU-seconds; measured 2.4.</text>
<text class="t2" x="20" y="46">4.2 s of wall time. Half a CPU allows about 2.1 CPU-seconds; measured 2.3.</text>
<rect class="oth" x="20" y="55" width="12" height="12" rx="2"/>
<text class="t2" x="38" y="65">no limit</text>
<rect class="acc" x="116" y="55" width="12" height="12" rx="2"/>
<text class="t2" x="134" y="65">--cpus 0.5</text>
<line class="grid" x1="170.0" y1="84" x2="170.0" y2="146"/>
<text class="tick" x="170.0" y="162" text-anchor="middle">0</text>
<line class="grid" x1="295.0" y1="84" x2="295.0" y2="146"/>
<text class="tick" x="295.0" y="162" text-anchor="middle">4</text>
<line class="grid" x1="420.0" y1="84" x2="420.0" y2="146"/>
<text class="tick" x="420.0" y="162" text-anchor="middle">8</text>
<line class="grid" x1="545.0" y1="84" x2="545.0" y2="146"/>
<text class="tick" x="545.0" y="162" text-anchor="middle">12</text>
<line class="grid" x1="270.0" y1="84" x2="270.0" y2="146"/>
<text class="tick" x="270.0" y="162" text-anchor="middle">4</text>
<line class="grid" x1="370.0" y1="84" x2="370.0" y2="146"/>
<text class="tick" x="370.0" y="162" text-anchor="middle">8</text>
<line class="grid" x1="470.0" y1="84" x2="470.0" y2="146"/>
<text class="tick" x="470.0" y="162" text-anchor="middle">12</text>
<line class="grid" x1="570.0" y1="84" x2="570.0" y2="146"/>
<text class="tick" x="570.0" y="162" text-anchor="middle">16</text>
<line class="grid" x1="670.0" y1="84" x2="670.0" y2="146"/>
<text class="tick" x="670.0" y="162" text-anchor="middle">16</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>
<path class="oth" d="M170.0,92.0 h482.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-482.2 z"><title>no limit: 15.6 CPU-seconds</title></path>
<text class="val" x="664.2" y="105.0">15.6 CPU-s</text>
<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="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>
<path class="acc" d="M170.0,122.0 h70.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-70.4 z"><title>--cpus 0.5: 2.4 CPU-seconds</title></path>
<text class="val" x="252.4" y="135.0">2.4 CPU-s</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>
<text class="val" x="235.2" y="135.0">2.3 CPU-s</text>
<line class="axis" x1="170" y1="84" x2="170" y2="146"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 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">
<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 532 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>
.bg { fill: var(--surface); }
.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>
<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>
<path class="acc" d="M170.0,170.0 h147.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-147.9 z"><title>doos: 532 KiB</title></path>
<text class="val" x="329.9" y="183.0">532 KiB</text>
<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="346.5" y="183.0">688 KiB</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>
<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

+44 -36
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">
<title id="t">Time to start a trivial command</title>
<desc id="d">Horizontal bars, milliseconds, lower is better. doos: 14 to 82 ms; Podman: 283 to 313 ms; Docker: 463 to 645 ms; bubblewrap: 8 to 9 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>
.bg { fill: var(--surface); }
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
@@ -21,62 +21,70 @@
</style>
<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="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-24, 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"/>
<text class="t2" x="38" y="65">doos</text>
<rect class="oth" x="88" y="55" width="12" height="12" rx="2"/>
<text class="t2" x="106" y="65">other tools</text>
<text class="t1" x="22" y="78">No network</text>
<line class="grid" x1="200.0" y1="90" x2="200.0" y2="242"/>
<line class="grid" x1="334.3" y1="90" x2="334.3" y2="242"/>
<line class="grid" x1="468.6" y1="90" x2="468.6" y2="242"/>
<line class="grid" x1="602.9" y1="90" x2="602.9" y2="242"/>
<line class="grid" x1="294.0" y1="90" x2="294.0" y2="242"/>
<line class="grid" x1="388.0" y1="90" x2="388.0" y2="242"/>
<line class="grid" x1="482.0" y1="90" x2="482.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"/>
<text class="lab" x="190" y="111.0" text-anchor="end">bubblewrap (host /usr)</text>
<path class="oth" d="M200.0,98.0 h1.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-1.9 z"><title>bubblewrap (host /usr): 8.8 ms</title></path>
<text class="val" x="213.9" y="111.0">8.8 ms</text>
<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="216.6" y="111.0">9.2 ms</text>
<text class="lab" x="190" y="141.0" text-anchor="end">doos Alpine image</text>
<path class="acc" d="M200.0,128.0 h5.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-5.5 z"><title>doos Alpine image: 14.2 ms</title></path>
<text class="val" x="217.5" y="141.0">14.2 ms</text>
<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.8" y="141.0">14.7 ms</text>
<text class="lab" x="190" y="171.0" text-anchor="end">doos host mode</text>
<path class="acc" d="M200.0,158.0 h9.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.6 z"><title>doos host mode: 20.2 ms</title></path>
<text class="val" x="221.6" y="171.0">20.2 ms</text>
<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="223.2" y="171.0">16.2 ms</text>
<text class="lab" x="190" y="201.0" text-anchor="end">Podman Alpine</text>
<path class="oth" d="M200.0,188.0 h185.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-185.9 z"><title>Podman Alpine: 283 ms</title></path>
<text class="val" x="397.9" y="201.0">283 ms</text>
<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="411.0" y="201.0">216 ms</text>
<text class="lab" x="190" y="231.0" text-anchor="end">Docker Alpine (rootful)</text>
<path class="oth" d="M200.0,218.0 h306.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-306.6 z"><title>Docker Alpine (rootful): 463 ms</title></path>
<text class="val" x="518.6" y="231.0">463 ms</text>
<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="533.1" y="231.0">346 ms</text>
<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="334.3" y="256" text-anchor="middle">200</text>
<text class="tick" x="468.6" y="256" text-anchor="middle">400</text>
<text class="tick" x="602.9" y="256" text-anchor="middle">600</text>
<text class="tick" x="294.0" y="256" text-anchor="middle">100</text>
<text class="tick" x="388.0" y="256" text-anchor="middle">200</text>
<text class="tick" x="482.0" y="256" text-anchor="middle">300</text>
<text class="tick" x="576.0" y="256" text-anchor="middle">400</text>
<text class="tick" x="670.0" y="256" text-anchor="middle">500</text>
<text class="t1" x="22" y="280">Default network</text>
<line class="grid" x1="200.0" y1="292" x2="200.0" y2="474"/>
<line class="grid" x1="334.3" y1="292" x2="334.3" y2="474"/>
<line class="grid" x1="468.6" y1="292" x2="468.6" y2="474"/>
<line class="grid" x1="602.9" y1="292" x2="602.9" y2="474"/>
<line class="grid" x1="294.0" y1="292" x2="294.0" y2="474"/>
<line class="grid" x1="388.0" y1="292" x2="388.0" y2="474"/>
<line class="grid" x1="482.0" y1="292" x2="482.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"/>
<text class="lab" x="190" y="313.0" text-anchor="end">bubblewrap (host /usr)</text>
<path class="oth" d="M200.0,300.0 h1.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-1.2 z"><title>bubblewrap (host /usr): 7.8 ms</title></path>
<text class="val" x="213.2" y="313.0">7.8 ms</text>
<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.5" y="313.0">8 ms</text>
<text class="lab" x="190" y="343.0" text-anchor="end">doos Alpine image</text>
<path class="acc" d="M200.0,330.0 h34.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-34.9 z"><title>doos Alpine image: 58 ms</title></path>
<text class="val" x="246.9" y="343.0">58 ms</text>
<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="260.8" y="343.0">56.2 ms</text>
<text class="lab" x="190" y="373.0" text-anchor="end">doos host mode</text>
<path class="acc" d="M200.0,360.0 h37.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-37.2 z"><title>doos host mode: 61.4 ms</title></path>
<text class="val" x="249.2" y="373.0">61.4 ms</text>
<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="264.4" y="373.0">60 ms</text>
<text class="lab" x="190" y="403.0" text-anchor="end">doos Alpine, pasta</text>
<path class="acc" d="M200.0,390.0 h51.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-51.4 z"><title>doos Alpine, pasta: 82.5 ms</title></path>
<text class="val" x="263.4" y="403.0">82.5 ms</text>
<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="270.0" y="403.0">66 ms</text>
<text class="lab" x="190" y="433.0" text-anchor="end">Podman Alpine</text>
<path class="oth" d="M200.0,420.0 h206.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-206.3 z"><title>Podman Alpine: 313 ms</title></path>
<text class="val" x="418.3" y="433.0">313 ms</text>
<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="426.9" y="433.0">233 ms</text>
<text class="lab" x="190" y="463.0" text-anchor="end">Docker Alpine (rootful)</text>
<path class="oth" d="M200.0,450.0 h429.1 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-429.1 z"><title>Docker Alpine (rootful): 645 ms</title></path>
<text class="val" x="641.1" y="463.0">645 ms</text>
<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="630.2" y="463.0">449 ms</text>
<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="334.3" y="488" text-anchor="middle">200</text>
<text class="tick" x="468.6" y="488" text-anchor="middle">400</text>
<text class="tick" x="602.9" y="488" text-anchor="middle">600</text>
<text class="tick" x="294.0" y="488" text-anchor="middle">100</text>
<text class="tick" x="388.0" y="488" text-anchor="middle">200</text>
<text class="tick" x="482.0" y="488" text-anchor="middle">300</text>
<text class="tick" x="576.0" y="488" text-anchor="middle">400</text>
<text class="tick" x="670.0" y="488" text-anchor="middle">500</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

+112 -4
View File
@@ -7,6 +7,7 @@
#include <optional>
#include "container.hpp"
#include "davmount.hpp"
#include "sandbox.hpp"
#include "spec.hpp"
#include "util.hpp"
@@ -42,6 +43,62 @@ MountSpec parseMountFlag(const std::string& text) {
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.
struct Parsed {
Options opts;
@@ -83,13 +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");
p.opts.relaxed = (v == "relaxed");
}
else if (allow.container && is(a, "--mount")) {
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 flags (max " + std::to_string(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));
}
p.opts.mounts->push_back(parseMountFlag(value(i, "--mount")));
}
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 (a == "--i-understand") p.iUnderstand = true;
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
@@ -160,6 +231,8 @@ void Cli::usage(std::FILE* out) {
" doos image list\n"
" doos image add [--sha256 HEX] SOURCE [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"
"\n"
"options:\n"
@@ -173,11 +246,19 @@ void Cli::usage(std::FILE* out) {
" --pids N process cap, 16..4194304, default 4096\n"
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\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; SRC must exist and be a directory when the container starts.\n"
" mount replaces the whole mount list; a bind SRC must exist and be a directory when the container starts.\n"
"\n"
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\n"
"attach creates NAME when missing. delete only reports unless --yes is given.\n"
"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",
out);
}
@@ -196,6 +277,7 @@ int Cli::run() {
if (cmd == "attach") return cmdAttach(rest);
if (cmd == "delete") return cmdDelete(rest);
if (cmd == "image") return cmdImage(rest);
if (cmd == "davmount") return cmdDavmount(rest);
if (cmd == "apparmor") return cmdApparmor();
std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str());
usage(stderr);
@@ -322,4 +404,30 @@ int Cli::cmdImageDelete(std::vector<std::string> rest) {
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
+3
View File
@@ -20,6 +20,9 @@ private:
int cmdImageList();
int cmdImageAdd(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);
std::vector<std::string> args_;
+71 -15
View File
@@ -26,28 +26,59 @@ std::string slug(std::string_view hostDir) {
return s;
}
bool noNewline(const std::string& s) { return s.find('\n') == std::string::npos; }
void validateMount(const MountSpec& m) {
auto bad = [](const std::string& s) {
return s.find(':') != std::string::npos || s.find(';') != std::string::npos || s.find('\n') != std::string::npos;
};
if (m.src.empty() || m.src[0] != '/' || bad(m.src)) {
throw std::runtime_error("--mount source must be an absolute path without ':' or ';': '" + m.src + "'");
if (m.dst.empty() || m.dst[0] != '/' || m.dst == "/" || !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.dst.empty() || m.dst[0] != '/' || m.dst == "/" || bad(m.dst)) {
throw std::runtime_error("--mount destination must be an absolute path, not '/', without ':' or ';': '" + m.dst + "'");
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;
for (const auto& m : mounts) {
if (!out.empty()) out += ';';
out += m.src + ":" + m.dst + ":" + (m.readOnly ? "ro" : "rw");
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;
}
std::vector<MountSpec> parseMounts(const std::string& text) {
// 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, ';');) {
@@ -64,6 +95,31 @@ std::vector<MountSpec> parseMounts(const std::string& text) {
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> kv;
std::istringstream in(text);
@@ -278,8 +334,8 @@ void Container::writeMeta() const {
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
"\nhardening=" + (relaxed_ ? "relaxed" : "default") +
"\nmounts=" + serializeMounts(mounts_) + "\n");
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n" +
serializeMounts(mounts_));
}
void Container::setSizeCap(std::uint64_t bytes) {
@@ -364,7 +420,7 @@ void Container::readMeta() {
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;
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
mounts_ = kv.count("mounts") ? parseMounts(kv["mounts"]) : std::vector<MountSpec>{};
mounts_ = parseMounts(kv);
}
std::optional<pid_t> Container::runningPid() const {
+19 -4
View File
@@ -15,13 +15,24 @@
namespace doos {
// One --mount SRC:DST[:ro]: SRC is bind-mounted at DST inside the container, in addition to the
// usual overlay/image root. Not subject to the --size cap: it is host storage (or whatever the
// host mounted at SRC, e.g. a WebDAV share via rclone), not the container's own writable layer.
// 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 {
std::string src; // host path, must exist as a directory when the container starts
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".
@@ -71,6 +82,10 @@ public:
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 readMeta(); // meta only, no side effects
+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
+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");
}
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> out;
std::stringstream ss(v);
@@ -214,7 +114,7 @@ ImageSource Catalog::resolve(const std::string& n) {
TempDir tmp;
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
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 sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
std::string suffix = "-base-" + arch + ".tar.gz";
@@ -343,7 +243,7 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
if (!src.sigUrl.empty()) {
TempDir sigDir;
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 {
std::fprintf(stderr, "note: local file used without --sha256, not verified\n");
+4
View File
@@ -23,6 +23,7 @@
#include <chrono>
#include "cgroup.hpp"
#include "davmount.hpp"
#include "hardening.hpp"
#include "spec.hpp"
#include "util.hpp"
@@ -397,6 +398,9 @@ int Sandbox::start(FileLock& lock) {
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;
if (Network::isolated(net)) flags |= CLONE_NEWNET;
+16
View File
@@ -13,6 +13,22 @@ constexpr std::size_t kMaxNameLen = 32;
constexpr std::size_t kMaxCommandArgs = 256;
constexpr std::size_t kMaxTreeDepth = 256; // removal recursion cap
constexpr std::size_t kMaxMounts = 8; // extra --mount bind mounts per container
// 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 int kNetReadyTimeoutMs = 5000; // slirp4netns must be ready by then
constexpr int kLockTimeoutMs = 5000; // per-container lock wait
+118
View File
@@ -8,6 +8,7 @@
#include <dirent.h>
#include <unistd.h>
#include <algorithm>
#include <csignal>
#include <cstring>
#include <cctype>
@@ -157,6 +158,123 @@ ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool q
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 {
+29
View File
@@ -61,6 +61,35 @@ struct ProcResult {
// 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);
// 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 {
struct SeedStats {
std::size_t made = 0;
+64 -1
View File
@@ -9,7 +9,11 @@ ok() { pass=$((pass+1)); echo "ok $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; }
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 "docs cross-linked, links resolve" "$(dirname "$0")/../scripts/check-docs.sh"
@@ -87,6 +91,65 @@ check "mount: missing source rejected" bash -c "'$DOOS' get_or_create --mount /n
"$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"
[ ! -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