Download
157 lines 5.6 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
package sandbox

import (
	"errors"
	"fmt"
	"io/fs"
	"net"
	"os"
	"path/filepath"
	"runtime"
	"slices"
	"strings"

	"blinker/internal/policy"
)

// platform is an OS's sandbox backend: everything about running a command
// in the sandbox that differs from one OS to another. Both implementations
// build on every OS, so each is checked, and tested, everywhere.
type platform interface {
	// Name names the kernel mechanism, for the user and the model.
	Name() string
	// ScratchRoot is where session scratch dirs go unless configured.
	ScratchRoot() (string, error)
	// SystemReadPaths are readable in every mode unless a rule says
	// otherwise: enough of the OS to run programs.
	SystemReadPaths() []string
	// BaselineWritePaths are writable in every mode unless a rule says
	// otherwise.
	BaselineWritePaths() []string
	// SystemSecrets are system files that hold secrets, which the floor
	// hides: the OS's permissions keep them from users, but not from root.
	SystemSecrets() []string
	// UserHomes are every user's home directory, for the floor.
	UserHomes() ([]string, error)
	// SystemTempDirs are the OS's shared temp dirs, which no command may
	// touch: each session has its own (the scratch dir). The one holding
	// the project, if any, is left to the policy, as any other dir is.
	SystemTempDirs() []string
	// TempDir is where commands see the scratch dir.
	TempDir(scratch, project string) string
	// PrepareScratch fills a new scratch dir with what commands would
	// otherwise rebuild in each session. Failing costs only time.
	PrepareScratch(scratch string) error
	// ListenProxy listens for a mode's proxy. dir is an empty directory
	// only blinker can write, for a socket.
	ListenProxy(dir string) (net.Listener, error)
	// Wrap returns the command line that runs w.Argv in the sandbox, the
	// variables it adds to the command's environment, and the empty mount
	// points the sandbox will make on the host, for Run to remove after.
	Wrap(w wrap) (argv, env, made []string, err error)
}

var (
	_ platform = (*linuxPlatform)(nil)
	_ platform = (*darwinPlatform)(nil)
)

// wrap is a command for a platform to put in the sandbox.
type wrap struct {
	Policy *policy.Compiled
	Mode   policy.Mode
	Proxy  net.Addr // the mode's proxy, from ListenProxy; nil for no network
	Argv   []string
	// Create, set only by WriteFile, is a missing path the command may
	// create (see Cmd.create).
	Create string
	// Empty is an empty read-only file of the session's, which bubblewrap
	// mounts in place of protected paths that do not exist (see guard).
	Empty string
	// Terminal is the path of the command's pty (see Cmd.Terminal), or "".
	Terminal string
	// Preview, if set, is where to keep the command's writes instead of
	// making them (see Cmd.Preview).
	Preview *Preview
	// Helper, if set, is a hidden subcommand of blinker's own to run in
	// place of Argv (see Cmd.helper).
	Helper string
}

// hostPlatform is the platform for the OS blinker runs on. exe is
// blinker's own executable.
func hostPlatform(exe string) (platform, error) {
	switch runtime.GOOS {
	case "linux":
		return newLinuxPlatform(exe)
	case "darwin":
		return newDarwinPlatform()
	default:
		return nil, fmt.Errorf("blinker does not support %s: it needs bubblewrap (Linux) or sandbox-exec (macOS)", runtime.GOOS)
	}
}

// passwdFile lists the users, for UserHomes.
var passwdFile = "/etc/passwd" // a variable for tests

// userHomes lists the home directories in a passwd file and the
// directories in dir (/home, /Users), which holds homes of users the
// passwd file may not list (from LDAP, say): by their real paths, and
// not /, a file, or in /proc, /sys or /dev.
func userHomes(passwd, dir string) ([]string, error) {
	var homes []string
	data, err := os.ReadFile(passwd)
	if err != nil && !errors.Is(err, fs.ErrNotExist) {
		return nil, err
	}
	for _, line := range strings.Split(string(data), "\n") {
		if fields := strings.Split(line, ":"); len(fields) >= 6 && !strings.HasPrefix(line, "#") {
			homes = append(homes, fields[5])
		}
	}
	entries, err := os.ReadDir(dir)
	if err != nil && !errors.Is(err, fs.ErrNotExist) {
		return nil, err
	}
	for _, e := range entries {
		homes = append(homes, filepath.Join(dir, e.Name()))
	}
	var out []string
	for _, h := range homes {
		if !filepath.IsAbs(h) || h == "/" {
			continue
		}
		// Real paths, as blinker's own home is, so it can be told apart. A
		// missing home is kept: the floor keeps a command that may write
		// where it would be from making it, with a .ssh of its own.
		h = filepath.Clean(h)
		if real, err := filepath.EvalSymlinks(h); err == nil {
			if fi, err := os.Stat(real); err != nil || !fi.IsDir() {
				continue // such as /dev/null
			}
			h = real
		} else if !errors.Is(err, fs.ErrNotExist) {
			continue // under a file, or unreadable: nothing to make there
		}
		// Not a pseudo file system: a system account's "home" (/proc), where
		// nothing can be made.
		pseudo := policy.Under(h, "/proc") || policy.Under(h, "/sys") || policy.Under(h, "/dev")
		if h != "/" && !pseudo && !policy.IsGlob(h) && policy.ValidatePath(h) == nil && !slices.Contains(out, h) {
			out = append(out, h)
		}
	}
	return out, nil
}

// proxyEnv points a command's HTTP clients at its proxy, on a port of
// localhost.
func proxyEnv(port int) []string {
	url := fmt.Sprintf("http://127.0.0.1:%d", port)
	var env []string
	for _, k := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"} {
		env = append(env, k+"="+url)
	}
	// Node's fetch (24 and later) reads the variables only when told to;
	// earlier ones ignore this.
	return append(env, "NO_PROXY=", "no_proxy=", "NODE_USE_ENV_PROXY=1")
}