Download
435 lines 13.2 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
package policy

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path"
	"path/filepath"
	"strings"
	"unicode"
)

// Rule paths are globs: "*" and "?" match within one path component,
// "[abc]" one of a set of characters, and a "**" component any number of
// directories, none included. Unlike in the shell, a leading "." is not
// special: "*" matches ".env". A path without wildcards names one path. A
// rule that matches a directory covers everything inside it, as a mount
// does. Paths are relative to the project unless they start with "/" or
// "~/". A path "$NAME" names the directories in an environment variable
// (see envPaths).

// ValidatePath rejects a rule path that is empty, climbs with "..", is a
// malformed glob, holds characters that could break out of a generated
// sandbox profile's string literal, or uses "$" other than as a whole
// "$NAME".
func ValidatePath(p string) error {
	if p == "" {
		return errors.New("empty path")
	}
	if IsVarPath(p) {
		return validateVarPath(p)
	}
	if name, ok := strings.CutPrefix(p, "$"); ok {
		if !validVarName(name) {
			return fmt.Errorf("%q: a variable must be the whole path, as $NAME", p)
		}
		if EnvFloored(name) {
			return fmt.Errorf("%q: %s is never available to commands", p, name)
		}
		return nil
	}
	if strings.Contains(p, "$") {
		return fmt.Errorf("%q: a variable must be the whole path, as $NAME", p)
	}
	for _, c := range p {
		// Control and format characters (bidi overrides, zero-width
		// spaces) would make a rule read as something it does not match.
		if unicode.IsControl(c) || unicode.Is(unicode.Cf, c) || c == '"' || c == '\\' {
			return fmt.Errorf("illegal character %U in path %q", c, p)
		}
	}
	// Policy files cannot hold one at either end (see field), so a rule
	// made in code must not either: it would read back as another path.
	if strings.TrimSpace(p) != p {
		return fmt.Errorf("%q: a path cannot start or end with a space", p)
	}
	if strings.HasPrefix(p, "~") && p != "~" && !strings.HasPrefix(p, "~/") {
		return fmt.Errorf("~user paths are not supported: %q", p)
	}
	for _, part := range strings.Split(p, "/") {
		if part == ".." {
			return fmt.Errorf(`".." is not allowed in paths: %q`, p)
		}
		if _, err := path.Match(part, ""); err != nil {
			return fmt.Errorf("bad glob %q: %w", p, err)
		}
	}
	return nil
}

// Expand turns a rule path into an absolute, cleaned one: "~" is home,
// and relative paths are under the project. Symlinks are not resolved.
func Expand(p, home, project string) string {
	switch {
	case p == "~":
		return home
	case strings.HasPrefix(p, "~/"):
		return filepath.Join(home, p[2:])
	case strings.HasPrefix(p, "/"):
		return filepath.Clean(p)
	}
	return filepath.Join(project, p)
}

// IsEnvPath reports whether a rule path is "$NAME".
func IsEnvPath(p string) bool { return strings.HasPrefix(p, "$") && !IsVarPath(p) }

// IsVarPath reports whether a rule path starts with "${NAME:-default}":
// the directory the variable names, or the default where it names none,
// and then the rest of the path. The default may be another such.
func IsVarPath(p string) bool { return strings.HasPrefix(p, "${") }

// splitVarPath splits "${NAME:-default}rest" into its parts.
func splitVarPath(p string) (name, def, rest string, err error) {
	body, ok := strings.CutPrefix(p, "${")
	if !ok {
		return "", "", "", fmt.Errorf("%q does not start with ${", p)
	}
	depth := 1
	for i := range len(body) {
		switch body[i] {
		case '{':
			depth++
		case '}':
			if depth--; depth == 0 {
				name, def, ok := strings.Cut(body[:i], ":-")
				if !ok {
					return "", "", "", fmt.Errorf("%q: write a variable as ${NAME:-default}", p)
				}
				return name, def, body[i+1:], nil
			}
		}
	}
	return "", "", "", fmt.Errorf("%q: no } closes the variable", p)
}

// validateVarPath checks a "${NAME:-default}rest" rule path: a variable's
// name, a default that is a plain path without wildcards or another such,
// and a rest that is empty or a path under it.
func validateVarPath(p string) error {
	name, def, rest, err := splitVarPath(p)
	if err != nil {
		return err
	}
	if !validVarName(name) {
		return fmt.Errorf("%q: bad variable name %q", p, name)
	}
	if IsVarPath(def) {
		if err := validateVarPath(def); err != nil {
			return err
		}
	} else if err := ValidatePath(def); err != nil || IsEnvPath(def) || IsGlob(def) || strings.ContainsAny(def, "{}") {
		return fmt.Errorf("%q: the default must be a plain path", p)
	}
	if rest != "" && !strings.HasPrefix(rest, "/") {
		return fmt.Errorf("%q: after the variable comes / and the rest of the path", p)
	}
	if strings.ContainsAny(rest, "{}") {
		return fmt.Errorf("%q: only the start of a path can be a variable", p)
	}
	if rest != "" {
		return ValidatePath("/x" + rest) // the rest, as a path's tail
	}
	return nil
}

