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
1272 lines
38 KiB
Go
1272 lines
38 KiB
Go
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()
|
||
}
|