123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 | package sandbox
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"syscall"
"blinker/internal/policy"
)
// Preview is what a command run with Cmd.Preview wrote, kept off the host.
// Each writable mount is an overlay, whose upper directory collects the
// command's writes: kept here where they could touch the project, to be
// read as Changes, and dropped elsewhere (caches, the temp dir, say).
// Discard removes what is kept.
type Preview struct {
Changes []Change
mode policy.Mode // what the command ran in, for Apply
policy *policy.Compiled // its own policy, if it had one (see Cmd.Policy)
dir string
layers []layer
mounts map[string]bool // mount points (see bwrapArgs)
}
// layer is an overlay whose upper directory holds the writes to host, which
// the sandbox sees at the same path.
type layer struct{ host, upper string }
// ChangeKind is what a Change does to its path.
type ChangeKind int
const (
Write ChangeKind = iota // a regular file, new, or changed in contents or mode
Mkdir // a directory, new, in place of what was there (Opaque), or changed in mode
Symlink // a symlink, new or pointing elsewhere
Remove // the path, and anything under it, is gone
)
// Change is one change a previewed command made, at a path on the host.
// Each replaces whatever is at Path (a file where a directory was, say),
// but a Mkdir that is not Opaque, which keeps what is in the directory.
// Changes to the same tree come in the order they apply in: a directory
// before what is in it.
type Change struct {
Kind ChangeKind
Path string
Mode fs.FileMode // Write and Mkdir: the permission bits
Target string // Symlink
Size int64 // Write: the new contents' size
// Opaque, for Mkdir, is set when what was at Path is gone, and the
// directory holds only what the changes under it put there.
Opaque bool
// Before is what the preview found at Path: nothing, in a directory
// that is new or Opaque.
Before State
fresh bool // in a directory that is new or Opaque, so not checked by Apply
upper string // Write: where the new contents are
root string // the nearest directory of the layer's on the host, for Apply
rel string // Path, relative to root
}
// State is what is at a path: its Type ("" for nothing), a file's or
// directory's permission bits, and a file's size and SHA-256 or a
// symlink's target. A directory's contents are not part of it.
type State struct {
Type string // "", "file", "dir", "symlink" or "other"
Mode fs.FileMode `json:",omitempty"`
Size int64 `json:",omitempty"`
Sum string `json:",omitempty"`
Target string `json:",omitempty"`
}
// Old reads what was at Path when the preview read it, if that was a
// regular file, for showing the change; nil otherwise. It reads the host
// as it is now, following no symlink.
func (c Change) Old() ([]byte, error) {
if c.Before.Type != "file" {
return nil, nil
}
root, err := os.OpenRoot(c.root)
if err != nil {
return nil, err
}
defer root.Close()
f, err := root.OpenFile(c.rel, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
// stateAt reads what is at a path in root, following no symlink out of it.
func stateAt(root *os.Root, name string) (State, error) {
fi, err := root.Lstat(name)
if errors.Is(err, fs.ErrNotExist) {
return State{}, nil
}
if err != nil {
return State{}, err
}
switch mode := fi.Mode(); {
case mode.IsRegular():
f, err := root.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
return State{}, err
}
defer f.Close()
return fileState(f, mode)
case mode.IsDir():
return State{Type: "dir", Mode: mode.Perm()}, nil
case mode&fs.ModeSymlink != 0:
target, err := root.Readlink(name)
return State{Type: "symlink", Target: target}, err
}
return State{Type: "other"}, nil
}
// fileState is the State of an open regular file.
func fileState(f *os.File, mode fs.FileMode) (State, error) {
h := sha256.New()
n, err := io.Copy(h, f)
return State{Type: "file", Mode: mode.Perm(), Size: n, Sum: hex.EncodeToString(h.Sum(nil))}, err
}
// Contents reads the new contents of a Write.
func (c Change) Contents() ([]byte, error) {
if c.Kind != Write {
return nil, fmt.Errorf("%s is not a written file", c.Path)
}
return os.ReadFile(c.upper)
}
// Discard removes what the preview kept.
func (p *Preview) Discard() error { return removeAll(p.dir) }
// newPreview makes a directory in dir for a preview's layers.
func newPreview(dir string) (*Preview, error) {
p := &Preview{dir: filepath.Join(dir, "preview-"+randomID()), mounts: map[string]bool{}}
return p, os.Mkdir(p.dir, 0o700)
}
// layer makes an overlay's upper and work directories, for writes to host.
func (p *Preview) layer(host string) (upper, work string, err error) {
fi, err := os.Stat(host)
if err != nil {
return "", "", err
}
dir := filepath.Join(p.dir, strconv.Itoa(len(p.layers)))
upper, work = filepath.Join(dir, "upper"), filepath.Join(dir, "work")
for _, d := range []string{dir, upper, work} {
if err := os.Mkdir(d, 0o700); err != nil {
return "", "", err
}
}
// The overlay's root takes its mode from the upper directory.
if err := os.Chmod(upper, fi.Mode().Perm()); err != nil {
return "", "", err
}
p.layers = append(p.layers, layer{host: host, upper: upper})
return upper, work, nil
}
// read lists the changes the layers hold, compared with the host as it is
// now. The command has ended, so nothing changes the layers meanwhile; what
// is in them is the command's, though, so nothing in them is followed.
func (p *Preview) read() error {
for _, l := range p.layers {
if err := p.readLayer(l); err != nil {
return err
}
}
return nil
}
// readLayer reads one layer's changes, in its directory on the host. One
// blinker made for a mount (see bwrapArgs) is gone again, so what is in it
// is new, under the nearest directory that is there.
func (p *Preview) readLayer(l layer) error {
top, base := l.host, "."
for {
_, err := os.Lstat(top)
if err == nil {
break
}
if !errors.Is(err, fs.ErrNotExist) || top == "/" {
return err
}
top, base = filepath.Dir(top), filepath.Join(filepath.Base(top), base)
}
root, err := os.OpenRoot(top)
if err != nil {
return err
}
defer root.Close()
var walk func(rel string, fresh bool) error
walk = func(rel string, fresh bool) error {
path, upper := filepath.Join(l.host, rel), filepath.Join(l.upper, rel)
if rel != "." && p.mounts[path] {
return nil // made by bubblewrap for a mount, and what is under it went there
}
fi, err := os.Lstat(upper)
if err != nil {
return err
}
var before State
if !fresh {
if before, err = stateAt(root, filepath.Join(base, rel)); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
c, err := change(upper, fi, before)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if c != nil && !(rel == "." && fresh) {
c.Path, c.Before, c.fresh, c.root, c.rel = path, before, fresh, top, filepath.Join(base, rel)
p.Changes = append(p.Changes, *c)
}
if !fi.IsDir() {
return nil
}
// Under a directory that is new, or replaced what was there, the
// host has nothing.
fresh = fresh || before.Type != "dir" || c != nil && c.Opaque
entries, err := os.ReadDir(upper)
if err != nil {
return err
}
for _, e := range entries {
if err := walk(filepath.Join(rel, e.Name()), fresh); err != nil {
return err
}
}
return nil
}
return walk(".", base != ".")
}
// change is what an entry in an upper directory changes, given what the
// host had there, if anything: an overlay copies a file up whole for any
// write, even one that changes nothing.
func change(upper string, fi fs.FileInfo, before State) (*Change, error) {
switch mode := fi.Mode(); {
case mode&fs.ModeCharDevice != 0 && whiteout(fi):
if before.Type == "" {
return nil, nil // gone from the host too
}
return &Change{Kind: Remove}, nil
case mode.IsDir():
opaque, err := overlayAttrs(upper)
if err != nil {
return nil, err
}
c := &Change{Kind: Mkdir, Mode: mode.Perm()}
switch {
case before.Type == "":
return c, nil
case before.Type != "dir" || opaque:
c.Opaque = true
return c, nil
case before.Mode != mode.Perm():
return c, nil
}
return nil, nil
case mode.IsRegular():
if _, err := overlayAttrs(upper); err != nil {
return nil, err
}
f, err := os.OpenFile(upper, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
return nil, err
}
defer f.Close()
now, err := fileState(f, mode)
if err != nil || now == before {
return nil, err
}
return &Change{Kind: Write, Mode: mode.Perm(), Size: now.Size, upper: upper}, nil
case mode&fs.ModeSymlink != 0:
target, err := os.Readlink(upper)
if err != nil || before.Type == "symlink" && before.Target == target {
return nil, err
}
return &Change{Kind: Symlink, Target: target}, nil
case mode&fs.ModeSocket != 0:
return nil, nil // its server ended with the command: nothing to carry over
}
return nil, fmt.Errorf("the command made a %v, which a preview cannot show", fi.Mode().Type())
}
// whiteout reports whether a character device is an overlay's mark for a
// removed path: device 0, 0.
func whiteout(fi fs.FileInfo) bool {
st, ok := fi.Sys().(*syscall.Stat_t)
return ok && st.Rdev == 0
}
// overlayAttrs reads the overlay's attributes of a directory or file in an
// upper directory: whether a directory is opaque. Any other attribute that
// changes what the entry means (a redirect for a renamed directory, a file
// whose contents are elsewhere) is refused: the reader would misread it.
func overlayAttrs(path string) (opaque bool, err error) {
attrs, err := listOverlayAttrs(path)
if err != nil {
return false, err
}
for name, value := range attrs {
switch {
case name == "opaque" && value == "y":
opaque = true
case name == "origin" || name == "impure" || name == "uuid":
// Bookkeeping: where a file was copied up from, and so on.
default:
return false, fmt.Errorf("the overlay marks it %s=%q, which a preview cannot read", name, value)
}
}
return opaque, nil
}
|