cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / confine / confine.go
confine.go 3.8K
1// Package confine maps the file names a user types to paths on disk,2// keeping them inside one directory when 012 serves sessions over SSH.3//4// A Root's zero value confines nothing: names are used as typed,5// relative to the working directory, as the local app always has. A Root6// made with New resolves names inside its directory and rejects absolute7// paths, names that climb out with "..", hidden files and directories,8// and symbolic links that lead outside or nowhere.9package confine1011import (12 "errors"13 "fmt"14 "io/fs"15 "os"16 "path/filepath"17 "slices"18 "strings"19)2021// ErrOutside is wrapped by every name a confined Root refuses.22var ErrOutside = errors.New("outside the served directory")2324// Root is a directory names are confined to; the zero value confines25// nothing.26type Root struct {27 dir string // absolute, with symbolic links resolved; "" confines nothing28}2930// New confines names to dir, which must exist.31func New(dir string) (Root, error) {32 abs, err := filepath.Abs(dir)33 if err != nil {34 return Root{}, err35 }36 real, err := filepath.EvalSymlinks(abs)37 if err != nil {38 return Root{}, err39 }40 st, err := os.Stat(real)41 if err != nil {42 return Root{}, err43 }44 if !st.IsDir() {45 return Root{}, fmt.Errorf("%s is not a directory", dir)46 }47 return Root{dir: real}, nil48}4950// Confined reports whether r keeps names inside a directory.51func (r Root) Confined() bool { return r.dir != "" }5253// Dir is the directory names are confined to, "" when unconfined.54func (r Root) Dir() string { return r.dir }5556// Resolve returns the path to use on disk for name, as the user typed57// it. Unconfined, that's name itself. Confined, it's a path inside the58// root with every existing symbolic link along it followed, or an error59// wrapping ErrOutside.60func (r Root) Resolve(name string) (string, error) {61 if r.dir == "" {62 return name, nil63 }64 if name == "" {65 return "", errors.New("no file name")66 }67 if filepath.IsAbs(name) || strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {68 return "", refuse("absolute path")69 }70 clean := filepath.Clean(name)71 if !filepath.IsLocal(clean) {72 return "", ErrOutside73 }74 parts := strings.Split(clean, string(filepath.Separator))75 if clean == "." {76 parts = nil77 }78 if slices.ContainsFunc(parts, hidden) {79 return "", refuse("hidden file")80 }81 cur := r.dir82 for i, p := range parts {83 next := filepath.Join(cur, p)84 st, err := os.Lstat(next)85 if errors.Is(err, fs.ErrNotExist) {86 // The rest doesn't exist yet (a file about to be saved):87 // nothing along it can lead elsewhere.88 return filepath.Join(append([]string{cur}, parts[i:]...)...), nil89 }90 if err != nil {91 return "", err92 }93 if st.Mode()&fs.ModeSymlink != 0 {94 target, err := filepath.EvalSymlinks(next)95 if err != nil {96 return "", refuse("broken link")97 }98 if !r.contains(target) {99 return "", refuse("link")100 }101 next = target102 }103 cur = next104 }105 return cur, nil106}107108// Scrub removes the root's location from msg, such as an error naming a109// resolved path, so it names files relative to the root.110func (r Root) Scrub(msg string) string {111 if r.dir == "" {112 return msg113 }114 msg = strings.ReplaceAll(msg, r.dir+string(filepath.Separator), "")115 return strings.ReplaceAll(msg, r.dir, ".")116}117118// contains reports whether the resolved path p is the root or a visible119// file or directory inside it.120func (r Root) contains(p string) bool {121 rel, err := filepath.Rel(r.dir, p)122 if err != nil || !filepath.IsLocal(rel) {123 return false124 }125 return rel == "." || !slices.ContainsFunc(strings.Split(rel, string(filepath.Separator)), hidden)126}127128func hidden(part string) bool { return strings.HasPrefix(part, ".") }129130// refuse is ErrOutside with the reason. The name is left out, as callers131// show it already.132func refuse(why string) error {133 return fmt.Errorf("%w (%s)", ErrOutside, why)134}