// resolveVarPath turns a "${NAME:-default}rest" rule path into the path it
// names now. The variable counts only if it holds one directory that
// varDir accepts; otherwise the default does, as the XDG spec says of a
// relative path and the tools themselves do. The value is returned as
// written, so the symlinks in it are followed, and checked, as any rule's
// are. The default is left as written (~/x, or relative to the project).
func resolveVarPath(p, home, project string) (string, error) {
	name, def, rest, err := splitVarPath(p)
	if err != nil {
		return "", err
	}
	base := def
	if IsVarPath(def) {
		if base, err = resolveVarPath(def, home, project); err != nil {
			return "", err
		}
	}
	if v := os.Getenv(name); !strings.Contains(v, ":") { // one path, not a list
		if _, _, ok := varDir(v, home, project); ok {
			base = filepath.Clean(v)
		}
	}
	return base + rest, nil
}

// IsGlob reports whether a path holds glob characters.
func IsGlob(p string) bool { return strings.ContainsAny(p, "*?[") }

// parts splits a clean absolute path into its components: it takes ".."
// for a name like any other.
func parts(p string) []string {
	if p == "/" {
		return nil
	}
	return strings.Split(strings.TrimPrefix(p, "/"), "/")
}

// matchParts reports whether a path's components match a glob's.
func matchParts(glob, elems []string) bool {
	for len(glob) > 0 {
		if glob[0] == "**" {
			if len(glob) == 2 && glob[1] != "**" {
				// Only the last component can match what follows, so
				// trying every split, as below, would answer the same.
				return len(elems) > 0 && matchParts(glob[1:], elems[len(elems)-1:])
			}
			for i := 0; i <= len(elems); i++ {
				if matchParts(glob[1:], elems[i:]) {
					return true
				}
			}
			return false
		}
		if len(elems) == 0 {
			return false
		}
		if strings.ContainsAny(glob[0], `*?[\`) {
			if ok, err := path.Match(glob[0], elems[0]); err != nil || !ok {
				return false
			}
		} else if glob[0] != elems[0] {
			return false // what path.Match does with no special characters
		}
		glob, elems = glob[1:], elems[1:]
	}
	return len(elems) == 0
}

// Matcher matches the paths a rule path names.
type Matcher struct {
	// Path is absolute. For a glob, it is the expanded pattern, and
	// Prefix is the directory above its first wildcard.
	Path   string
	Prefix string
	// Dir is set when the rule path ends in "/": it names a directory,
	// which the sandbox makes (or blocks) as one if it is missing.
	Dir  bool
	glob []string // a glob's components; nil for a plain path
}

// NewMatcher prepares a rule path. A trailing "/**" adds nothing, since a
// rule covers what is inside the directories it matches, so "dir/**" is
// the plain path "dir", which needs no search of the filesystem.
//
// A path outside the project names a place the user chose (~/.vimrc, a
// link into a dotfiles repo), so the symlinks in its part before any
// wildcard are resolved now, as the kernel will see them (Compile refuses
// one that commands could change; see resolveLinks). A path in the
// project (whose real path is fixed for the session) keeps the rest as
// written: what is in it is not the user's choice, and a symlink planted
// there cannot redirect a rule.
func NewMatcher(rulePath, home, project string) (Matcher, error) {
	m, _, err := newMatcher(rulePath, home, project)
	return m, err
}

// newMatcher is NewMatcher, also returning the symlinks it followed.
func newMatcher(rulePath, home, project string) (Matcher, []string, error) {
	if err := ValidatePath(rulePath); err != nil {
		return Matcher{}, nil, err
	}
	if IsEnvPath(rulePath) {
		return Matcher{}, nil, fmt.Errorf("%q names a variable's directories, not one path", rulePath)
	}
	if IsVarPath(rulePath) {
		var err error
		if rulePath, err = resolveVarPath(rulePath, home, project); err != nil {
			return Matcher{}, nil, err
		}
	}
	p := Expand(rulePath, home, project)
	for strings.HasSuffix(p, "/**") {
		p = strings.TrimSuffix(p, "/**")
	}
	if p == "" {
		p = "/"
	}
	var links []string
	if !Under(p, project) {
		// Only the part before the first wildcard: a file named "*" is no
		// wildcard's to follow.
		lit, rest := parts(p), []string(nil)
		for i, part := range lit {
			if IsGlob(part) {
				lit, rest = lit[:i], lit[i:]
				break
			}
		}
		var real string
		real, links = resolveLinks("/" + strings.Join(lit, "/"))
		p = filepath.Join(append([]string{real}, rest...)...)
	}
	dir := strings.HasSuffix(rulePath, "/")
	if !IsGlob(p) {
		return Matcher{Path: p, Dir: dir}, links, nil
	}
	glob := parts(p)
	prefix := "/"
	for i, part := range glob {
		if IsGlob(part) {
			prefix = "/" + strings.Join(glob[:i], "/")
			break
		}
	}
	return Matcher{Path: p, Prefix: prefix, Dir: dir, glob: glob}, links, nil
}

// IsGlob reports whether the matcher needs the filesystem searched to find
// what it matches.
func (m Matcher) IsGlob() bool { return m.glob != nil }

// Match reports whether the matcher covers path: path is a match, or
// inside one.
func (m Matcher) Match(p string) bool {
	if m.glob == nil {
		return Under(p, m.Path)
	}
	if !Under(p, m.Prefix) {
		return false
	}
	elems := parts(p)
	for i := len(elems); i >= 0; i-- {
		if matchParts(m.glob, elems[:i]) {
			return true
		}
	}
	return false
}

// MatchAnyExact reports whether one of ms matches p itself, rather than
// something p is inside, splitting p into its components once for all of
// them.
func MatchAnyExact(ms []Matcher, p string) bool {
	var elems []string
	for _, m := range ms {
		switch {
		case m.glob == nil:
			if p == m.Path {
				return true
			}
		case Under(p, m.Prefix):
			if elems == nil {
				elems = parts(p)
			}
			if matchParts(m.glob, elems) {
				return true
			}
		}
	}
	return false
}

// Covers reports whether every path o matches, m matches too, so a later
// rule m leaves an earlier rule o nothing to decide. It only answers yes
// when that is certain.
func (m Matcher) Covers(o Matcher) bool {
	if m.Path == o.Path {
		return true
	}
	if o.glob == nil {
		return m.Match(o.Path)
	}
	// Everything a glob matches is under its prefix.
	return m.glob == nil && Under(o.Prefix, m.Path)
}

// Under reports whether path is dir or inside it. An empty dir (such as an
// Env without a scratch dir) contains nothing. It compares the strings, so
// both must be clean absolute paths (see filepath.Clean): "/p/../etc" is
// under "/p" here.
func Under(path, dir string) bool {
	if dir == "" {
		return false
	}
	// As strings.HasPrefix(path, dir+"/"), without making that string.
	return path == dir || dir == "/" || len(path) > len(dir) && path[len(dir)] == '/' && strings.HasPrefix(path, dir)
}

// ResolveExisting resolves the symlinks in the longest existing prefix of
// p, keeping the rest as written.
func ResolveExisting(p string) string {
	var tail []string
	cur := p
	for {
		if r, err := filepath.EvalSymlinks(cur); err == nil {
			return filepath.Join(append([]string{r}, tail...)...)
		}
		parent := filepath.Dir(cur)
		if parent == cur {
			return p
		}
		tail = append([]string{filepath.Base(cur)}, tail...)
		cur = parent
	}
}

// resolveLinks resolves the symlinks in an absolute path as the kernel
// would, a dangling last one included, keeping what does not exist as
// written, and lists each symlink it followed, links within links too.
func resolveLinks(p string) (string, []string) {
	var links []string
	todo, cur := parts(filepath.Clean(p)), "/"
	for hops := 0; len(todo) > 0; {
		next := filepath.Join(cur, todo[0])
		fi, err := os.Lstat(next)
		if err != nil {
			return filepath.Join(append([]string{cur}, todo...)...), links
		}
		if fi.Mode()&fs.ModeSymlink == 0 {
			cur, todo = next, todo[1:]
			continue
		}
		target, err := os.Readlink(next)
		if hops++; err != nil || hops > 40 {
			return filepath.Clean(p), links // a loop: as written, which matches nothing real
		}
		links = append(links, next)
		if !filepath.IsAbs(target) {
			target = filepath.Join(cur, target) // cur holds no symlinks, so ".." is plain
		}
		todo, cur = append(parts(filepath.Clean(target)), todo[1:]...), "/"
	}
	return cur, links
}

// RealPath resolves where an operation on p lands: the symlinks of its
// existing prefix, and a final symlink even if it dangles (writing through
// it creates its target).
func RealPath(p string) string {
	for range 40 {
		fi, err := os.Lstat(p)
		if err != nil || fi.Mode()&fs.ModeSymlink == 0 {
			break
		}
		if r, err := filepath.EvalSymlinks(p); err == nil {
			return r
		}
		target, err := os.Readlink(p)
		if err != nil {
			break
		}
		if !filepath.IsAbs(target) {
			target = filepath.Join(filepath.Dir(p), target)
		}
		p = filepath.Clean(target)
	}
	return ResolveExisting(p)
}