Files
mesh-catalog/modules/mesh-files/cmd/mesh-files/reads.go
T
jschoubben 5d98bcc0db
mesh/delivery-group group feat/900-node-files checking: composed check out of date: novox/mesh-controller@210f7cf5e64b was judged against trunk 611e2275cf7c, t…
mesh/merge-gate out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
mesh-files: the holder of node-files, reads and writes with trash, owned paths and an act log, so file work on a node needs no shell (issue 900, ADR 0394)
2026-10-16 14:50:03 +02:00

1272 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// The bounds (novox/hq ADR 0394, Consequences: set by the build and stated in each verb's description). Every
// answer stays under MaxAnswer, which the tools check once more after an answer is made.
const (
MaxAnswer = 1 << 20 // no answer of this module is larger, whatever the bounds below allow
MaxPaths = 200 // paths one stat answers
MaxComponents = 64 // components of one path
MaxEntries = 2000 // entries one list answers
DefaultReadSize = 64 << 10 // bytes a read answers unless more is asked
MaxReadSize = 256 << 10 // bytes a read answers at most
MaxScan = 64 << 20 // bytes a read passes over to reach the lines asked
DefaultMatches = 100
MaxMatches = 500
DefaultDepth = 4
MaxDepth = 8
MaxFiles = 5000 // files one search opens
MaxFileScan = 4 << 20 // bytes one search reads of one file
MaxLine = 300 // characters of one matching line answered
SearchTime = 20 * time.Second
MaxAccounts = 2000
)
// Reader reads one node's files and accounts as the account it runs as.
type Reader struct {
Refuser
// Passwd, Group and MountInfo are the files accounts, groups and mount points are read from.
Passwd, Group, MountInfo string
// Now is the clock a search is bounded by, and the trash's age.
Now func() time.Time
// Root repeats an act through sudo -n where the account's own rights do not reach (ADR 0394 §2); nil: never.
Root Root
}
func newReader(places []string) Reader {
return Reader{Refuser: Refuser{Places: places}, Passwd: "/etc/passwd", Group: "/etc/group",
MountInfo: "/proc/self/mountinfo", Now: time.Now}
}
func absolute(path string) (string, error) {
path = strings.TrimSpace(path)
if !strings.HasPrefix(path, "/") {
return "", fmt.Errorf("%q is not an absolute path: name it from /", path)
}
return filepath.Clean(path), nil
}
// why turns a file error into the words an answer says: what stopped the read, never a Go error's text.
func why(err error) string {
switch {
case errors.Is(err, fs.ErrNotExist):
return "it does not exist"
case errors.Is(err, fs.ErrPermission):
return "the account mesh-files runs as may not reach it (its file modes refuse it)"
case errors.Is(err, syscall.ELOOP):
return "its links loop"
case errors.Is(err, syscall.ENOTDIR):
return "a component of it is not a directory"
}
var pe *fs.PathError
if errors.As(err, &pe) {
return pe.Err.Error()
}
return err.Error()
}
// cannotRead is the file system's refusal of a read, in the words an answer says, keeping the error beneath so a
// permission error can be told from the others.
type cannotRead struct {
path string
err error
}
func (e *cannotRead) Error() string { return fmt.Sprintf("%s cannot be read: %s", e.path, why(e.err)) }
func (e *cannotRead) Unwrap() error { return e.err }
// errRefusedByName marks a refusal by name, which a search names rather than counts as unreadable.
var errRefusedByName = errors.New("refused by name")
// openJudged opens a path and judges what was opened, not its name (the review of #244: a link flipped between
// the name check and the open read a secret place 3744 times in 20000): the type is checked before the open, so
// a device or a fifo is never opened; the descriptor is opened non-blocking, without a controlling terminal, and
// in a search without following a link; then it is checked again by fstat, and the path the kernel says it is
// (/proc/self/fd) is judged by name like any path asked. A deleted file, or one the kernel names no path for, is
// refused.
func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileInfo, error) {
stat := os.Stat
if nofollow {
stat = os.Lstat
}
before, err := stat(path)
if err != nil {
return nil, nil, &cannotRead{path, err}
}
if err := wantType(path, before, dir); err != nil {
return nil, nil, err
}
flags := os.O_RDONLY | syscall.O_NONBLOCK | syscall.O_NOCTTY | syscall.O_CLOEXEC
if nofollow {
flags |= syscall.O_NOFOLLOW
}
if dir {
flags |= syscall.O_DIRECTORY
}
f, err := os.OpenFile(path, flags, 0)
if err != nil {
return nil, nil, &cannotRead{path, err}
}
info, err := f.Stat()
if err == nil {
err = wantType(path, info, dir)
}
if err != nil {
f.Close()
return nil, nil, err
}
real, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", f.Fd()))
if err != nil || !strings.HasPrefix(real, "/") || strings.HasSuffix(real, " (deleted)") {
f.Close()
return nil, nil, fmt.Errorf("%s is refused: the kernel names no path for what was opened, so it cannot be "+
"judged by name (novox/hq ADR 0394 §5)", path)
}
if why := r.Refused(real); why != "" {
f.Close()
return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §5): %w", path, real, why, errRefusedByName)
}
return f, info, nil
}
func wantType(path string, info fs.FileInfo, dir bool) error {
switch {
case dir && !info.IsDir():
return fmt.Errorf("%s is a %s, not a directory", path, typeOf(info.Mode()))
case !dir && !info.Mode().IsRegular():
return fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, typeOf(info.Mode()))
}
return nil
}
// deviceOf is the file system a path is on.
func deviceOf(path string) (uint64, bool) {
info, err := os.Lstat(path)
if err != nil {
return 0, false
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(st.Dev), true
}
// ---- stat ----
// Component is one component of a path as lstat sees it.
type Component struct {
Path string `json:"path"`
Type string `json:"type,omitempty"`
Owner string `json:"owner,omitempty"`
UID *int `json:"uid,omitempty"`
Group string `json:"group,omitempty"`
GID *int `json:"gid,omitempty"`
Mode string `json:"mode,omitempty"` // octal, with the setuid, setgid and sticky bits
Perms string `json:"perms,omitempty"` // as ls shows them
Size *int64 `json:"size,omitempty"`
Link string `json:"link,omitempty"` // what a link points to, as written
Mount *Mount `json:"mount,omitempty"`
Secret string `json:"secret-place,omitempty"` // why a read under it is refused
Unreachd string `json:"unreachable,omitempty"`
}
// Mount is a mount point: its file system and source.
type Mount struct {
FS string `json:"fs"`
Source string `json:"source"`
}
// PathStat is one path's components, from / down, and where it leads once every link is followed.
type PathStat struct {
Path string `json:"path"`
Components []Component `json:"components"`
LeadsTo string `json:"leads-to,omitempty"`
Leads *Component `json:"there,omitempty"`
Stopped string `json:"stopped,omitempty"`
}
// StatAnswer is what stat answers.
type StatAnswer struct {
Paths []PathStat `json:"paths"`
Withheld []Withheld `json:"withheld,omitempty"`
}
func (r Reader) Stat(paths []string, follow bool) (StatAnswer, error) {
if len(paths) == 0 {
return StatAnswer{}, errors.New("name a path to stat")
}
if len(paths) > MaxPaths {
return StatAnswer{}, fmt.Errorf("%d paths asked, and one stat answers at most %d: ask in parts", len(paths), MaxPaths)
}
names := r.names()
mounts := r.mounts()
var out StatAnswer
for _, raw := range paths {
path, err := absolute(raw)
if err != nil {
return StatAnswer{}, err
}
ps := PathStat{Path: path}
walk := []string{"/"}
acc := ""
for _, part := range strings.Split(strings.TrimPrefix(path, "/"), "/") {
if part == "" {
continue
}
acc += "/" + part
walk = append(walk, acc)
}
if len(walk) > MaxComponents {
return StatAnswer{}, fmt.Errorf("%s has %d components, and a stat answers at most %d", path, len(walk), MaxComponents)
}
for _, p := range walk {
c := r.component(p, names, mounts)
ps.Components = append(ps.Components, c)
if c.Unreachd != "" {
ps.Stopped = c.Path + ": " + c.Unreachd
break
}
}
if follow && ps.Stopped == "" {
if leads, err := filepath.EvalSymlinks(path); err != nil {
ps.Stopped = "following its links: " + why(err)
} else {
ps.LeadsTo = leads
if leads != path {
c := r.component(leads, names, mounts)
ps.Leads = &c
}
}
}
out.Paths = append(out.Paths, ps)
}
out.Withheld = cleanStat(&out)
return out, nil
}
func (r Reader) component(p string, names accounts, mounts map[string]Mount) Component {
c := Component{Path: p}
info, err := os.Lstat(p)
if err != nil {
if r.Root != nil && errors.Is(err, fs.ErrPermission) {
if rc, ok := r.componentAsRoot(p, names, mounts); ok {
return rc
}
}
c.Unreachd = why(err)
return c
}
c.Type = typeOf(info.Mode())
c.Perms = info.Mode().String()
if st, ok := info.Sys().(*syscall.Stat_t); ok {
uid, gid := int(st.Uid), int(st.Gid)
c.UID, c.GID = &uid, &gid
c.Owner, c.Group = names.user(uid), names.group(gid)
c.Mode = fmt.Sprintf("%04o", st.Mode&07777)
}
if info.Mode().IsRegular() {
size := info.Size()
c.Size = &size
}
if info.Mode()&fs.ModeSymlink != 0 {
if target, err := os.Readlink(p); err == nil {
c.Link = target
}
}
if m, ok := mounts[p]; ok {
c.Mount = &m
}
if why := r.Refused(p); why != "" {
c.Secret = why
}
return c
}
// statFormat is the fixed format of the root stat: type, owner, group, mode, perms as ls shows them, size.
const statFormat = "%F|%u|%g|%a|%A|%s"
var rootTypes = map[string]string{"regular file": "file", "regular empty file": "file", "directory": "directory",
"symbolic link": "link", "fifo": "fifo", "socket": "socket", "character special file": "character device",
"block special file": "block device"}
// componentAsRoot is a component the account may not reach, said by a fixed stat through root.
func (r Reader) componentAsRoot(p string, names accounts, mounts map[string]Mount) (Component, bool) {
out, err := r.Root.Run("/usr/bin/stat", "-c", statFormat, "--", p)
f := strings.Split(strings.TrimSpace(string(out)), "|")
if err != nil || len(f) != 6 {
return Component{}, false
}
uid, e1 := strconv.Atoi(f[1])
gid, e2 := strconv.Atoi(f[2])
mode, e3 := strconv.ParseUint(f[3], 8, 32)
size, e4 := strconv.ParseInt(f[5], 10, 64)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
return Component{}, false
}
c := Component{Path: p, Type: rootTypes[f[0]], Perms: f[4], UID: &uid, GID: &gid,
Owner: names.user(uid), Group: names.group(gid), Mode: fmt.Sprintf("%04o", mode)}
if c.Type == "" {
c.Type = "other"
}
if c.Type == "file" {
c.Size = &size
}
if c.Type == "link" {
if t, err := r.Root.Run("/usr/bin/readlink", "--", p); err == nil {
c.Link = strings.TrimRight(string(t), "\n")
}
}
if m, ok := mounts[p]; ok {
c.Mount = &m
}
if why := r.Refused(p); why != "" {
c.Secret = why
}
return c, true
}
func typeOf(m fs.FileMode) string {
switch {
case m.IsDir():
return "directory"
case m&fs.ModeSymlink != 0:
return "link"
case m.IsRegular():
return "file"
case m&fs.ModeNamedPipe != 0:
return "fifo"
case m&fs.ModeSocket != 0:
return "socket"
case m&fs.ModeCharDevice != 0:
return "character device"
case m&fs.ModeDevice != 0:
return "block device"
}
return "other"
}
func cleanStat(a *StatAnswer) []Withheld {
var w []Withheld
cleanC := func(c *Component) {
for _, f := range []*string{&c.Link} {
if s, what := clean(*f); what != "" {
w = append(w, Withheld{At: "a link's target", Class: what})
*f = s
}
}
if c.Mount != nil {
if s, what := clean(c.Mount.Source); what != "" {
w = append(w, Withheld{At: "a mount's source", Class: what})
c.Mount.Source = s
}
}
}
for i := range a.Paths {
p := &a.Paths[i]
for j := range p.Components {
cleanC(&p.Components[j])
}
if p.Leads != nil {
cleanC(p.Leads)
}
}
return w
}
// ---- readlink ----
// LinkAnswer is what readlink answers.
type LinkAnswer struct {
Path string `json:"path"`
Link string `json:"link,omitempty"` // as written; empty when the path is not a link
IsLink bool `json:"is-link"`
LeadsTo string `json:"leads-to,omitempty"`
Exists bool `json:"exists"`
Stopped string `json:"stopped,omitempty"`
Withheld []Withheld `json:"withheld,omitempty"`
}
func (r Reader) Readlink(raw string) (LinkAnswer, error) {
path, err := absolute(raw)
if err != nil {
return LinkAnswer{}, err
}
a := LinkAnswer{Path: path}
info, err := os.Lstat(path)
if err != nil {
if r.Root != nil && errors.Is(err, fs.ErrPermission) {
return r.readlinkAsRoot(a), nil
}
a.Stopped = why(err)
return a, nil
}
if info.Mode()&fs.ModeSymlink != 0 {
a.IsLink = true
if a.Link, err = os.Readlink(path); err != nil {
a.Stopped = why(err)
}
}
if leads, err := filepath.EvalSymlinks(path); err == nil {
a.LeadsTo, a.Exists = leads, true
} else if r.Root != nil && errors.Is(err, fs.ErrPermission) {
if out, rerr := r.Root.Run("/usr/bin/readlink", "-e", "--", path); rerr == nil {
a.LeadsTo, a.Exists = strings.TrimRight(string(out), "\n"), true
} else if a.Stopped == "" {
a.Stopped = "following its links: " + rerr.Error()
}
} else if a.Stopped == "" {
a.Stopped = "following its links: " + why(err)
}
for _, f := range []*string{&a.Link} {
if s, what := clean(*f); what != "" {
a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what})
*f = s
}
}
return a, nil
}
// readlinkAsRoot answers a link the account may not reach, through fixed readlinks as root.
func (r Reader) readlinkAsRoot(a LinkAnswer) LinkAnswer {
if out, err := r.Root.Run("/usr/bin/readlink", "--", a.Path); err == nil {
a.IsLink, a.Link = true, strings.TrimRight(string(out), "\n")
}
if out, err := r.Root.Run("/usr/bin/readlink", "-e", "--", a.Path); err == nil {
a.LeadsTo, a.Exists = strings.TrimRight(string(out), "\n"), true
} else {
a.Stopped = "it does not exist, or cannot be reached even through sudo"
}
if s, what := clean(a.Link); what != "" {
a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what})
a.Link = s
}
return a
}
// ---- list ----
// Entry is one entry of a directory.
type Entry struct {
Name string `json:"name"`
Type string `json:"type"`
Owner string `json:"owner,omitempty"`
Group string `json:"group,omitempty"`
Mode string `json:"mode,omitempty"`
Size *int64 `json:"size,omitempty"`
Link string `json:"link,omitempty"`
}
// ListAnswer is what list answers.
type ListAnswer struct {
Path string `json:"path"`
Entries []Entry `json:"entries"`
Count int `json:"count"`
Cut string `json:"cut,omitempty"`
Withheld []Withheld `json:"withheld,omitempty"`
}
// refusedRead is a refusal by name, before any read: the path asked and where it leads are both judged, so a
// link to a secret place is refused as the place is.
func (r Reader) refusedRead(path string) error {
if why := r.Refused(path); why != "" {
return fmt.Errorf("%s is refused by name: %s (novox/hq ADR 0394 §5)", path, why)
}
if leads, err := filepath.EvalSymlinks(path); err == nil && leads != path {
if why := r.Refused(leads); why != "" {
return fmt.Errorf("%s leads to %s, which is refused by name: %s (novox/hq ADR 0394 §5)", path, leads, why)
}
}
return nil
}
func (r Reader) List(raw string) (ListAnswer, error) {
path, err := absolute(raw)
if err != nil {
return ListAnswer{}, err
}
if err := r.refusedRead(path); err != nil {
return ListAnswer{}, err
}
names := r.names()
var all []Entry
dir, _, err := r.openJudged(path, false, true)
switch {
case err != nil && r.Root != nil && errors.Is(err, fs.ErrPermission):
if all, err = r.listAsRoot(path, names); err != nil {
return ListAnswer{}, err
}
case err != nil:
return ListAnswer{}, err
default:
entries, err := dir.ReadDir(-1)
dir.Close()
if err != nil {
return ListAnswer{}, fmt.Errorf("%s cannot be listed: %s", path, why(err))
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, e := range entries {
if len(all) >= MaxEntries+1 {
break
}
en := Entry{Name: e.Name(), Type: typeOf(e.Type())}
if info, err := e.Info(); err == nil {
en.Type = typeOf(info.Mode())
if st, ok := info.Sys().(*syscall.Stat_t); ok {
en.Owner, en.Group = names.user(int(st.Uid)), names.group(int(st.Gid))
en.Mode = fmt.Sprintf("%04o", st.Mode&07777)
}
if info.Mode().IsRegular() {
size := info.Size()
en.Size = &size
}
if info.Mode()&fs.ModeSymlink != 0 {
en.Link, _ = os.Readlink(filepath.Join(path, e.Name()))
}
}
all = append(all, en)
}
return r.finishList(path, all, len(entries)), nil
}
return r.finishList(path, all, len(all)), nil
}
// finishList bounds a list and holds each link's target to the content rule. total is how many entries the
// directory holds.
func (r Reader) finishList(path string, all []Entry, total int) ListAnswer {
a := ListAnswer{Path: path, Count: total, Entries: []Entry{}}
if total > MaxEntries {
a.Cut = fmt.Sprintf("cut at %d of %d entries (the bound of one list), in name order", MaxEntries, total)
}
if len(all) > MaxEntries {
all = all[:MaxEntries]
}
for _, en := range all {
if s, what := clean(en.Link); what != "" {
a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what})
en.Link = s
}
a.Entries = append(a.Entries, en)
}
return a
}
// listAsRoot lists a directory the account may not read, through a fixed find as root. A tab or a line break in
// a name cannot confuse it: the fields end with a NUL, and the name is the last.
func (r Reader) listAsRoot(path string, names accounts) ([]Entry, error) {
out, err := r.Root.Run("/usr/bin/find", "-H", path, "-mindepth", "1", "-maxdepth", "1", "-printf",
"%y\t%U\t%G\t%m\t%s\t%l\t%f\x00")
if err != nil {
return nil, fmt.Errorf("%s cannot be listed, not even through sudo: %v", path, err)
}
var all []Entry
for _, rec := range strings.Split(string(out), "\x00") {
f := strings.SplitN(rec, "\t", 7)
if len(f) != 7 {
continue
}
uid, e1 := strconv.Atoi(f[1])
gid, e2 := strconv.Atoi(f[2])
mode, e3 := strconv.ParseUint(f[3], 8, 32)
size, e4 := strconv.ParseInt(f[4], 10, 64)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
continue
}
en := Entry{Name: f[6], Type: findTypes[f[0]], Owner: names.user(uid), Group: names.group(gid),
Mode: fmt.Sprintf("%04o", mode), Link: f[5]}
if en.Type == "" {
en.Type = "other"
}
if en.Type == "file" {
en.Size = &size
}
all = append(all, en)
}
sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name })
return all, nil
}
var findTypes = map[string]string{"f": "file", "d": "directory", "l": "link", "p": "fifo", "s": "socket",
"c": "character device", "b": "block device"}
// ---- read ----
// ReadAnswer is what read answers.
type ReadAnswer struct {
Path string `json:"path"`
Size int64 `json:"size"` // as the file says; 0 for most of /proc and /sys
From int `json:"from"` // the first line answered, counted from 1
Lines int `json:"lines"`
Text string `json:"text"`
Cut string `json:"cut,omitempty"`
Withheld []int `json:"withheld-lines,omitempty"`
Why string `json:"withheld-because,omitempty"`
}
// ReadAsk is what a read asks: the lines from From (1 is the first; a negative From counts from the end, as
// tail does), at most Lines of them (0: as many as fit), and at most Bytes bytes.
type ReadAsk struct {
From, Lines, Bytes int
}
func (r Reader) Read(raw string, ask ReadAsk) (ReadAnswer, error) {
path, err := absolute(raw)
if err != nil {
return ReadAnswer{}, err
}
if err := r.refusedRead(path); err != nil {
return ReadAnswer{}, err
}
bound := ask.Bytes
switch {
case bound <= 0:
bound = DefaultReadSize
case bound > MaxReadSize:
return ReadAnswer{}, fmt.Errorf("%d bytes asked, and one read answers at most %d", bound, MaxReadSize)
}
if ask.Lines < 0 {
return ReadAnswer{}, errors.New("lines is a count: name how many, or none for as many as fit")
}
f, info, err := r.openJudged(path, false, false)
if err != nil {
if r.Root != nil && errors.Is(err, fs.ErrPermission) {
return r.readAsRoot(path, ask, bound)
}
return ReadAnswer{}, err
}
defer f.Close()
return r.answer(path, info.Size(), io.LimitReader(f, MaxScan), ask, bound)
}
// readAsRoot reads a file the account's own rights do not reach, through sudo -n and a fixed cat (ADR 0394 §2).
// The path was judged by name before, as for any read; what comes back is held to the same bounds and rule.
func (r Reader) readAsRoot(path string, ask ReadAsk, bound int) (ReadAnswer, error) {
real, err := r.rootFile(path)
if err != nil {
return ReadAnswer{}, err
}
out, err := r.Root.Run("/usr/bin/cat", "--", real)
if err != nil {
return ReadAnswer{}, fmt.Errorf("%s cannot be read, not even through sudo: %v", path, err)
}
return r.answer(path, int64(len(out)), bytes.NewReader(out), ask, bound)
}
// rootFile says where a path leads and what it is, as root, before root reads it: the resolved path is judged by
// name like any path asked, and it must be a regular file, so a link cannot lead root into a place that is
// refused, and a fifo or a device is never opened. It answers the resolved path, which is the one to read.
func (r Reader) rootFile(path string) (string, error) {
out, err := r.Root.Run("/usr/bin/readlink", "-e", "--", path)
real := strings.TrimRight(string(out), "\n")
if err != nil || !strings.HasPrefix(real, "/") {
return "", fmt.Errorf("%s cannot be read, not even through sudo: it cannot be resolved", path)
}
if why := r.Refused(real); why != "" {
return "", fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §5): %w", path, real, why, errRefusedByName)
}
st, err := r.Root.Run("/usr/bin/stat", "-c", statFormat, "--", real)
kind := rootTypes[strings.SplitN(string(st), "|", 2)[0]]
if err != nil || kind != "file" {
if kind == "" {
kind = "thing that cannot be told"
}
return "", fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, kind)
}
return real, nil
}
// answer makes the read's answer from the file's bytes: the lines asked, each held to the content rule.
func (r Reader) answer(path string, size int64, in io.Reader, ask ReadAsk, bound int) (ReadAnswer, error) {
a := ReadAnswer{Path: path, Size: size}
lines, cut, err := pick(in, ask, bound)
if err != nil {
return ReadAnswer{}, fmt.Errorf("%s: %v", path, err)
}
a.Cut = cut
if len(lines) > 0 {
a.From = lines[0].n
}
var text strings.Builder
for _, l := range lines {
s, what := clean(l.text)
if l.block {
s, what = "‹withheld: a key or certificate block›", "a key or certificate block"
}
if what != "" {
a.Withheld = append(a.Withheld, l.n)
a.Why = "a line shaped like a secret is withheld by the content rule (novox/hq ADR 0394 §4)"
}
text.WriteString(s)
if l.newline {
text.WriteByte('\n')
}
}
a.Text, a.Lines = text.String(), len(lines)
return a, nil
}
type line struct {
n int
text string
newline bool
size int
block bool // inside a BEGIN … END block, whose every line is withheld
}
// pick reads lines and keeps the ones asked, within bound bytes. A NUL byte is a binary file, refused.
func pick(in io.Reader, ask ReadAsk, bound int) ([]line, string, error) {
br := bufio.NewReaderSize(in, 64<<10)
var kept []line
size, n := 0, 0
from := ask.From
if from == 0 {
from = 1
}
var blocks blockState
for {
raw, err := br.ReadString('\n')
if raw == "" && err != nil {
if err == io.EOF {
break
}
return nil, "", err
}
n++
if strings.IndexByte(raw, 0) >= 0 {
return nil, "", errors.New("it is a binary file, and read answers text")
}
l := line{n: n, text: strings.TrimSuffix(raw, "\n"), newline: strings.HasSuffix(raw, "\n"), size: len(raw),
block: blocks.in(raw)}
if from > 0 {
if n < from {
continue
}
if ask.Lines > 0 && len(kept) == ask.Lines {
break
}
if size+len(raw) > bound {
return kept, fmt.Sprintf("cut at line %d: the answer holds at most %d bytes", n, bound), nil
}
kept = append(kept, l)
size += len(raw)
} else {
// From the end, as tail does: the last -from lines, kept as they pass.
kept = append(kept, l)
size += len(raw)
for len(kept) > -from || size > bound {
size -= kept[0].size
kept = kept[1:]
}
}
if err == io.EOF {
break
}
}
if from > 0 && n < from && n > 0 {
return nil, "", fmt.Errorf("it has %d lines, and line %d was asked", n, from)
}
if from < 0 && ask.Lines > 0 && len(kept) > ask.Lines {
kept = kept[:ask.Lines]
}
return kept, "", nil
}
// ---- search ----
// Match is one matching line.
type Match struct {
File string `json:"file"`
Line int `json:"line"`
Text string `json:"text"`
}
// SearchAnswer is what search answers.
type SearchAnswer struct {
Text string `json:"text"`
Path string `json:"path"`
Matches []Match `json:"matches"`
Files int `json:"files-read"`
Bound string `json:"bound-met,omitempty"`
Skipped []string `json:"refused-by-name,omitempty"`
Unreadable int `json:"unreadable,omitempty"`
OtherFS []string `json:"other-file-systems-skipped,omitempty"`
Binary int `json:"binary-skipped,omitempty"`
Withheld []Withheld `json:"withheld,omitempty"`
}
// SearchAsk is what a search asks.
type SearchAsk struct {
Text, Path string
Depth, Matches int
CaseInsensitive bool
}
var errBound = errors.New("bound met")
func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
if ask.Text == "" {
return SearchAnswer{}, errors.New("name the text to find")
}
if strings.ContainsAny(ask.Text, "\n\x00") {
return SearchAnswer{}, errors.New("the text is found within one line: it may not hold a line break")
}
root, err := absolute(ask.Path)
if err != nil {
return SearchAnswer{}, err
}
if err := r.refusedRead(root); err != nil {
return SearchAnswer{}, err
}
depth, matches := ask.Depth, ask.Matches
switch {
case depth == 0:
depth = DefaultDepth
case depth < 0 || depth > MaxDepth:
return SearchAnswer{}, fmt.Errorf("depth %d asked; a search goes at most %d directories deep", depth, MaxDepth)
}
switch {
case matches == 0:
matches = DefaultMatches
case matches < 0 || matches > MaxMatches:
return SearchAnswer{}, fmt.Errorf("%d matches asked; a search answers at most %d", matches, MaxMatches)
}
needle := ask.Text
if ask.CaseInsensitive {
needle = strings.ToLower(needle)
}
a := SearchAnswer{Text: ask.Text, Path: root, Matches: []Match{}}
deadline := r.Now().Add(SearchTime)
rootDev, hasDev := deviceOf(root)
base := strings.Count(root, "/")
if root == "/" {
base = 0
}
err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if r.Now().After(deadline) {
a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime)
return errBound
}
if err != nil {
if r.Root != nil && errors.Is(err, fs.ErrPermission) && (d == nil || d.IsDir()) {
// The account may not reach this folder: root lists its files, and each is read as any other.
if p != root && r.Refused(p) != "" {
a.Skipped = append(a.Skipped, p)
return fs.SkipDir
}
left := depth - (strings.Count(p, "/") - base) + 1
if p == "/" {
left = depth + 1
}
if rerr := r.searchBelowAsRoot(p, left, needle, ask.CaseInsensitive, matches, deadline, &a); rerr != nil {
return rerr
}
if d != nil {
return fs.SkipDir
}
return nil
}
a.Unreadable++
if d != nil && d.IsDir() {
return fs.SkipDir
}
return nil
}
if p != root {
if why := r.Refused(p); why != "" {
a.Skipped = append(a.Skipped, p)
if d.IsDir() {
return fs.SkipDir
}
return nil
}
}
if d.IsDir() {
if p != root && strings.Count(p, "/")-base > depth {
return fs.SkipDir
}
// The root's file system only, as grep -r with find's -xdev: a hard network mount would hang a
// read past every bound, and /proc and /sys are searched only from within them.
if dev, ok := deviceOf(p); p != root && hasDev && ok && dev != rootDev {
a.OtherFS = append(a.OtherFS, p)
return fs.SkipDir
}
return nil
}
if !d.Type().IsRegular() {
return nil // links are not followed; a device, a fifo or a socket is not text
}
if a.Files >= MaxFiles {
a.Bound = fmt.Sprintf("files: a search reads at most %d files", MaxFiles)
return errBound
}
a.Files++
return r.searchFile(p, needle, ask.CaseInsensitive, matches, deadline, &a)
})
if err != nil && !errors.Is(err, errBound) {
return SearchAnswer{}, fmt.Errorf("%s cannot be searched: %s", root, why(err))
}
return a, nil
}
// searchBelowAsRoot lists the files below a folder the account may not reach, through a fixed find as root,
// and searches each: the names are judged first, so no credential file is read.
func (r Reader) searchBelowAsRoot(p string, depth int, needle string, fold bool, bound int, deadline time.Time, a *SearchAnswer) error {
out, err := r.Root.Run("/usr/bin/find", "-H", p, "-maxdepth", strconv.Itoa(depth), "-xdev", "-type", "f",
"-printf", "%p\x00")
if err != nil {
a.Unreadable++
return nil
}
for _, f := range strings.Split(string(out), "\x00") {
if f == "" {
continue
}
if r.Now().After(deadline) {
a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime)
return errBound
}
if r.Refused(f) != "" {
a.Skipped = append(a.Skipped, f)
continue
}
if a.Files >= MaxFiles {
a.Bound = fmt.Sprintf("files: a search reads at most %d files", MaxFiles)
return errBound
}
a.Files++
if err := r.searchFile(f, needle, fold, bound, deadline, a); err != nil {
return err
}
}
return nil
}
func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time.Time, a *SearchAnswer) error {
var src io.Reader
f, _, err := r.openJudged(p, true, false)
switch {
case err == nil:
defer f.Close()
src = f
case errors.Is(err, errRefusedByName):
a.Skipped = append(a.Skipped, p)
return nil
case r.Root != nil && errors.Is(err, fs.ErrPermission) && r.Refused(p) == "":
real, rerr := r.rootFile(p)
if errors.Is(rerr, errRefusedByName) {
a.Skipped = append(a.Skipped, p)
return nil
}
var out []byte
if rerr == nil {
out, rerr = r.Root.Run("/usr/bin/cat", "--", real)
}
if rerr != nil {
a.Unreadable++
return nil
}
src = bytes.NewReader(out)
default:
a.Unreadable++
return nil
}
br := bufio.NewReaderSize(io.LimitReader(src, MaxFileScan), 64<<10)
if head, _ := br.Peek(8 << 10); bytes.IndexByte(head, 0) >= 0 {
a.Binary++
return nil
}
var blocks blockState
n := 0
for {
raw, err := br.ReadString('\n')
if raw == "" && err != nil {
return nil
}
n++
if n%1000 == 0 && r.Now().After(deadline) {
a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime)
return errBound
}
block := blocks.in(raw)
hay := raw
if fold {
hay = strings.ToLower(hay)
}
if strings.Contains(hay, needle) {
if len(a.Matches) >= bound {
a.Bound = fmt.Sprintf("matches: %d answered, as asked or the default", bound)
return errBound
}
text := strings.TrimRight(raw, "\r\n")
if r := []rune(text); len(r) > MaxLine {
text = string(r[:MaxLine]) + "…"
}
if s, what := clean(text); what != "" || block {
if block {
s, what = "‹withheld: a key or certificate block›", "a key or certificate block"
}
a.Withheld = append(a.Withheld, Withheld{At: "a matching line", Class: what})
text = s
}
a.Matches = append(a.Matches, Match{File: p, Line: n, Text: text})
}
if err != nil {
return nil
}
}
}
// blockState follows BEGIN … END blocks through a file (the review of #244): a private key's body is base64 a
// line at a time, and one line of it can pass the content rule alone, so every line from a BEGIN through its
// END is withheld — a certificate's too, which costs nothing a read needs.
type blockState struct{ open bool }
func (b *blockState) in(raw string) bool {
switch {
case strings.Contains(raw, "-----BEGIN "):
b.open = !strings.Contains(raw, "-----END ")
return true
case b.open:
if strings.Contains(raw, "-----END ") {
b.open = false
}
return true
}
return false
}
// ---- accounts ----
// Account is one account as the account file says it, never its password field.
type Account struct {
Name string `json:"name"`
UID int `json:"uid"`
GID int `json:"gid"`
Group string `json:"group,omitempty"`
Gecos string `json:"gecos,omitempty"`
Home string `json:"home"`
Shell string `json:"shell"`
Groups []string `json:"groups,omitempty"`
}
// GroupEntry is one group.
type GroupEntry struct {
Name string `json:"name"`
GID int `json:"gid"`
Members []string `json:"members"`
}
// AccountAnswer is what account answers.
type AccountAnswer struct {
Accounts []Account `json:"accounts,omitempty"`
Groups []GroupEntry `json:"groups,omitempty"`
Source string `json:"source"`
Cut string `json:"cut,omitempty"`
Withheld []Withheld `json:"withheld,omitempty"`
}
type accounts struct {
users []Account
groups []GroupEntry
}
func (a accounts) user(uid int) string {
for _, u := range a.users {
if u.UID == uid {
return u.Name
}
}
return ""
}
func (a accounts) group(gid int) string {
for _, g := range a.groups {
if g.GID == gid {
return g.Name
}
}
return ""
}
// names reads the account files; a file it cannot read gives no names, and a component says only numbers.
func (r Reader) names() accounts {
var a accounts
if raw, err := os.ReadFile(r.Passwd); err == nil {
for _, l := range strings.Split(string(raw), "\n") {
f := strings.Split(l, ":")
if len(f) < 7 || strings.HasPrefix(l, "#") {
continue
}
uid, e1 := strconv.Atoi(f[2])
gid, e2 := strconv.Atoi(f[3])
if e1 != nil || e2 != nil {
continue
}
a.users = append(a.users, Account{Name: f[0], UID: uid, GID: gid, Gecos: f[4], Home: f[5], Shell: f[6]})
}
}
if raw, err := os.ReadFile(r.Group); err == nil {
for _, l := range strings.Split(string(raw), "\n") {
f := strings.Split(l, ":")
if len(f) < 4 || strings.HasPrefix(l, "#") {
continue
}
gid, err := strconv.Atoi(f[2])
if err != nil {
continue
}
members := []string{}
for _, m := range strings.Split(f[3], ",") {
if m = strings.TrimSpace(m); m != "" {
members = append(members, m)
}
}
a.groups = append(a.groups, GroupEntry{Name: f[0], GID: gid, Members: members})
}
}
return a
}
func (r Reader) Account(name, group string) (AccountAnswer, error) {
all := r.names()
if len(all.users) == 0 {
return AccountAnswer{}, fmt.Errorf("%s gave no account: it is unreadable or empty", r.Passwd)
}
out := AccountAnswer{Source: r.Passwd + ", " + r.Group + " (an account kept elsewhere, such as a directory " +
"service or systemd's dynamic accounts, is not here)"}
complete := func(u Account) Account {
u.Group = all.group(u.GID)
for _, g := range all.groups {
if g.GID == u.GID {
u.Groups = append(u.Groups, g.Name)
continue
}
for _, m := range g.Members {
if m == u.Name {
u.Groups = append(u.Groups, g.Name)
}
}
}
return u
}
name, group = strings.TrimSpace(name), strings.TrimSpace(group)
switch {
case name != "":
for _, u := range all.users {
if u.Name == name || strconv.Itoa(u.UID) == name {
out.Accounts = append(out.Accounts, complete(u))
}
}
if len(out.Accounts) == 0 {
return AccountAnswer{}, fmt.Errorf("no account %q on this node", name)
}
case group != "":
for _, g := range all.groups {
if g.Name == group || strconv.Itoa(g.GID) == group {
// Its members as getent says them, and every account whose own group it is.
for _, u := range all.users {
if u.GID == g.GID && !contains(g.Members, u.Name) {
g.Members = append(g.Members, u.Name)
}
}
out.Groups = append(out.Groups, g)
}
}
if len(out.Groups) == 0 {
return AccountAnswer{}, fmt.Errorf("no group %q on this node", group)
}
default:
for _, u := range all.users {
out.Accounts = append(out.Accounts, complete(u))
}
out.Groups = all.groups
if len(out.Accounts) > MaxAccounts {
out.Cut = fmt.Sprintf("cut at %d of %d accounts", MaxAccounts, len(out.Accounts))
out.Accounts = out.Accounts[:MaxAccounts]
}
if len(out.Groups) > MaxAccounts {
out.Cut = strings.TrimPrefix(out.Cut+"; ", "; ") + fmt.Sprintf("cut at %d of %d groups", MaxAccounts, len(out.Groups))
out.Groups = out.Groups[:MaxAccounts]
}
}
for i := range out.Accounts {
u := &out.Accounts[i]
for _, f := range []*string{&u.Gecos, &u.Home, &u.Shell} {
if s, what := clean(*f); what != "" {
out.Withheld = append(out.Withheld, Withheld{At: "account " + u.Name, Class: what})
*f = s
}
}
}
sort.SliceStable(out.Accounts, func(i, j int) bool { return out.Accounts[i].UID < out.Accounts[j].UID })
return out, nil
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// ---- mounts ----
// mounts reads the mount points this process sees, by path. /proc/self/mountinfo is readable by every account.
func (r Reader) mounts() map[string]Mount {
out := map[string]Mount{}
raw, err := os.ReadFile(r.MountInfo)
if err != nil {
return out
}
for _, l := range strings.Split(string(raw), "\n") {
f := strings.Fields(l)
dash := -1
for i, x := range f {
if x == "-" {
dash = i
break
}
}
if len(f) < 5 || dash < 0 || dash+2 >= len(f) {
continue
}
out[unescape(f[4])] = Mount{FS: f[dash+1], Source: unescape(f[dash+2])}
}
return out
}
// unescape reads mountinfo's octal escapes (\040 for a space).
func unescape(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+3 < len(s) {
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
b.WriteByte(byte(v))
i += 3
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}