Files
werkdock/internal/engine/bwrap.go
T
mhoennigandClaude Fable 5 e4bfeacf5a Werkdock skeleton: doctor, load, run over the bwrap engine (Go)
The minimal build-capable CLI decided in RFC 0002's outcome: stdlib-only
Go module, one static binary.

- engine: RunSpec behind the Engine interface (RFC 0001); the Bwrap
  engine ports Werkator's hardened invocation — uid-0 mapping, read-only
  rootfs at /, proc/dev/tmp/root before the user binds so binds below
  them land inside, mountpoint pre-creation in the rootfs including file
  mountpoints, and a guard against binds escaping the rootfs. --clearenv
  gives docker-style clean environments (HOME/PATH set explicitly).
- store: images under $WERKDOCK_HOME (default ~/.werkdock), load
  unpacks via the tar CLI into a tmp dir and renames atomically.
- cli: docker-shaped run flags (-v/-e/-w/--rm); refused docker flags
  (-p, --network, --memory, --cpus, --user, -d) fail loudly with the
  reason; exit codes follow docker (125 CLI errors, child code through).
- doctor: port of werkator-build-prerequisites.sh — userns probe with
  the three signals, tar/zstd, free space and group-quota headroom via
  testable df/quota parsers, same PASS/FAIL output.
- tests: argv golden test, mountpoint and escape tests, flag refusals,
  store round trip, doctor parsers — plus real-sandbox integration
  tests that skip where bwrap or userns are unavailable.

Also records in step 21: RFC 0002 levels 2/3 deferred; next goal is
sandbox builds of Werkator, Werkbaum, and Werkdock itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 07:01:21 +02:00

200 lines
5.4 KiB
Go

package engine
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Bwrap runs a RunSpec through the bwrap CLI — filesystem isolation
// only; network, uid mapping target, /proc, /dev, and /tmp come from
// the host by contract.
//
// The invocation is a port of Werkator's BwrapBuildRunner, including
// the parts hardened on a real Hostsharing webspace: bind mountpoints
// are pre-created inside the rootfs (a plain host directory), because
// bwrap cannot mkdir them against the read-only root bind.
type Bwrap struct {
// Path of the bwrap binary; empty means "bwrap" via PATH.
Path string
// Stdio of the sandboxed command; nil fields default to the
// werkdock process's own.
Stdout io.Writer
Stderr io.Writer
Stdin io.Reader
}
// DefaultPATH is the PATH inside the sandbox; the environment is
// cleared (docker semantics), so a sane default must be set explicitly.
const DefaultPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
// Argv assembles the full bwrap command line for spec.
//
// Mount order: the rootfs first; then /proc, /dev, and the tmpfs
// mounts for /tmp and /root, BEFORE the user binds, so a bind whose
// destination lies below them lands inside instead of being shadowed;
// then the user binds in the given order.
func (b *Bwrap) Argv(spec RunSpec) ([]string, error) {
if spec.RootFS == "" {
return nil, errors.New("rootfs must be set")
}
if !filepath.IsAbs(spec.RootFS) {
return nil, fmt.Errorf("rootfs must be an absolute path: %s", spec.RootFS)
}
if len(spec.Command) == 0 {
return nil, errors.New("no command specified")
}
bin := b.Path
if bin == "" {
bin = "bwrap"
}
args := []string{
bin,
"--unshare-user",
"--unshare-pid",
"--die-with-parent",
"--uid", "0",
"--gid", "0",
"--ro-bind", spec.RootFS, "/",
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
"--tmpfs", "/root",
}
for _, bd := range spec.Binds {
if !filepath.IsAbs(bd.Dest) {
return nil, fmt.Errorf("bind destination must be an absolute path: %s", bd.Dest)
}
flag := "--bind"
if bd.ReadOnly {
flag = "--ro-bind"
}
args = append(args, flag, bd.Source, bd.Dest)
}
args = append(args,
"--clearenv",
"--setenv", "HOME", "/root",
"--setenv", "PATH", DefaultPATH,
)
for _, e := range spec.Env {
args = append(args, "--setenv", e.Key, e.Value)
}
workdir := spec.Workdir
if workdir == "" {
workdir = "/"
}
args = append(args, "--chdir", workdir, "--")
args = append(args, spec.Command...)
return args, nil
}
// EnsureMountpoints pre-creates the mountpoints of spec inside the
// rootfs directory. bwrap creates mountpoints against the sandbox view,
// which is the read-only rootfs bind — every destination missing from
// the rootfs fails with "Read-only file system". The rootfs directory
// itself is a plain host directory, so the mountpoints are created
// there; bwrap then finds them and has nothing left to mkdir.
//
// Anything that already exists in the rootfs is left alone (e.g.
// /etc/resolv.conf is a file many rootfs archives ship). A bind whose
// source is a regular file gets a file mountpoint, not a directory.
func EnsureMountpoints(spec RunSpec) error {
for _, dest := range []string{"/proc", "/dev", "/tmp", "/root"} {
if err := ensureDir(spec.RootFS, dest); err != nil {
return err
}
}
for _, bd := range spec.Binds {
target, err := rootfsPath(spec.RootFS, bd.Dest)
if err != nil {
return err
}
if _, err := os.Lstat(target); err == nil {
continue
}
src, err := os.Stat(bd.Source)
if err != nil {
return fmt.Errorf("bind source %s: %w", bd.Source, err)
}
if src.Mode().IsRegular() {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
if err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
continue
}
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
}
return nil
}
func ensureDir(rootfs, dest string) error {
target, err := rootfsPath(rootfs, dest)
if err != nil {
return err
}
if _, statErr := os.Lstat(target); statErr == nil {
return nil
}
return os.MkdirAll(target, 0o755)
}
// rootfsPath resolves dest inside rootfs and refuses destinations that
// escape it — werkdock assembles mounts from user input, so this must
// hold even for hostile paths.
func rootfsPath(rootfs, dest string) (string, error) {
root := filepath.Clean(rootfs)
target := filepath.Join(root, dest)
prefix := root
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
prefix += string(filepath.Separator)
}
if target != root && !strings.HasPrefix(target, prefix) {
return "", fmt.Errorf("bind destination escapes the rootfs: %s", dest)
}
return target, nil
}
// Run executes spec and returns the command's exit code; bwrap
// propagates the child's code, so the caller can pass it through.
func (b *Bwrap) Run(spec RunSpec) (int, error) {
argv, err := b.Argv(spec)
if err != nil {
return 0, err
}
if err := EnsureMountpoints(spec); err != nil {
return 0, err
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdout = b.Stdout
if cmd.Stdout == nil {
cmd.Stdout = os.Stdout
}
cmd.Stderr = b.Stderr
if cmd.Stderr == nil {
cmd.Stderr = os.Stderr
}
cmd.Stdin = b.Stdin
err = cmd.Run()
if err == nil {
return 0, nil
}
var exit *exec.ExitError
if errors.As(err, &exit) {
return exit.ExitCode(), nil
}
return 0, err
}