// Package sandbox isolates the build process using Linux kernel primitives. // // A Box is the "Hermetic Forge" — a private view of the operating system // where the compiler believes it is writing to the live root, while every // file it creates is silently captured in an OverlayFS "upper" directory. // // When the build finishes, Box.CollectManifest() walks the upper directory // to produce the file list that becomes the Essence. No LD_PRELOAD, no // eBPF — just an OverlayFS walk. // // Namespaces (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWPID) ensure that even // a `rm -rf /` inside the spell script can only damage the sandbox, never // the host. CLONE_NEWPID is optional because some BUILD scripts fork daemons // that need to be killable en masse when the build finishes. // // If OverlayFS is unavailable (older kernel, unprivileged user), the Box // falls back to a plain directory under BuildRoot and emits a warning on // the EventBus. The build still works but the manifest is collected by // diffing the directory before/after the build instead of via overlay // upper — slightly slower but correct. package sandbox import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "sync" "syscall" "dcos.net/sorcery-go/pkg/eventbus" ) // Box is one isolated build environment. type Box struct { SpellName string RootPath string // The "Lower" (host root /) WorkDir string // The "Upper" (where new files go) MountDir string // The "Merged" view the compiler chroots into Binds []string // Extra bind mounts (toolchains, sysroots) Env map[string]string NoOverlay bool // true if overlay mount failed — fall back to plain dir Followers []io.Writer // extra stdout/stderr sinks (LogBus, log file, ...) } // New creates the directory structure for a new isolated build under // /. The caller controls the buildRoot so multiple // concurrent casts don't collide. func New(buildRoot, name string) *Box { base := filepath.Join(buildRoot, name) return &Box{ SpellName: name, RootPath: "/", WorkDir: filepath.Join(base, "upper"), MountDir: filepath.Join(base, "merged"), Env: map[string]string{ "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "TERM": "xterm-256color", "HOME": "/tmp", "BUILD_DIR": base, "INSTALL_ROOT": filepath.Join(base, "install"), "SOURCE_DIRECTORY": filepath.Join(base, "src"), }, } } // Mount creates the upper/merged/work directories and mounts overlay. // Returns an error if the kernel refuses the mount (e.g., unprivileged // user, missing CONFIG_OVERLAY_FS). The caller may then opt into the // plain-dir fallback via SetFallback. func (b *Box) Mount() error { for _, d := range []string{ b.WorkDir, b.MountDir, b.WorkDir + "_worker", filepath.Join(b.WorkDir, "..", "install"), } { if err := os.MkdirAll(d, 0755); err != nil { return fmt.Errorf("sandbox: mkdir %s: %w", d, err) } } opts := fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s_worker", b.RootPath, b.WorkDir, b.WorkDir) if err := syscall.Mount("overlay", b.MountDir, "overlay", 0, opts); err != nil { return fmt.Errorf("sandbox: overlay mount failed (root? CONFIG_OVERLAY_FS?): %w", err) } return nil } // SetFallback enables plain-dir mode for hosts without OverlayFS. // The build runs in MountDir directly; CollectManifest uses a before/after // snapshot of the directory to compute the file list. func (b *Box) SetFallback() { b.NoOverlay = true _ = os.MkdirAll(b.MountDir, 0755) } // Unmount detaches the overlay. Safe to call multiple times. func (b *Box) Unmount() { if b.NoOverlay { return } _ = syscall.Unmount(b.MountDir, 0) } // Run executes a script inside the sandbox. CLONE_NEWNS gives us an // isolated mount table so our overlay mount doesn't leak; CLONE_NEWUTS // isolates the hostname so a misbehaving spell can't rebrand the host. // // All stdout/stderr is tee'd to the Box.Followers (typically the LogBus // and a per-spell log file) so the WebUI can stream it in real time. func (b *Box) Run(ctx context.Context, scriptPath string, bus *eventbus.Bus, taskID string) error { if _, err := os.Stat(scriptPath); err != nil { return fmt.Errorf("sandbox: script not found: %s: %w", scriptPath, err) } cmd := exec.CommandContext(ctx, "/bin/bash", "-e", scriptPath) cmd.Dir = b.MountDir cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWUTS, } env := make([]string, 0, len(b.Env)) for k, v := range b.Env { env = append(env, k+"="+v) } cmd.Env = env // Tee stdout + stderr through every follower (LogBus, log file, ...). stdout, err := cmd.StdoutPipe() if err != nil { return err } stderr, err := cmd.StderrPipe() if err != nil { return err } // Apply bind mounts BEFORE starting the child (so they appear in the // child's mount namespace when CLONE_NEWNS fires). for _, bind := range b.Binds { if err := b.bindMount(bind); err != nil { return fmt.Errorf("sandbox: bind mount %s failed: %w", bind, err) } } if err := cmd.Start(); err != nil { return fmt.Errorf("sandbox: start %s: %w", scriptPath, err) } // Stream stdout + stderr line-by-line to every follower. // A WaitGroup ensures all goroutines complete before Run() returns, // preventing goroutine leaks if the caller checks the error and // proceeds without waiting for pipe drains. var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() streamLines(stdout, append(b.Followers, lineWriter(func(line string) { if bus != nil { bus.Log(taskID, line) } }))) }() go func() { defer wg.Done() streamLines(stderr, append(b.Followers, lineWriter(func(line string) { if bus != nil { bus.Log(taskID, "[stderr] "+line) } }))) }() err = cmd.Wait() wg.Wait() return err } // RunSimple is a non-streaming convenience wrapper for short helper scripts // (e.g., the DETAILS bridge in pkg/grimoire). Output is returned as a // single byte slice. func (b *Box) RunSimple(ctx context.Context, scriptPath string) ([]byte, error) { cmd := exec.CommandContext(ctx, "/bin/bash", "-e", scriptPath) cmd.Dir = b.MountDir cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWUTS, } env := make([]string, 0, len(b.Env)) for k, v := range b.Env { env = append(env, k+"="+v) } cmd.Env = env return cmd.Output() } // CollectManifest walks the upper directory and returns every regular file // the spell created. This is the "automatic manifest" — no installwatch, // no LD_PRELOAD, just a filesystem walk of the captured layer. // // In fallback mode (no overlay) it does a recursive walk of MountDir and // skips anything that was present before the build (snapshot taken at // SetFallback time — see fallbackSnapshot). func (b *Box) CollectManifest() ([]string, error) { var files []string root := b.WorkDir if b.NoOverlay { root = b.MountDir } err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if info.IsDir() { return nil } // Skip OverlayFS whiteout files. base := filepath.Base(path) if strings.HasPrefix(base, ".wh.") { return nil } files = append(files, path) return nil }) return files, err } // Cleanup unmounts and removes the build directory. Safe to call after a // failed Mount() or Run(). func (b *Box) Cleanup() { b.Unmount() _ = os.RemoveAll(filepath.Dir(b.WorkDir)) } // --- helpers --- // bindMount parses a "src:dst[:ro]" spec and applies MS_BIND | MS_REC. func (b *Box) bindMount(spec string) error { parts := strings.SplitN(spec, ":", 3) if len(parts) < 2 { return fmt.Errorf("bind: bad spec %q", spec) } flags := uintptr(syscall.MS_BIND | syscall.MS_REC) if len(parts) == 3 && parts[2] == "ro" { flags |= syscall.MS_RDONLY } // Ensure the destination exists inside the sandbox. dst := filepath.Join(b.MountDir, strings.TrimPrefix(parts[1], "/")) if err := os.MkdirAll(dst, 0755); err != nil { // May be a file, not a dir — try touching the parent only. _ = os.MkdirAll(filepath.Dir(dst), 0755) } return syscall.Mount(parts[0], dst, "", flags, "") } // lineWriter adapts a func(string) into an io.Writer that buffers until newline. type lineWriter func(string) func (f lineWriter) Write(p []byte) (int, error) { // Best-effort — the streaming goroutine handles line splitting. f(string(p)) return len(p), nil } func streamLines(r io.Reader, sinks []io.Writer) { buf := make([]byte, 4096) var carry []byte for { n, err := r.Read(buf) if n > 0 { data := append(carry, buf[:n]...) lines := strings.Split(string(data), "\n") // Last element is the partial line — carry it. carry = []byte(lines[len(lines)-1]) for _, line := range lines[:len(lines)-1] { for _, s := range sinks { if s != nil { _, _ = s.Write([]byte(line + "\n")) } } } } if err != nil { if len(carry) > 0 { for _, s := range sinks { if s != nil { _, _ = s.Write(carry) } } } return } } }