Compare commits

...
24 Commits
Author SHA1 Message Date
hulthe bfda3e626a kernel: Enable more networking stuff
CI / build (push) Successful in 13s
2026-09-12 21:41:03 +02:00
hulthe 53587780c3 Add systemd example to README.md
CI / build (push) Successful in 13s
2026-09-11 18:08:37 +02:00
hulthe 8d97fc16c8 Tweak README.md
CI / build (push) Successful in 13s
2026-09-11 18:01:33 +02:00
hulthe a24e1efe31 Rebrand to boco
CI / build (push) Successful in 13s
2026-09-11 17:56:38 +02:00
hulthe 8782baa705 Add linux kernel as submodule and check in slim kernel config
CI / build (push) Successful in 13s
2026-09-11 17:35:01 +02:00
hulthe d7613a4987 Rewrite init script in rust and fix non-shell CMDs
CI / build (push) Successful in 14s
2026-09-11 14:40:10 +02:00
marvin 3a99da196d Add --mount flag for 9p host directory sharing
CI / build (push) Successful in 13s
Add support for sharing host directories into the VM via QEMU 9p
(virtio-9p).

The --mount flag can be repeated.

The format is `<host-path>` or `<host-path>:<guest-path>`

Relative guest paths are relative to the configured WORKDIR.

Design:
- QEMU: each --mount gets a short 9p tag (slim0, slim1, …) via
  -virtfs local,path=…,mount_tag=slimN,security_model=mapped-xattr.
  Tags are kept short because 9p mount_tag has a ~31-byte limit.
- Kernel cmdline: the full destination path is passed as
  slim.mount=<tag>:<base64(path)> so the init script knows where to
  mount each tag.  Base64 avoids issues with spaces/special chars.
- slim-init.sh: after networking, parse slim.mount= entries, mkdir -p
  the destination, and mount -t 9p <tag> <dest> -o trans=virtio,version=9p2000.L

Tests verify: 9p share detection via sysfs mount_tag, 9p entry in
mount output, file content accessible at the expected path, and clean
VM exit.
2026-09-11 09:39:34 +02:00
marvin 6111bbeb8d Merge pull request 'Respect USER directive from OCI image config' (#8) from feature/user-directive into master
CI / build (push) Successful in 13s
2026-09-10 12:18:57 +02:00
marvin 524ef3e793 Respect USER directive from OCI image config
CI / build (pull_request) Successful in 12s
Add support for the Dockerfile USER directive so that the container's
CMD/ENTRYPOINT runs as the configured user instead of root.

Changes:
- inject.rs: Add user field to Config struct, write /slim/user at
  build time
- build.rs: Pass config.user through to inject()
- slim-init.sh: Read /slim/user and drop privileges via su before
  executing the command. Numeric uids are resolved to usernames via
  /etc/passwd (BusyBox su does not accept numeric args). When dropping
  privileges, run as a child (not exec) so PID 1 stays root and can
  poweroff after the command exits.
- test.sh: Add test_user verifying build-time CMD and --cmd override
  both run as the configured user

Closes #7
2026-09-10 12:18:13 +02:00
hulthe 5960cfe430 Clean up smelly code in inject.rs
CI / build (push) Successful in 13s
2026-09-08 22:50:39 +02:00
hulthe c2a9f00fc0 Inject universal /slim/init instead of requiring container-specific setup
CI / build (pull_request) Successful in 36s
CI / build (push) Successful in 13s
slim now works with any Containerfile by injecting distro-agnostic scripts
at build time:

- /slim/init: mounts special filesystems, configures networking (static
  QEMU slirp), sets up cgroup2, timezone, and rootless container prereqs.
  Parses slim.cmd=<base64> from the kernel cmdline for runtime overrides,
  otherwise execs /slim/exec.

- /slim/exec: generated from the image's CMD/ENTRYPOINT (via podman image
  inspect), overridable via --cmd at build and run time.

Changes:
- New src/inject.rs: inspect image config, infer command, generate
  /slim/exec script, inject /slim/ into mounted rootfs
- New src/scripts/slim-init.sh: the universal init script (include_str!)
- build.rs: rename --init to --cmd, inject scripts before packing,
  drop Meta::save, restructure to ensure unmount always runs
- qemu.rs: hardcode init=/slim/init, add --cmd (base64 on cmdline),
  drop Meta::load, add -no-reboot
- Remove src/meta.rs and meta.toml (no longer needed)
- Cargo.toml: add serde_json + base64, remove unused walkdir + cpio + toml
- Example Containerfiles simplified to plain FROM + CMD
- New example/test.sh for manual verification
- README updated for new workflow
2026-09-08 21:58:01 +02:00
hulthe 506213ed9a Fixes
CI / build (push) Successful in 14s
2026-09-06 16:09:20 +02:00
hulthe 7dd818db68 Progress
CI / build (push) Successful in 14s
2026-09-04 17:01:46 +02:00
hulthe 286def05c1 Add qcow2 support
CI / build (push) Successful in 13s
2026-08-30 17:08:08 +02:00
hulthe 46a86116b0 Add a --memory flag
CI / build (push) Successful in 10s
2026-08-29 21:12:23 +02:00
hulthe ec05936d2f Add Arch Linux example
CI / build (push) Successful in 10s
2026-08-29 21:04:35 +02:00
hulthe 41bc570bfb Print qemu command 2026-08-29 21:04:27 +02:00
hulthe 56a76ce934 Add basic README.md with an example
CI / build (push) Successful in 11s
2026-08-26 23:46:57 +02:00
hulthe 6af8660f8d Add qemu network device 2026-08-26 23:27:58 +02:00
hulthe 8b544ab688 Add image ls and image rm commands 2026-08-26 23:27:58 +02:00
marvin 37f537184c refactor: split main.rs into registry, build, and qemu modules
CI / build (pull_request) Successful in 10s
CI / build (push) Successful in 11s
main.rs keeps only the CLI definition and command dispatch:
- registry.rs: image-name validation + XDG registry path handling (tests)
- build.rs: podman mount/unmount, vmlinuz extraction, cpio/gzip initrd
- qemu.rs: direct-kernel-boot VM launch

No behavior change; cargo fmt/clippy/test pass, build/run paths
verified against the mock-vm image.
2026-08-26 20:15:16 +00:00
marvin 5b80885207 fix: validate image name before building registry path
CI / build (pull_request) Successful in 10s
CI / build (push) Successful in 10s
The user-supplied image/name argument was interpolated unsanitized into
registry/{name}/initrd, so a name containing '../' (e.g. 'slim run
../../.ssh/authorized_keys') escaped the XDG data dir and let slim
create directories and read/write files at attacker-chosen locations.

validate_image_name now rejects empty names, '.', '..' and anything
outside [A-Za-z0-9._:-] at the registry_dir chokepoint used by both
build and run, plus a defense-in-depth containment check that the
resolved path stays under the registry root.

Fixes sec-2 from the code review.
2026-08-26 20:11:57 +00:00
hulthe d517ab6a1a Fix minor issues
CI / build (push) Successful in 10s
2026-08-26 21:45:26 +02:00
hulthe e3bd77379f Add lockfile
CI / build (push) Successful in 10s
2026-08-26 19:25:20 +02:00
20 changed files with 6183 additions and 190 deletions
+1 -4
View File
@@ -1,4 +1 @@
/target/
/target/debug/
/target/release/
Cargo.lock
target
+4
View File
@@ -0,0 +1,4 @@
[submodule "linux"]
path = kernel/linux
url = https://github.com/torvalds/linux.git
shallow = true
Generated
+435
View File
@@ -0,0 +1,435 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "boco"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"clap",
"flate2",
"serde",
"serde_json",
"tempfile",
"xdg",
]
[[package]]
name = "boco-init"
version = "0.1.0"
dependencies = [
"base64",
"nix",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "xdg"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+13 -3
View File
@@ -1,15 +1,25 @@
[workspace]
members = [".", "init"]
[profile.release]
opt-level = "z"
lto = true
strip = true
[package]
name = "slim"
name = "boco"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
base64 = "0.23"
clap = { version = "4.6.6", features = ["derive"] }
flate2 = "1.1.9"
walkdir = "2.5.0"
cpio = "0.4.1"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0"
xdg = "3.0.0"
tempfile = "3.27.0"
[dev-dependencies]
tempfile = "3.27.0"
+41
View File
@@ -0,0 +1,41 @@
# boco
Turn container images into bootable VMs
## Example
```Dockerfile
# ./example/alpine/Containerfile
FROM alpine:latest
CMD ["/bin/sh"]
```
```sh
# Build the container
podman build ./example/alpine -t boco-alpine
# Make the container bootable. boco injects a custom init program,
# then packs the rootfs as a qcow2 disk.
boco build qcow2 boco-alpine
# Boot the image using QEMU, and drop into /bin/sh.
boco boot boco-alpine
# Cleanup
boco image rm boco-alpine
podman image rm boco-alpine
```
Can also be used to boot a more traditional systemd-based VM:
```Dockerfile
FROM archlinux:latest
# This works! Although you should probably add a user or some services.
CMD ["/sbin/init"]
```
## Kernel
boco boots VMs using a shared kernel at `$XDG_DATA_HOME/boco/registry/vmlinuz`.
Place a suitable x86 bzImage there before running `boco boot`.
+2
View File
@@ -0,0 +1,2 @@
FROM alpine:latest
CMD ["/bin/sh"]
+2
View File
@@ -0,0 +1,2 @@
FROM archlinux:latest
CMD ["/bin/sh"]
+369
View File
@@ -0,0 +1,369 @@
#!/bin/bash
# Test script for boco - boots containers and verifies CMD inference/override.
# Requires: podman, qemu-system-x86_64, KVM, curl, and a kernel at
# $XDG_DATA_HOME/boco/registry/vmlinuz.
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TARGET_DIR="${CARGO_TARGET_DIR:-$SCRIPT_DIR/../target}"
BOCO_BIN="$TARGET_DIR/debug/boco"
TIMEOUT=120
pass=0
fail=0
report() {
if [ "$1" = "pass" ]; then
echo " PASS: $2"
pass=$((pass + 1))
else
echo " FAIL: $2"
fail=$((fail + 1))
fi
}
check_output() {
output="$1"
marker="$2"
label="$3"
if echo "$output" | grep -q "$marker"; then
report pass "$label"
else
report fail "$label (expected marker: $marker)"
fi
}
cleanup() {
"$BOCO_BIN" image rm "$1" >/dev/null 2>&1 || true
podman image rm "$2" >/dev/null 2>&1 || true
}
test_distro() {
distro="$1"
from="$2"
extra_setup="$3"
echo "=== Testing $distro ==="
work="$(mktemp -d)"
# === Test 1: CMD-inferred ===
echo "-- Test 1: CMD-inferred"
cat > "$work/Containerfile.infer" <<EOF
FROM $from
$extra_setup
CMD ["/bin/sh", "-c", "echo CMD_INFERRED_OK; wget -q -O /dev/null http://1.1.1.1 2>/dev/null && echo CONN_OK; wget -q -O /dev/null http://example.org 2>/dev/null && echo DNS_OK; poweroff -f"]
EOF
img="boco-test-$distro-infer"
if podman build -t "$img" -f "$work/Containerfile.infer" >/dev/null 2>&1; then
"$BOCO_BIN" build qcow2 "$img" >/dev/null 2>&1
output=$(timeout "$TIMEOUT" "$BOCO_BIN" boot "$img" 2>&1 || true)
check_output "$output" "CMD_INFERRED_OK" "$distro CMD-inferred"
check_output "$output" "CONN_OK" "$distro connect to 1.1.1.1"
check_output "$output" "DNS_OK" "$distro DNS lookup"
else
report fail "$distro CMD-inferred (podman build failed)"
fi
cleanup "$img" "$img"
# === Test 2: boco boot --cmd override ===
echo "-- Test 2: boco boot --cmd override"
cat > "$work/Containerfile.run" <<EOF
FROM $from
$extra_setup
CMD ["/bin/sh", "-c", "echo SHOULD_NOT_APPEAR; poweroff -f"]
EOF
img="boco-test-$distro-run"
if podman build -t "$img" -f "$work/Containerfile.run" >/dev/null 2>&1; then
"$BOCO_BIN" build qcow2 "$img" >/dev/null 2>&1
output=$(timeout "$TIMEOUT" "$BOCO_BIN" boot "$img" --cmd 'echo RUN_OVERRIDE_OK; poweroff -f' 2>&1 || true)
check_output "$output" "RUN_OVERRIDE_OK" "$distro boot --cmd override"
else
report fail "$distro boot --cmd override (podman build failed)"
fi
cleanup "$img" "$img"
# === Test 3: boco build --cmd override ===
echo "-- Test 3: boco build --cmd override"
cat > "$work/Containerfile.build" <<EOF
FROM $from
$extra_setup
CMD ["/bin/sh", "-c", "echo SHOULD_NOT_APPEAR; poweroff -f"]
EOF
img="boco-test-$distro-build"
if podman build -t "$img" -f "$work/Containerfile.build" >/dev/null 2>&1; then
"$BOCO_BIN" build qcow2 "$img" --cmd 'echo BUILD_OVERRIDE_OK; poweroff -f' >/dev/null 2>&1
output=$(timeout "$TIMEOUT" "$BOCO_BIN" boot "$img" 2>&1 || true)
check_output "$output" "BUILD_OVERRIDE_OK" "$distro build --cmd override"
else
report fail "$distro build --cmd override (podman build failed)"
fi
cleanup "$img" "$img"
rm -rf "$work"
}
test_user() {
echo "=== Testing USER directive ==="
work="$(mktemp -d)"
img="boco-test-user"
cat > "$work/Containerfile" <<'EOF'
FROM alpine:latest
RUN adduser -D -u 1500 testuser
USER testuser
CMD ["/bin/sh", "-c", "echo USER_BUILD_OK:$(id -u):$(whoami); poweroff -f"]
EOF
echo "-- Building container image with USER directive..."
if ! podman build --network=none -t "$img" -f "$work/Containerfile" >/dev/null 2>&1; then
report fail "USER directive (podman build failed)"
rm -rf "$work"
return
fi
echo "-- Test 1: build-time CMD runs as USER"
"$BOCO_BIN" build qcow2 "$img" >/dev/null 2>&1
output=$(timeout "$TIMEOUT" "$BOCO_BIN" boot "$img" 2>&1 || true)
check_output "$output" "USER_BUILD_OK:1500:testuser" "USER build-time CMD runs as testuser"
echo "-- Test 2: boot --cmd override runs as USER"
output=$(timeout "$TIMEOUT" "$BOCO_BIN" boot "$img" --cmd 'echo USER_RUN_OK:$(id -u):$(whoami); poweroff -f' 2>&1 || true)
check_output "$output" "USER_RUN_OK:1500:testuser" "USER boot --cmd override runs as testuser"
cleanup "$img" "$img"
rm -rf "$work"
}
test_mount() {
echo "=== Testing --mount (9p shares) ==="
work="$(mktemp -d)"
# --- Image 1: no WORKINGDIR, test host:guest and multi-mount ---
img="boco-test-mount"
share_dir="$work/share"
share_dir2="$work/share2"
guest_dir="/mnt/guest"
guest_dir2="/mnt/guest2"
mkdir -p "$share_dir" "$share_dir2"
echo "hello from host" > "$share_dir/testfile.txt"
echo "second share" > "$share_dir2/testfile2.txt"
cat > "$work/Containerfile" <<'EOF'
FROM alpine:latest
CMD ["/bin/sh", "-c", "poweroff -f"]
EOF
echo "-- Building container image (no WORKINGDIR)..."
if ! podman build --network=none -t "$img" -f "$work/Containerfile" >/dev/null 2>&1; then
report fail "--mount (podman build failed)"
rm -rf "$work"
return
fi
echo "-- Building boco VM..."
if ! "$BOCO_BIN" build qcow2 "$img" >/dev/null 2>&1; then
report fail "--mount (boco build failed)"
cleanup "$img" "$img"
rm -rf "$work"
return
fi
share_canon=$(readlink -f "$share_dir")
share_canon2=$(readlink -f "$share_dir2")
echo "-- Test 1: --mount host:guest (absolute guest path, multi-mount)..."
output=$(timeout "$TIMEOUT" "$BOCO_BIN" boot "$img" \
--mount "${share_dir}:${guest_dir}" \
--mount "${share_dir2}:${guest_dir2}" \
--cmd "for f in /sys/bus/virtio/drivers/9pnet_virtio/virtio*/mount_tag; do [ -f \"\$f\" ] && echo \"\$f: \$(tr -d '\\0' < \"\$f\")\"; done; mount -v; cat ${guest_dir}/testfile.txt 2>/dev/null || echo NO_FILE; cat ${guest_dir2}/testfile2.txt 2>/dev/null || echo NO_FILE2; echo MOUNT_VERIFY_DONE; poweroff -f" \
2>&1 || true)
check_output "$output" "mount_tag: boco0" "--mount 9p share detected"
check_output "$output" "mount_tag: boco1" "--mount second 9p share detected"
check_output "$output" "type 9p" "--mount 9p filesystem in mount list"
check_output "$output" "hello from host" "--mount file accessible at guest path"
check_output "$output" "second share" "--mount second file accessible at guest path"
check_output "$output" "MOUNT_VERIFY_DONE" "--mount VM ran to completion"
cleanup "$img" "$img"
# --- Image 2: with WORKINGDIR, test relative guest path ---
img="boco-test-mount-wd"
share_dir3="$work/share3"
mkdir -p "$share_dir3"
echo "relative share" > "$share_dir3/relfile.txt"
cat > "$work/Containerfile.wd" <<'EOF'
FROM alpine:latest
WORKDIR /app
CMD ["/bin/sh", "-c", "poweroff -f"]
EOF
echo "-- Building container image (WORKINGDIR /app)..."
if ! podman build --network=none -t "$img" -f "$work/Containerfile.wd" >/dev/null 2>&1; then
report fail "--mount relative (podman build failed)"
rm -rf "$work"
return
fi
echo "-- Building boco VM..."
if ! "$BOCO_BIN" build qcow2 "$img" >/dev/null 2>&1; then
report fail "--mount relative (boco build failed)"
cleanup "$img" "$img"
rm -rf "$work"
return
fi
echo "-- Test 2: --mount host:relative-guest (resolved against WORKINGDIR)..."
output=$(timeout "$TIMEOUT" "$BOCO_BIN" boot "$img" \
--mount "${share_dir3}:data" \
--cmd "mount -v; cat /app/data/relfile.txt 2>/dev/null || echo NO_REL_FILE; echo REL_VERIFY_DONE; poweroff -f" \
2>&1 || true)
check_output "$output" "type 9p" "--mount relative 9p filesystem in mount list"
check_output "$output" "relative share" "--mount relative file accessible at WORKINGDIR/data"
check_output "$output" "REL_VERIFY_DONE" "--mount relative VM ran to completion"
cleanup "$img" "$img"
rm -rf "$work"
}
test_service() {
echo "=== Testing nextcloud service ==="
img="boco-test-nextcloud"
host_port=18080
work="$(mktemp -d)"
cat > "$work/Containerfile" <<'EOF'
FROM docker.io/library/nextcloud:32-apache
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 && rm -rf /var/lib/apt/lists/*
EOF
echo "-- Building nextcloud container image..."
if ! podman build -t "$img" -f "$work/Containerfile" >/dev/null 2>&1; then
report fail "nextcloud service (podman build failed)"
rm -rf "$work"
return
fi
echo "-- Building boco VM..."
if ! "$BOCO_BIN" build qcow2 "$img" >/dev/null 2>&1; then
report fail "nextcloud service (boco build failed)"
cleanup "$img" "$img"
rm -rf "$work"
return
fi
echo "-- Booting VM with port forward (host :${host_port} -> guest :80)..."
setsid timeout 300 "$BOCO_BIN" boot "$img" \
--forward "tcp:0.0.0.0:${host_port}-:80" \
--memory 2048M >/dev/null 2>&1 &
vm_pid=$!
echo "-- Waiting for Nextcloud to start..."
up=0
for _ in $(seq 1 60); do
if curl -s -o /dev/null -m 2 "http://localhost:${host_port}/" 2>/dev/null; then
up=1
break
fi
sleep 3
done
if [ "$up" = "1" ]; then
response=$(curl -s -L -m 10 "http://localhost:${host_port}/" 2>/dev/null || true)
if echo "$response" | grep -qi "nextcloud"; then
report pass "nextcloud service (port forward + content)"
else
report fail "nextcloud service (port reachable but no 'nextcloud' in response)"
fi
else
report fail "nextcloud service (port not reachable within timeout)"
fi
pkill -f "qemu-system-x86_64.*$img" 2>/dev/null || true
kill "$vm_pid" 2>/dev/null || true
wait "$vm_pid" 2>/dev/null || true
cleanup "$img" "$img"
rm -rf "$work"
}
test_systemd() {
echo "=== Testing systemd as init ==="
work="$(mktemp -d)"
img="boco-test-systemd"
cat > "$work/boco-test.service" <<'SVC'
[Unit]
Description=boco systemd test
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo SYSTEMD_TEST_OK > /dev/console; sleep 1; systemctl poweroff"
[Install]
WantedBy=multi-user.target
SVC
cat > "$work/Containerfile" <<EOF
FROM archlinux:latest
COPY boco-test.service /etc/systemd/system/boco-test.service
RUN mkdir -p /etc/systemd/system/multi-user.target.wants && \
ln -sf /etc/systemd/system/boco-test.service \
/etc/systemd/system/multi-user.target.wants/boco-test.service
CMD ["/sbin/init"]
EOF
echo "-- Building container image with systemd..."
if ! podman build -t "$img" "$work" >/dev/null 2>&1; then
report fail "systemd as init (podman build failed)"
rm -rf "$work"
return
fi
echo "-- Building boco VM..."
if ! "$BOCO_BIN" build qcow2 "$img" >/dev/null 2>&1; then
report fail "systemd as init (boco build failed)"
cleanup "$img" "$img"
rm -rf "$work"
return
fi
echo "-- Booting VM (systemd as PID 1)..."
output=$(timeout "$TIMEOUT" "$BOCO_BIN" boot "$img" 2>&1 || true)
check_output "$output" "SYSTEMD_TEST_OK" "systemd boots and runs service"
check_output "$output" "reboot: Power down" "systemd shuts down cleanly"
cleanup "$img" "$img"
rm -rf "$work"
}
echo "Building boco..."
cargo build 2>&1
cargo build --release -p boco-init --target x86_64-unknown-linux-musl 2>&1
cp "$TARGET_DIR/x86_64-unknown-linux-musl/release/boco-init" \
"${XDG_DATA_HOME:-$HOME/.local/share}/boco/boco-init"
test_distro "alpine" "alpine:latest" ""
test_distro "archlinux" "archlinux:latest" "RUN pacman -Sy --noconfirm iproute2 wget; pacman -Sc --noconfirm"
test_user
test_mount
test_service
test_systemd
echo ""
echo "=== Results: $pass passed, $fail failed ==="
[ "$fail" -eq 0 ]
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "boco-init"
version = "0.1.0"
edition = "2024"
[dependencies]
base64 = "0.23"
nix = { version = "0.31", features = ["mount", "fs", "process", "user", "reboot"] }
+429
View File
@@ -0,0 +1,429 @@
//! boco universal init.
//!
//! Injected by `boco build` at /boco/init and invoked via init=/boco/init.
//! Sets up devices, filesystems, networking, and 9p shares, then execs the
//! container's CMD/ENTRYPOINT (or a runtime override) with execvp so the
//! command becomes PID 1 directly.
use base64::Engine;
use nix::mount::{MsFlags, mount};
use nix::sys::reboot::{RebootMode, reboot};
use nix::sys::stat::{Mode, SFlag, makedev, mknod};
use nix::sys::wait::waitpid;
use nix::unistd::{ForkResult, Gid, Uid, User, execvp, fork, setgid, setuid};
use std::env;
use std::ffi::CString;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
fn warn(msg: &str) {
eprintln!("boco-init: {msg}");
}
/// Run a fallible operation, logging on error.
fn try_io<F, T>(label: &str, f: F)
where
F: FnOnce() -> std::io::Result<T>,
{
if let Err(e) = f() {
warn(&format!("{label}: {e}"));
}
}
/// Run a command, logging on error or non-zero exit.
fn try_cmd(label: &str, cmd: &mut Command) {
match cmd.status() {
Ok(status) => {
if !status.success() {
warn(&format!("{label}: exited with {status}"));
}
}
Err(e) => warn(&format!("{label}: {e}")),
}
}
/// The kernel passes a minimal environment to init. Set a default PATH
/// so that `ip` and other tools can be found by name.
fn ensure_path() {
if env::var("PATH").is_err() {
// SAFETY: we are single-threaded before fork/exec.
unsafe {
env::set_var(
"PATH",
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
);
}
}
}
fn find_in_path(prog: &str) -> Option<String> {
let path = env::var("PATH").ok()?;
for dir in path.split(':') {
let candidate = format!("{dir}/{prog}");
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
None
}
fn try_mount(
source: Option<&str>,
target: &str,
fstype: Option<&str>,
flags: MsFlags,
data: Option<&str>,
) {
if let Err(e) = mount(source, target, fstype, flags, data) {
warn(&format!("mount {target}: {e}"));
}
}
fn setup_filesystems() {
if !Path::new("/dev/console").exists() {
try_io("mknod /dev/console", || {
mknod(
"/dev/console",
SFlag::S_IFCHR,
Mode::S_IRUSR | Mode::S_IWUSR,
makedev(5, 1),
)
.map_err(std::io::Error::from)
});
}
let dirs = [
"/proc",
"/sys",
"/dev/pts",
"/dev/shm",
"/run",
"/tmp",
"/sys/fs/cgroup",
];
for d in &dirs {
try_io(&format!("mkdir {d}"), || fs::create_dir_all(d));
}
try_mount(Some("proc"), "/proc", Some("proc"), MsFlags::empty(), None);
try_mount(Some("sysfs"), "/sys", Some("sysfs"), MsFlags::empty(), None);
try_mount(
Some("devpts"),
"/dev/pts",
Some("devpts"),
MsFlags::empty(),
None,
);
try_mount(
Some("tmpfs"),
"/dev/shm",
Some("tmpfs"),
MsFlags::empty(),
None,
);
try_mount(
Some("tmpfs"),
"/run",
Some("tmpfs"),
MsFlags::empty(),
Some("mode=755"),
);
try_mount(
Some("tmpfs"),
"/tmp",
Some("tmpfs"),
MsFlags::empty(),
Some("mode=1777"),
);
// cgroup2
try_mount(
Some("cgroup2"),
"/sys/fs/cgroup",
Some("cgroup2"),
MsFlags::empty(),
None,
);
if let Ok(controllers) = fs::read_to_string("/sys/fs/cgroup/cgroup.controllers") {
for c in controllers.split_whitespace() {
try_io("write cgroup.subtree_control", || {
fs::write("/sys/fs/cgroup/cgroup.subtree_control", format!("+{c}"))
});
}
}
}
fn setup_rootless_prereqs() {
// make-rshared /
if let Err(e) =
mount::<str, str, str, str>(None, "/", None, MsFlags::MS_REC | MsFlags::MS_SHARED, None)
{
warn(&format!("make-rshared /: {e}"));
}
// chmod u+s newuidmap newgidmap
for bin in &["/usr/bin/newuidmap", "/usr/bin/newgidmap"] {
match fs::metadata(bin) {
Ok(meta) => {
let mut perms = meta.permissions();
perms.set_mode(perms.mode() | 0o4000);
try_io(&format!("chmod u+s {bin}"), || {
fs::set_permissions(bin, perms)
});
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn(&format!("stat {bin}: {e}")),
}
}
}
fn setup_timezone() {
try_io("symlink timezone", || {
std::os::unix::fs::symlink("/usr/share/zoneinfo/Europe/Stockholm", "/etc/localtime")
});
}
fn setup_networking() {
let ip = find_in_path("ip").unwrap_or_else(|| "/sbin/ip".to_string());
try_cmd(
"ip link set lo up",
Command::new(&ip).args(["link", "set", "lo", "up"]),
);
try_cmd(
"ip link set eth0 up",
Command::new(&ip).args(["link", "set", "eth0", "up"]),
);
try_cmd(
"ip addr add 10.0.2.15/24 dev eth0",
Command::new(&ip).args(["addr", "add", "10.0.2.15/24", "dev", "eth0"]),
);
try_cmd(
"ip route add default via 10.0.2.2",
Command::new(&ip).args(["route", "add", "default", "via", "10.0.2.2"]),
);
try_io("write /etc/resolv.conf", || {
fs::write("/etc/resolv.conf", "nameserver 10.0.2.3\n")
});
}
fn setup_9p_shares(workdir: &str) {
let cmdline = match fs::read_to_string("/proc/cmdline") {
Ok(c) => c,
Err(e) => {
warn(&format!("read /proc/cmdline: {e}"));
return;
}
};
let b64 = base64::engine::general_purpose::STANDARD;
for tok in cmdline.split_whitespace() {
if let Some(rest) = tok.strip_prefix("boco.mount=") {
let Some((tag, dest_b64)) = rest.split_once(':') else {
continue;
};
let dest = match b64.decode(dest_b64) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => {
warn(&format!("9p: invalid UTF-8 in guest path: {e}"));
continue;
}
},
Err(e) => {
warn(&format!("9p: base64 decode failed: {e}"));
continue;
}
};
// Resolve relative guest paths against WORKINGDIR
let dest = if dest.starts_with('/') {
dest
} else {
format!("{}/{}", workdir, dest)
};
try_io(&format!("mkdir {dest}"), || fs::create_dir_all(&dest));
try_mount(
Some(tag),
&dest,
Some("9p"),
MsFlags::empty(),
Some("trans=virtio,version=9p2000.L"),
);
}
}
}
fn read_file_opt(path: &str) -> Option<String> {
fs::read_to_string(path)
.ok()
.filter(|s| !s.trim().is_empty())
}
fn split_null_delimited(data: &[u8]) -> Vec<String> {
data.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect()
}
fn read_argv() -> Vec<String> {
// Check for runtime override: boco.cmd=<base64(null-delimited argv)>
let b64 = base64::engine::general_purpose::STANDARD;
if let Ok(cmdline) = fs::read_to_string("/proc/cmdline") {
for tok in cmdline.split_whitespace() {
if let Some(rest) = tok.strip_prefix("boco.cmd=")
&& let Ok(decoded) = b64.decode(rest)
{
let argv = split_null_delimited(&decoded);
if !argv.is_empty() {
return argv;
}
}
}
}
// Fall back to build-time argv from /boco/exec
match fs::read("/boco/exec") {
Ok(data) => {
let argv = split_null_delimited(&data);
if !argv.is_empty() {
return argv;
}
}
Err(e) => warn(&format!("read /boco/exec: {e}")),
}
warn("no command found, falling back to /bin/sh");
vec!["/bin/sh".to_string()]
}
fn apply_env() {
if let Ok(data) = fs::read("/boco/env") {
for entry in split_null_delimited(&data) {
if let Some((key, val)) = entry.split_once('=') {
// SAFETY: we are single-threaded before fork/exec.
unsafe { env::set_var(key, val) };
}
}
}
}
fn apply_workdir() {
if let Some(dir) = read_file_opt("/boco/workdir") {
try_io("set working directory", || env::set_current_dir(&dir));
}
}
struct UserSpec {
uid: Uid,
gid: Gid,
}
fn resolve_user(spec: &str) -> Option<UserSpec> {
let (user_part, gid_part) = match spec.split_once(':') {
Some((u, g)) => (u, Some(g)),
None => (spec, None),
};
let (uid, default_gid) = if let Ok(numeric) = user_part.parse::<u32>() {
let user = User::from_uid(Uid::from_raw(numeric)).ok().flatten();
let gid = user
.as_ref()
.map(|u| u.gid)
.unwrap_or(Gid::from_raw(numeric));
(Uid::from_raw(numeric), gid)
} else {
let user = User::from_name(user_part).ok().flatten()?;
(user.uid, user.gid)
};
let gid = match gid_part {
Some(g) => match g.parse::<u32>() {
Ok(n) => Gid::from_raw(n),
Err(_) => default_gid,
},
None => default_gid,
};
Some(UserSpec { uid, gid })
}
fn exec_command(argv: &[String], user: Option<UserSpec>) -> ! {
let cstrings: Vec<CString> = argv
.iter()
.map(|s| CString::new(s.as_str()).unwrap_or_default())
.collect();
let c_prog = cstrings[0].clone();
let c_refs: Vec<&CString> = cstrings.iter().collect();
match user {
None => {
// No privilege drop: exec directly, PID 1 becomes the command.
let Err(e) = execvp(&c_prog, &c_refs);
warn(&format!("execvp {}: {e}", argv[0]));
let _ = reboot(RebootMode::RB_POWER_OFF);
std::process::exit(1);
}
Some(user) => {
// Drop privileges in a child so PID 1 (root) can poweroff afterwards.
match unsafe { fork() } {
Ok(ForkResult::Child) => {
// setgid before setuid to avoid losing privileges
if let Err(e) = setgid(user.gid) {
warn(&format!("setgid: {e}"));
}
if let Err(e) = setuid(user.uid) {
warn(&format!("setuid: {e}"));
}
let Err(e) = execvp(&c_prog, &c_refs);
warn(&format!("execvp {}: {e}", argv[0]));
std::process::exit(127);
}
Ok(ForkResult::Parent { child }) => {
let _ = waitpid(child, None);
let _ = reboot(RebootMode::RB_POWER_OFF);
std::process::exit(0);
}
Err(e) => {
warn(&format!("fork: {e}"));
let _ = reboot(RebootMode::RB_POWER_OFF);
std::process::exit(1);
}
}
}
}
}
fn main() {
ensure_path();
// === Devices & special filesystems ===
setup_filesystems();
// === Rootless container prerequisites (best-effort) ===
setup_rootless_prereqs();
// === Timezone ===
setup_timezone();
// === Networking ===
setup_networking();
// === 9p shares ===
let workdir = read_file_opt("/boco/workdir").unwrap_or_default();
setup_9p_shares(&workdir);
// === Environment & working directory ===
apply_env();
apply_workdir();
// === Resolve user for privilege drop ===
let user = read_file_opt("/boco/user").and_then(|spec| resolve_user(&spec));
// === Execute the configured command ===
let argv = read_argv();
exec_command(&argv, user);
}
+4081
View File
File diff suppressed because it is too large Load Diff
Submodule
+1
Submodule kernel/linux added at 8d3ae59288
+210
View File
@@ -0,0 +1,210 @@
//! Build: mount a podman image, inject /boco/ scripts, and pack the rootfs
//! as a gzipped cpio initrd or qcow2 disk in the registry.
use anyhow::{Context, Result, bail};
use clap::{Args, ValueEnum};
use flate2::write::GzEncoder;
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::NamedTempFile;
use crate::command::cmd;
use crate::inject;
use crate::registry::registry_dir;
#[derive(Args, Debug)]
pub struct BuildCmd {
kind: ImageKind,
/// Image tag / registry subdir name
name: String,
/// Override the command to exec in the VM. Inferred from the image's
/// CMD/ENTRYPOINT if not provided.
#[clap(long)]
cmd: Option<String>,
}
#[derive(ValueEnum, Clone, Debug)]
enum ImageKind {
Qcow2,
Initrd,
}
pub(crate) fn build(
BuildCmd {
kind,
name,
cmd: cmd_override,
}: BuildCmd,
) -> Result<()> {
let image = &name;
let container = format!("boco-build-{}", image.replace(':', "-"));
let mount_path = mount_container(image, &container)?;
println!("Mounted at: {}", mount_path.display());
let result = build_inner(&kind, image, &mount_path, cmd_override);
let unmounted = cmd(&[
"podman",
"unshare",
"--",
"podman",
"container",
"unmount",
&container,
])
.is_ok();
let removed = cmd(&["podman", "rm", &container]).is_ok();
if unmounted && removed {
println!("Unmounted and removed container.");
} else {
eprintln!("warning: failed to clean up container '{container}'");
}
result
}
fn build_inner(
kind: &ImageKind,
image: &str,
mount_path: &Path,
cmd: Option<String>,
) -> Result<()> {
let config = inject::inspect_config(image)?;
let argv = match cmd {
Some(s) => vec!["/bin/sh".into(), "-c".into(), s],
None => inject::infer_argv(&config),
};
inject::inject(
mount_path,
&argv,
&config.env,
config.user.as_deref(),
config.working_dir.as_deref(),
)?;
println!("Injected /boco/ (init + exec)");
match kind {
ImageKind::Initrd => build_initrd(image, mount_path),
ImageKind::Qcow2 => build_qcow2(image, mount_path),
}
}
fn mount_container(image: &str, container: &str) -> Result<PathBuf> {
cmd(&["podman", "create", "--name", container, image, "/bin/true"])?;
let mount_path = cmd(&[
"podman",
"unshare",
"--",
"podman",
"container",
"mount",
container,
])?;
Ok(PathBuf::from(mount_path.trim()))
}
/// Copy a directory onto a new raw disk image with EXT4.
fn to_raw_ext4(dir: &Path, tmp_dir: &Path) -> Result<NamedTempFile> {
let dir = dir.to_str().context("Invalid UTF-8")?;
let du_out = cmd(&["podman", "unshare", "--", "du", "-sk", dir])?;
let used_kb: u64 = du_out
.split_whitespace()
.next()
.context("du produced no output")?
.parse()
.context("failed to parse du output")?;
let used = used_kb * 1024;
let gb = 1024 * 1024 * 1024;
let size = used
+ (used / 10) // Add 10%
// TODO: make configurable
+ 64 * gb; // Add some spare capacity for activities
let size = size.to_string();
let raw_file = NamedTempFile::new_in(tmp_dir)?;
let raw_path = raw_file.path().to_str().context("Invalid UTF-8")?;
cmd(&["podman", "unshare", "--", "truncate", "-s", &size, raw_path])?;
cmd(&[
"podman",
"unshare",
"--",
"mkfs.ext4",
"-F",
"-d",
dir,
raw_path,
])?;
Ok(raw_file)
}
/// Copy a directory onto a new qcow2 disk image with EXT4.
fn to_qcow2_ext4(mount_path: &Path, tmp_dir: &Path) -> Result<NamedTempFile> {
let raw = to_raw_ext4(mount_path, tmp_dir)?;
let qcow2 = NamedTempFile::new_in(tmp_dir)?;
let raw_path = raw.path().to_str().context("Invalid UTF-8")?;
let qcow2_path = qcow2.path().to_str().context("Invalid UTF-8")?;
cmd(&[
"qemu-img", "convert", "-f", "raw", "-O", "qcow2", raw_path, qcow2_path,
])?;
Ok(qcow2)
}
fn build_qcow2(image: &str, mount_path: &Path) -> Result<()> {
let reg_dir = registry_dir(image)?;
fs::create_dir_all(&reg_dir)?;
println!("Registry: {}", reg_dir.display());
let qcow2 = to_qcow2_ext4(mount_path, &reg_dir)?;
fs::copy(qcow2.path(), reg_dir.join("image.qcow2"))
.context("Failed to copy qcow2 image to registry")?;
Ok(())
}
fn build_initrd(image: &str, mount_path: &Path) -> Result<()> {
let mount_path = mount_path.to_str().context("mount path is not UTF-8")?;
let reg_dir = registry_dir(image)?;
fs::create_dir_all(&reg_dir)?;
println!("Registry: {}", reg_dir.display());
// Pack the rootfs as a gzipped newc cpio archive. The cpio pipeline
// runs inside the namespace, its stdout is compressed here.
let initrd_path = reg_dir.join("initrd");
let script = r#"cd "$1" && find . | cpio -o -H newc"#;
let mut cpio = Command::new("podman")
.args(["unshare", "--", "sh", "-c", script, "sh", mount_path])
.stdout(Stdio::piped())
.spawn()
.context("Failed to spawn the cpio pipeline")?;
let initrd_file = File::create(&initrd_path).context("Failed to create initrd file")?;
let mut encoder = GzEncoder::new(initrd_file, flate2::Compression::default());
let cpio_stdout = cpio.stdout.take().context("cpio stdout was not piped")?;
let copied = io::copy(&mut io::BufReader::new(cpio_stdout), &mut encoder);
let stream_result = copied
.and_then(move |_| encoder.finish().map(|_| ()))
.context("Failed to write the gzipped initrd");
let cpio_status = cpio
.wait()
.context("Failed to wait for the cpio pipeline")?;
if let Err(e) = stream_result {
let _ = fs::remove_file(&initrd_path);
return Err(e);
}
if !cpio_status.success() {
let _ = fs::remove_file(&initrd_path);
bail!("find/cpio pipeline failed with status {cpio_status}");
}
println!("Created initrd -> {}", initrd_path.display());
Ok(())
}
+31
View File
@@ -0,0 +1,31 @@
use std::process::Command;
use anyhow::{Context, anyhow, bail};
/// Spawn a subprocess with arguments.
///
/// Collects `stdout` and returns it on success.
/// Returns `Err` if the exit code isn't 0, or if the process failed to spawn.
pub fn cmd(cmd: &[&str]) -> anyhow::Result<String> {
let [cmd, args @ ..] = cmd else {
bail!("missing command");
};
let mut command = Command::new(cmd);
command.args(args);
println!("$ {command:?}");
let output = command
.output()
.with_context(|| anyhow!("Failed to run {command:?}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{command:?} failed");
eprintln!("stdout:\n{stdout}\n");
eprintln!("stderr:\n{stderr}\n");
bail!("{command:?} failed with exit code {}", output.status);
}
Ok(stdout.to_string())
}
+121
View File
@@ -0,0 +1,121 @@
use std::{
fmt::{self, Display},
net::{Ipv4Addr, SocketAddr},
str::FromStr,
};
use anyhow::{Context, anyhow, bail};
use serde::{Deserialize, Serialize, de::Error};
#[derive(Clone, Copy, Debug)]
pub enum Protocol {
Tcp,
Udp,
}
#[derive(Clone, Copy, Debug)]
pub struct PortForward {
pub protocol: Protocol,
pub from: SocketAddr,
pub to_addr: Option<Ipv4Addr>,
pub to_port: u16,
}
impl Display for Protocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Protocol::Tcp => "tcp",
Protocol::Udp => "udp",
})
}
}
impl Display for PortForward {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}-", self.protocol, self.from)?;
if let Some(to_addr) = self.to_addr {
write!(f, ":{}", to_addr)?;
}
write!(f, ":{}", self.to_port)
}
}
impl FromStr for PortForward {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let result = || {
let (protocol, s) = s.split_once(':').context("Missing ':'")?;
let (from, to) = s.split_once('-').context("Missing '-'")?;
let (to_addr, to_port) = to.split_once(':').context("Missing ':'")?;
let from = if let Some(from) = from.strip_prefix(':') {
let port: u16 = from.parse().context("Invalid port number")?;
SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port)
} else {
from.parse()?
};
let to_addr = if to_addr.is_empty() {
None
} else {
Some(to_addr.parse()?)
};
Ok(PortForward {
from,
to_addr,
to_port: to_port.parse()?,
protocol: match protocol {
"udp" => Protocol::Udp,
"tcp" => Protocol::Tcp,
_ => bail!("Invalid protocol, expected 'udp' or 'tcp'"),
},
})
};
result()
.with_context(|| anyhow!("Expected '(tcp|udp):<address>-<address>', got {s:?}"))
.context("Malformed PortForward string")
}
}
impl Serialize for PortForward {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
format_args!("{}", self).serialize(serializer)
}
}
impl<'de> Deserialize<'de> for PortForward {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
s.parse().map_err(D::Error::custom)
}
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use super::PortForward;
#[test]
fn parse_port_forwards() {
let valid = [
"tcp:0.0.0.0:80-:8080",
"udp::1234-:1234",
"tcp:0.0.0.0:80-1.2.3.4:8080",
"udp::1234-255.255.255.255:1234",
];
for valid in valid {
PortForward::from_str(valid).expect("Failed to parse a valid port forward string");
}
}
}
+34
View File
@@ -0,0 +1,34 @@
use std::fs;
use crate::registry::{registry_base_dir, validate_image_name};
use anyhow::Context;
use clap::Subcommand;
#[derive(Subcommand, Debug)]
pub enum ImageCmd {
Ls,
Rm { image: String },
}
pub fn run(cmd: ImageCmd) -> anyhow::Result<()> {
match cmd {
ImageCmd::Ls => ls(),
ImageCmd::Rm { image } => rm(&image),
}
}
fn rm(image: &str) -> Result<(), anyhow::Error> {
validate_image_name(image)?;
let image_dir = registry_base_dir()?.join(image);
fs::remove_dir_all(image_dir).context("Failed to remove image dir")?;
Ok(())
}
fn ls() -> anyhow::Result<()> {
let dir = fs::read_dir(registry_base_dir()?)?;
for entry in dir {
let entry = entry?.file_name();
println!(" {}", entry.to_string_lossy());
}
Ok(())
}
+115
View File
@@ -0,0 +1,115 @@
//! Inject: inspect a podman image's config, infer the default command argv,
//! and inject the boco init binary + null-delimited argv/env files into the
//! mounted rootfs.
use anyhow::{Context, Result, anyhow};
use serde::Deserialize;
use std::fs;
use std::path::Path;
use crate::command::cmd;
use crate::registry::init_binary_path;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Config {
#[serde(default)]
pub cmd: Vec<String>,
#[serde(default)]
pub entrypoint: Vec<String>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub user: Option<String>,
}
pub fn inspect_config(image: &str) -> Result<Config> {
let json = cmd(&[
"podman",
"image",
"inspect",
image,
"--format",
"{{json .Config}}",
])?;
let config: Config = serde_json::from_str(json.trim())
.with_context(|| anyhow!("failed to parse image inspect output for '{image}'"))?;
Ok(config)
}
/// Infer the command argv from the image config.
///
/// Concatenates ENTRYPOINT + CMD (Docker semantics). If neither is present,
/// falls back to `["/bin/sh"]`.
pub fn infer_argv(config: &Config) -> Vec<String> {
let parts: Vec<String> = config
.entrypoint
.iter()
.chain(config.cmd.iter())
.cloned()
.collect();
if parts.is_empty() {
vec!["/bin/sh".into()]
} else {
parts
}
}
/// Join items as null-delimited bytes.
pub fn null_delimited(items: &[String]) -> Vec<u8> {
let mut data = Vec::new();
for item in items {
data.extend_from_slice(item.as_bytes());
data.push(0);
}
data
}
/// Install `content` into the mounted rootfs at `<mount>/boco/<name>` with
/// the given mode.
fn install_into_rootfs(mount: &str, name: &str, mode: &str, content: &[u8]) -> Result<()> {
let temp = tempfile::NamedTempFile::new()?;
fs::write(temp.path(), content)?;
let src = temp.path().to_str().context("temp path is not UTF-8")?;
let dest = format!("{mount}/boco/{name}");
cmd(&[
"podman", "unshare", "--", "install", "-D", "-m", mode, src, &dest,
])?;
Ok(())
}
/// Inject /boco/init (binary), /boco/exec (null-delimited argv), /boco/env
/// (null-delimited env), and optionally /boco/user and /boco/workdir into a
/// mounted container image rootfs.
pub fn inject(
mount_path: &Path,
argv: &[String],
env: &[String],
user: Option<&str>,
working_dir: Option<&str>,
) -> Result<()> {
let mount_str = mount_path.to_str().context("mount path is not UTF-8")?;
let init_binary = fs::read(init_binary_path()?).context("Failed to read boco-init binary")?;
let exec_data = null_delimited(argv);
let env_data = null_delimited(env);
install_into_rootfs(mount_str, "init", "755", &init_binary)?;
install_into_rootfs(mount_str, "exec", "644", &exec_data)?;
if !env_data.is_empty() {
install_into_rootfs(mount_str, "env", "644", &env_data)?;
}
if let Some(user) = user.filter(|u| !u.is_empty()) {
install_into_rootfs(mount_str, "user", "644", user.as_bytes())?;
}
if let Some(dir) = working_dir.filter(|d| !d.is_empty()) {
install_into_rootfs(mount_str, "workdir", "644", dir.as_bytes())?;
}
Ok(())
}
+25 -183
View File
@@ -1,12 +1,18 @@
use anyhow::{Context, Result};
mod build;
mod command;
mod forward;
mod image;
mod inject;
mod qemu;
mod registry;
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::{build::BuildCmd, image::ImageCmd, qemu::BootCmd};
#[derive(Parser, Debug)]
#[command(name = "slim")]
#[command(about = "Build bootable initrd VMs from container images")]
#[command(about = "Turn containers into bootable VMs")]
struct Cli {
#[command(subcommand)]
command: Commands,
@@ -15,190 +21,26 @@ struct Cli {
#[derive(Subcommand, Debug)]
enum Commands {
/// Build initrd from an image (podman image must exist)
Build {
/// Image name (e.g. alpine:3.15 or slim:tag)
image: String,
/// Size hint (unused in v1 initrd)
#[arg(short = 's', long, default_value = "512")]
_size: String,
},
/// Launch the VM via QEMU using registry artifacts
Run {
/// Image tag / registry subdir name
name: String,
},
Build(BuildCmd),
/// Launch a previously built VM via QEMU.
Boot(BootCmd),
/// List boco images in the registry
#[command(subcommand)]
Image(ImageCmd),
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Build { image, _size: _ } => {
build(&image)?;
Commands::Build(cmd) => {
build::build(cmd)?;
}
Commands::Run { name } => {
run(&name)?;
Commands::Boot(cmd) => {
qemu::boot(cmd)?;
}
Commands::Image(cmd) => {
image::run(cmd)?;
}
}
Ok(())
}
fn registry_dir(image: &str) -> PathBuf {
let base = xdg::BaseDirectories::with_prefix("slim-rs");
let initrd_path = base.place_data_file(format!("registry/{}/initrd", image));
initrd_path.unwrap().parent().unwrap().to_path_buf()
}
fn build(image: &str) -> Result<()> {
let mount_path = mount_image(image)?;
println!("Mounted at: {}", mount_path.display());
// Keep the build result so the image is unmounted even when the build fails.
let result = build_artifacts(image, &mount_path);
let unmounted = Command::new("podman")
.args(["unshare", "--", "podman", "image", "unmount", image])
.output()
.is_ok_and(|out| out.status.success());
if unmounted {
println!("Unmounted image.");
} else {
eprintln!("warning: failed to unmount image '{}'", image);
}
result
}
fn mount_image(image: &str) -> Result<PathBuf> {
let output = Command::new("podman")
.args(["unshare", "--", "podman", "image", "mount", image])
.output()
.context("Failed to run podman image mount")?;
let mount_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !output.status.success() || mount_path.is_empty() {
anyhow::bail!(
"podman image mount failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(PathBuf::from(mount_path))
}
fn build_artifacts(image: &str, mount_path: &Path) -> Result<()> {
let mount_path = mount_path.to_str().context("mount path is not UTF-8")?;
let vmlinuz_src = format!("{mount_path}/vmlinuz");
// The rootless overlay mount only exists inside podman unshare's user
// namespace, so every read of the rootfs must run there; pipes still
// cross the namespace boundary.
let has_kernel = Command::new("podman")
.args(["unshare", "--", "test", "-f", &vmlinuz_src])
.status()
.context("Failed to check for /vmlinuz inside the image mount")?;
if !has_kernel.success() {
anyhow::bail!(
"Image missing required /vmlinuz. Place kernel at /vmlinuz in Containerfile."
);
}
let reg_dir = registry_dir(image);
fs::create_dir_all(&reg_dir)?;
println!("Registry: {}", reg_dir.display());
let vmlinuz_dst = reg_dir.join("vmlinuz");
let cp_status = Command::new("podman")
.args([
"unshare",
"--",
"cp",
&vmlinuz_src,
vmlinuz_dst.to_str().context("registry path is not UTF-8")?,
])
.status()
.context("Failed to copy vmlinuz out of the image mount")?;
if !cp_status.success() {
anyhow::bail!("Failed to copy vmlinuz to registry");
}
println!("Copied vmlinuz -> {}", vmlinuz_dst.display());
// Pack the rootfs as a gzipped newc cpio archive; vmlinuz is excluded
// because QEMU loads the kernel separately via -kernel. The cpio
// pipeline runs inside the namespace, its stdout is compressed here.
let initrd_path = reg_dir.join("initrd");
let script = r#"cd "$1" && find . -not -path ./vmlinuz | cpio -o -H newc"#;
let mut cpio = Command::new("podman")
.args(["unshare", "--", "sh", "-c", script, "sh", mount_path])
.stdout(Stdio::piped())
.spawn()
.context("Failed to spawn the cpio pipeline")?;
let initrd_file = File::create(&initrd_path).context("Failed to create initrd file")?;
let mut encoder = flate2::write::GzEncoder::new(initrd_file, flate2::Compression::default());
let cpio_stdout = cpio.stdout.take().context("cpio stdout was not piped")?;
let copied = std::io::copy(&mut std::io::BufReader::new(cpio_stdout), &mut encoder);
let stream_result = copied
.and_then(move |_| encoder.finish().map(|_| ()))
.context("Failed to write the gzipped initrd");
let cpio_status = cpio
.wait()
.context("Failed to wait for the cpio pipeline")?;
if let Err(e) = stream_result {
let _ = fs::remove_file(&initrd_path);
return Err(e);
}
if !cpio_status.success() {
let _ = fs::remove_file(&initrd_path);
anyhow::bail!("find/cpio pipeline failed with status {cpio_status}");
}
println!("Created initrd -> {}", initrd_path.display());
Ok(())
}
fn run(name: &str) -> Result<()> {
let reg_dir = registry_dir(name);
let vmlinuz_path = reg_dir.join("vmlinuz");
let initrd_path = reg_dir.join("initrd");
if !vmlinuz_path.exists() || !initrd_path.exists() {
anyhow::bail!(
"Registry missing vmlinuz/initrd for '{}'. Run `slim build` first.",
name
);
}
println!("Booting {} from registry: {}", name, reg_dir.display());
println!(
" qemu-system-x86_64 -m 256 -nographic -kernel {} -initrd {} -append 'console=ttyS0'",
vmlinuz_path.display(),
initrd_path.display()
);
// Launch with a 5-second timeout for quick smoke verification
let status = Command::new("timeout")
.args([
"5",
"qemu-system-x86_64",
"-m",
"256",
"-nographic",
"-kernel",
vmlinuz_path.to_str().unwrap(),
"-initrd",
initrd_path.to_str().unwrap(),
"-append",
"console=ttyS0",
])
.status()
.context("Failed to launch qemu-system-x86_64")?;
// `timeout` exits 124 when it kills QEMU after the 5s smoke-test
// window; any other non-zero status is a real failure.
match status.code() {
Some(0) => println!("QEMU exited cleanly."),
Some(124) => println!("QEMU timed out after 5s (expected during smoke boot)."),
Some(126) | Some(127) => {
anyhow::bail!("Failed to start qemu-system-x86_64 (is it installed and in PATH?)")
}
Some(code) => anyhow::bail!("QEMU exited with code {code}"),
None => anyhow::bail!("QEMU terminated by a signal"),
}
Ok(())
}
+165
View File
@@ -0,0 +1,165 @@
//! QEMU: boot a registry entry with qemu-system-x86_64 using direct kernel
//! boot (-kernel/-initrd or -drive). The kernel is shared across all VMs
//! at $XDG_DATA_HOME/boco/registry/vmlinuz. A universal /boco/init script (injected
//! at build time) handles VM bootstrap; /boco/exec runs the container's
//! CMD/ENTRYPOINT. Runtime overrides are delivered as `boco.cmd=<base64>`
//! on the kernel cmdline.
use anyhow::{Context, Result, bail};
use base64::Engine;
use clap::Args;
use std::{fmt::Write as _, process::Command};
use crate::{
forward::PortForward,
registry::{registry_base_dir, registry_dir},
};
#[derive(Args, Debug)]
pub struct BootCmd {
/// Image tag / registry subdir name
name: String,
/// Amount of memory to give the VM, in qemu's format.
#[clap(short, long, default_value = "1024M")]
memory: String,
/// Forward ports from host to guest. Example: `tcp:0.0.0.0:80-:8080`
#[clap(long)]
forward: Vec<PortForward>,
/// Override the command to exec in the VM (base64-encoded on the kernel
/// cmdline as boco.cmd=<b64>). Overrides the CMD inferred at build time.
#[clap(long)]
cmd: Option<String>,
/// Share a host directory into the VM via 9p. Format:
/// `<host-path>` or `<host-path>:<guest-path>`. When the guest path is
/// omitted, the host path is used. Non-absolute guest paths are
/// relative to the image's WORKINGDIR. Can be repeated.
#[clap(long)]
mount: Vec<String>,
}
pub(crate) fn boot(
BootCmd {
name,
memory,
forward,
cmd,
mount,
}: BootCmd,
) -> Result<()> {
let reg_dir = registry_dir(&name)?;
let vmlinuz_path = registry_base_dir()?.join("vmlinuz");
let initrd_path = reg_dir.join("initrd");
let qcow2_path = reg_dir.join("image.qcow2");
if !vmlinuz_path.exists() {
// TODO: guide user in how to set up a kernel
bail!(
"No kernel available. Place a vmlinuz at {}",
vmlinuz_path.display()
);
}
let mut cmdline: Vec<String> = vec![
"console=ttyS0,115200".into(),
"rw".into(),
"earlyprintk=serial".into(),
"nokaslr".into(),
"init=/boco/init".into(),
"devtmpfs.mount=1".into(), // Automatically mount /dev at boot
];
let fs_args;
let qcow2_arg = format!("file={},format=qcow2,if=virtio", qcow2_path.display());
if qcow2_path.exists() {
fs_args = vec!["-drive".to_string(), qcow2_arg];
cmdline.extend(["root=/dev/vda".to_string(), "rootfstype=ext4".to_string()]);
} else if initrd_path.exists() {
let initrd_str = initrd_path.to_str().context("Invalid UTF-8")?.to_string();
fs_args = vec!["-initrd".to_string(), initrd_str];
cmdline.push("root=/dev/ram0".into());
} else {
bail!("Registry missing rootfs/initrd for '{name}'. Run `boco build` first.");
}
if let Some(cmd) = &cmd {
let argv = ["/bin/sh".to_string(), "-c".to_string(), cmd.clone()];
let data: Vec<u8> = argv.join("\0").into_bytes();
let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
cmdline.push(format!("boco.cmd={encoded}"));
}
// Build 9p shares for each --mount. Tags are short (boco0, boco1, …)
// because 9p mount_tag has a ~31-byte limit. The guest destination path
// is passed on the kernel cmdline as boco.mount=<tag>:<base64(guest_path)>.
// If no guest path is specified, the host path is used as the guest path.
let mut virtfs_args: Vec<String> = Vec::new();
for (i, spec) in mount.iter().enumerate() {
let (host_path, guest_path) = match spec.split_once(':') {
Some((h, g)) => (h, g),
None => (spec.as_str(), spec.as_str()),
};
let canonical = std::path::Path::new(host_path)
.canonicalize()
.with_context(|| format!("Cannot resolve mount path '{host_path}'"))?;
let host_str = canonical
.to_str()
.context("Mount path is not valid UTF-8")?;
let tag = format!("boco{i}");
let dest_b64 = base64::engine::general_purpose::STANDARD.encode(guest_path.as_bytes());
// QEMU's QemuOpts splits on commas — escape literal commas in the
// path as ",," per QEMU convention.
let host_escaped = host_str.replace(',', ",,");
virtfs_args.push("-virtfs".into());
virtfs_args.push(format!(
"local,path={host_escaped},mount_tag={tag},security_model=mapped-xattr"
));
cmdline.push(format!("boco.mount={tag}:{dest_b64}"));
}
println!("Booting {name} from registry: {}", reg_dir.display());
let cmdline = cmdline.join(" ");
// add NIC and forward any user specified ports
let mut network = "user,model=virtio-net-pci".to_string();
for forward in &forward {
_ = write!(&mut network, ",hostfwd={forward}");
}
let mut command = Command::new("qemu-system-x86_64");
// TODO: make a lot of this configurable. especially -cpus
command
.args(["-accel", "kvm"])
.args(["-cpu", "host", "-smp", "cpus=8"])
.args(["-m", &memory])
.arg("-kernel")
.arg(vmlinuz_path.as_os_str())
.args(&fs_args)
.args(&virtfs_args)
.args(["-snapshot"])
.args(["-no-reboot"])
.args(["-append", &cmdline])
.args(["-nographic"])
.args(["-nic", &network]);
println!("{command:?}");
let status = command
.status()
.context("Failed to launch qemu-system-x86_64")?;
match status.code() {
Some(0) => println!("QEMU exited cleanly."),
Some(126) | Some(127) => {
bail!("Failed to start qemu-system-x86_64 (is it installed and in PATH?)")
}
Some(code) => bail!("QEMU exited with code {code}"),
None => bail!("QEMU terminated by a signal"),
}
Ok(())
}
+96
View File
@@ -0,0 +1,96 @@
//! Registry: local storage of built VM artifacts under
//! `$XDG_DATA_HOME/boco/registry/<image>/`.
use anyhow::{Context, Result, anyhow, bail};
use std::{fs, io, path::PathBuf};
/// Validate a user-supplied image name before it is used in a registry path.
///
/// The name becomes a directory under `$XDG_DATA_HOME/boco/registry/`, so
/// path separators and `.`/`..` would allow escaping that directory.
/// Allow-list: ASCII alphanumerics plus `.`, `_`, `:`, `-` (covers image
/// refs like `alpine:3.15`).
pub(crate) fn validate_image_name(name: &str) -> Result<()> {
let valid = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-'));
if !valid {
bail!(
"invalid image name '{name}': must be non-empty and contain only \
alphanumerics, '.', '_', ':' and '-' (no path separators or '..')"
);
}
Ok(())
}
pub(crate) fn registry_base_dir() -> Result<PathBuf> {
let base = xdg::BaseDirectories::with_prefix("boco");
base.create_data_directory("registry")
.context("Failed to create XDG_DATA_HOME subdirectory")
}
pub(crate) fn init_binary_path() -> Result<PathBuf> {
let base = xdg::BaseDirectories::with_prefix("boco");
base.get_data_file("boco-init")
.context("boco-init not found in XDG data dir")
}
pub(crate) fn registry_dir(image: &str) -> Result<PathBuf> {
validate_image_name(image)?;
let base = registry_base_dir()?;
let reg_dir = base.join(image);
fs::create_dir(&reg_dir)
.or_else(|e| {
(e.kind() == io::ErrorKind::AlreadyExists)
.then_some(())
.ok_or(e)
})
.with_context(|| anyhow!("Failed to create {reg_dir:?}"))?;
Ok(reg_dir)
}
#[cfg(test)]
mod tests {
use super::validate_image_name;
#[test]
fn accepts_valid_image_names() {
for name in [
"alpine:3.15",
"boco",
"mock-vm",
"my_vm",
"a.b.c",
"vm-1.2.3",
] {
validate_image_name(name).unwrap_or_else(|e| panic!("{name} rejected: {e}"));
}
}
#[test]
fn rejects_traversal_and_separators() {
for name in [
"",
".",
"..",
"../evil",
"../../tmp/evil",
"a/b",
"/abs",
"a\\b",
"foo bar",
"vm;rm",
"vm\n",
] {
assert!(
validate_image_name(name).is_err(),
"{name:?} should be rejected"
);
}
}
}