diff --git a/.gitignore b/.gitignore index 9adfdd46..a856002b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge modules/mesh-knowledge/mesh-knowledge modules/mesh-reads/mesh-reads modules/mesh-reads/cmd/mesh-reads/mesh-reads +modules/mesh-files/mesh-files +modules/mesh-files/cmd/mesh-files/mesh-files # The provisioner client built in place (issue 671). modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client diff --git a/modules/mesh-files/README.md b/modules/mesh-files/README.md new file mode 100644 index 00000000..f844592b --- /dev/null +++ b/modules/mesh-files/README.md @@ -0,0 +1,63 @@ +# mesh-files + +The holder of the node seat `node-files` (novox/hq ADR 0394, issue 900; to-be 41 WP9). It reads and manages +the files and accounts of the node it runs on, through the mesh, so nothing about a node's files goes through +a shell. + +It replaces `mesh-reads` (ADR 0310), which stays beside it until `mesh-files` is assigned on every node (ADR 0394 §9). + +## Verbs + +The seat's verbs, with the descriptions the controller defines (`internal/catalogue/files.go`). + +- **Reads:** `stat`, `readlink`, `list`, `read`, `search`, `account`. As in mesh-reads: bounded answers, a place a + secret is kept refused by name before any read, a line shaped like a secret withheld. +- **Writes:** `write` (a text file whole, with an optional mode), `copy`, `move`, `delete` (recursive on + request), `mkdir` (parents on request), `chmod` (octal or `u+x,go-w`, recursive on request), `chown` (owner, + optional group, recursive on request). +- **Trash:** `trash` lists it, `restore` puts an item back. + +Every write verb takes `reason` (required, for the audit trail) and `override` (a reason; needed only for a +path a module owns). + +## Rights + +It runs as the tool runner's account, the node's operator account, and declares no account of its own. An act +is tried with that account's rights first. Where that fails with a permission error, the act is repeated +through `sudo -n` with a fixed binary (cat, cp, mv, stat, chmod, chown, mkdir, rm under `/usr/bin`) and an +argument list built from fixed words and one path, never a shell (`sudo.go`). Tests use a fake runner. +Not yet through sudo: `stat`, `list`, `readlink` and `search` (they answer what the account reaches and say what it +may not); `read` and every write do. + +## Owners and modes + +- `copy` and `move` keep owner and mode (`cp -a`); a copy refuses a target that exists. +- A new file or folder takes its folder's owner and group. +- An overwrite keeps the overwritten file's owner and mode. + +## Trash + +`delete`, and the overwrite of `write`, first move the old item to `/trash//item` with a +`meta.json` (original path, owner, group, mode, caller, reason, time). `restore` puts it back and refuses when +the path is taken now. Entries older than 7 days are emptied on every write call and every `trash` list. + +## Owned paths + +The controller gives the holder `MESH_OWNED_PATHS`, a JSON object from an absolute path to `module (tool)`. A +write, move or delete of such a path, anything under it, or (for delete, move and recursive chmod and chown) a +folder that holds it, is refused naming the module and tool, unless `override` gives a reason. The override is +recorded in the act log and told to the operator, in words without a path, through `operator-channel.tell`. +Without the word, the module starts and owns nothing. + +## The act log + +Each write act that was done is one JSON line in `/acts.log`: time, caller, verb, paths, reason, and for +an override the override and the owned paths it went through. The caller is the bus user the runtime proved +(`stdio.CallerOf`), or `unproven`; it is never read from the arguments. A refused act is not logged. + +## What it refuses + +- A place a secret is kept (`MESH_SECRET_PLACES`, and the known credential files in `refuse.go`) is refused by + name for `read`, `list` and `search`. Copying or moving such a place is allowed: nothing is printed. +- A copy or move to or from `:/path` is refused: copying between nodes comes in step 2 (issue 903). +- A write without a reason; a path that is not absolute; a mode that is not a mode. diff --git a/modules/mesh-files/cmd/mesh-files/files.go b/modules/mesh-files/cmd/mesh-files/files.go new file mode 100644 index 00000000..c8eea476 --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/files.go @@ -0,0 +1,913 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// OwnedWord is where the controller names the paths a module owns on this node (novox/hq ADR 0394 §5): a JSON +// object from each absolute path to "module (tool)". +const OwnedWord = "MESH_OWNED_PATHS" + +// ownedFrom reads the controller's object. Absent is not an error: the controller part may roll later than this +// module, and until it does, nothing is owned. +func ownedFrom(raw string) (map[string]string, error) { + owned := map[string]string{} + if strings.TrimSpace(raw) == "" { + return owned, nil + } + if err := json.Unmarshal([]byte(raw), &owned); err != nil { + return nil, fmt.Errorf("%s is not an object from a path to its owner: %v", OwnedWord, err) + } + for p := range owned { + if !strings.HasPrefix(p, "/") { + return nil, fmt.Errorf("%s names %q, which is not an absolute path", OwnedWord, p) + } + } + return owned, nil +} + +// Ident is an owner and a group, by number. +type Ident struct{ UID, GID int } + +func ownerOnDisk(p string) (Ident, error) { + info, err := os.Lstat(p) + if err != nil { + return Ident{}, err + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return Ident{}, fmt.Errorf("%s: this system does not say who owns a file", p) + } + return Ident{int(st.Uid), int(st.Gid)}, nil +} + +// Files reads and manages one node's files (novox/hq ADR 0394). It acts as the account it runs as, and repeats +// an act through Root where that account's rights do not reach. +type Files struct { + Reader + // State is the module's state directory: the trash and the act log. + State string + // Owned maps a path a module owns on this node to "module (tool)". + Owned map[string]string + // Node is this node's name, for the notice of an override. + Node string + // Tell tells the operator a plain notice (operator-channel.tell). + Tell func(headline string, lines []string, subject string) error + // OwnerOf says who owns a file; Lchown changes it; NewID makes a trash id. Tests replace each. + OwnerOf func(path string) (Ident, error) + Lchown func(path string, uid, gid int) error + NewID func() string + + mu sync.Mutex +} + +// Act is who asks a write, and why. +type Act struct { + // Caller is the bus user the runtime proved (never an argument). + Caller string + // Reason is why the file is changed; required. + Reason string + // Override is a reason to change a path a module owns anyway. + Override string +} + +// ActAnswer is what a write verb answers. +type ActAnswer struct { + Done string `json:"done"` + Paths []string `json:"paths"` + Trashed string `json:"trashed,omitempty"` + Overrides []string `json:"overridden,omitempty"` + Notice string `json:"notice,omitempty"` +} + +var remote = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*:/`) + +func notRemote(p string) error { + if remote.MatchString(strings.TrimSpace(p)) { + return fmt.Errorf("%s is on another node: copying between nodes comes in step 2 (issue 903)", p) + } + return nil +} + +func (f *Files) ownerOf(p string) (Ident, error) { + if f.OwnerOf != nil { + return f.OwnerOf(p) + } + return ownerOnDisk(p) +} + +func (f *Files) asRoot(bin string, args ...string) error { + if f.Root == nil { + return errors.New("this account may not do that, and sudo is not available to this holder") + } + _, err := f.Root.Run(bin, args...) + return err +} + +func denied(err error) bool { return errors.Is(err, fs.ErrPermission) } + +// lchown changes an owner (-1: leave as it is), through root where the account may not. +func (f *Files) lchown(path string, uid, gid int) error { + do := f.Lchown + if do == nil { + do = os.Lchown + } + err := do(path, uid, gid) + if err == nil || !denied(err) { + return err + } + spec := "" + if uid >= 0 { + spec = strconv.Itoa(uid) + } + if gid >= 0 { + spec += ":" + strconv.Itoa(gid) + } + if spec == "" { + return nil + } + return f.asRoot("/usr/bin/chown", spec, "--", path) +} + +// chownTo gives a file an owner it has not got yet. +func (f *Files) chownTo(path string, id Ident) error { + if now, err := ownerOnDisk(path); err == nil && now == id { + return nil + } + return f.lchown(path, id.UID, id.GID) +} + +func fileMode(perm uint32) fs.FileMode { + m := fs.FileMode(perm & 0o777) + if perm&0o4000 != 0 { + m |= fs.ModeSetuid + } + if perm&0o2000 != 0 { + m |= fs.ModeSetgid + } + if perm&0o1000 != 0 { + m |= fs.ModeSticky + } + return m +} + +func permOf(m fs.FileMode) uint32 { + p := uint32(m.Perm()) + if m&fs.ModeSetuid != 0 { + p |= 0o4000 + } + if m&fs.ModeSetgid != 0 { + p |= 0o2000 + } + if m&fs.ModeSticky != 0 { + p |= 0o1000 + } + return p +} + +// setMode sets a file's permission bits, through root where the account may not. +func (f *Files) setMode(path string, perm uint32) error { + err := os.Chmod(path, fileMode(perm)) + if err == nil || !denied(err) { + return err + } + return f.asRoot("/usr/bin/chmod", fmt.Sprintf("%04o", perm), "--", path) +} + +// ---- the owned paths (§5) and the act log (§6) ---- + +func within2(path, place string) bool { return within(filepath.Clean(path), filepath.Clean(place)) } + +// guard refuses a change of what a module owns, unless the act gives an override. wide: the act also takes +// away or changes everything beneath the path, so a path that holds an owned one is owned too. It answers the +// owned paths the override went through. +func (f *Files) guard(a Act, wide bool, paths ...string) ([]hit, error) { + var hits []hit + for _, p := range paths { + for owned, owner := range f.Owned { + if within2(p, owned) || (wide && within2(owned, p)) { + if a.Override == "" { + return nil, fmt.Errorf("%s is owned by %s: change it with that, or give override with a reason "+ + "(novox/hq ADR 0394 §5)", owned, owner) + } + hits = append(hits, hit{owned, owner}) + } + } + } + return hits, nil +} + +// hit is an owned path an override went through, and who owns it. +type hit struct{ Path, Owner string } + +func (a Act) check() error { + if strings.TrimSpace(a.Reason) == "" { + return errors.New("reason is required: say why the file is changed, for the audit trail (novox/hq ADR 0394 §6)") + } + return nil +} + +type actLine struct { + Time string `json:"time"` + Caller string `json:"caller"` + Verb string `json:"verb"` + Paths []string `json:"paths"` + Reason string `json:"reason"` + Override string `json:"override,omitempty"` + Owned []string `json:"owned,omitempty"` + Trashed string `json:"trashed,omitempty"` +} + +// finish records a write act that was done: one line in the act log, and, for an override that was needed, a +// notice to the operator. A notice that cannot be sent is said in the answer, never swallowed. +func (f *Files) finish(a Act, verb string, hits []hit, ans ActAnswer) (ActAnswer, error) { + l := actLine{Time: f.Now().UTC().Format(time.RFC3339), Caller: a.Caller, Verb: verb, Paths: ans.Paths, + Reason: a.Reason, Trashed: ans.Trashed} + if len(hits) > 0 { + l.Override = a.Override + for _, h := range hits { + l.Owned = append(l.Owned, h.Path+" ("+h.Owner+")") + } + ans.Overrides = l.Owned + } + body, _ := json.Marshal(l) + if err := os.MkdirAll(f.State, 0o700); err != nil { + return ans, fmt.Errorf("%s was done, but the act log cannot be written: %v", verb, err) + } + log, err := os.OpenFile(filepath.Join(f.State, "acts.log"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600) + if err == nil { + _, err = log.Write(append(body, '\n')) + log.Close() + } + if err != nil { + return ans, fmt.Errorf("%s was done, but the act log cannot be written: %v", verb, err) + } + if len(hits) > 0 && f.Tell != nil { + // No path in the words: the operator channel holds its words to the content rule, which refuses one. + // The path is in the act log. + err := f.Tell("A module's file was changed by override", + []string{fmt.Sprintf("%s owns it, and %s changed it anyway.", strings.Join(owners(hits), " and "), a.Caller), + "The reason given: " + a.Override}, + "mesh-files on "+f.Node) + if err != nil { + ans.Notice = "the operator was not told: " + err.Error() + fmt.Fprintf(os.Stderr, "[mesh-files] %s\n", ans.Notice) + } + } + return ans, nil +} + +func owners(hits []hit) []string { + seen := map[string]bool{} + var out []string + for _, h := range hits { + if !seen[h.Owner] { + seen[h.Owner] = true + out = append(out, h.Owner) + } + } + return out +} + +// ---- the write verbs ---- + +func (f *Files) begin(a Act) error { + if err := a.check(); err != nil { + return err + } + f.sweep() + return nil +} + +func parseMode(s string) (uint32, error) { + n, err := strconv.ParseUint(strings.TrimSpace(s), 8, 32) + if err != nil || n > 0o7777 { + return 0, fmt.Errorf("%q is not a mode such as 0644", s) + } + return uint32(n), nil +} + +// Write writes one text file whole. The old file, if any, goes to the trash first; the new one keeps its owner +// and mode, or, when new, takes its folder's owner and group (ADR 0394 §3). +func (f *Files) Write(a Act, raw, content, mode string) (ActAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return ActAnswer{}, err + } + path, err := absolute(raw) + if err != nil { + return ActAnswer{}, err + } + hits, err := f.guard(a, false, path) + if err != nil { + return ActAnswer{}, err + } + var perm uint32 = 0o644 + asked := false + if strings.TrimSpace(mode) != "" { + if perm, err = parseMode(mode); err != nil { + return ActAnswer{}, err + } + asked = true + } + if _, err := os.Stat(filepath.Dir(path)); err != nil { + return ActAnswer{}, fmt.Errorf("the folder of %s cannot be reached: %s", path, why(err)) + } + want, trashed := Ident{}, "" + if old, err := os.Lstat(path); err == nil { + if !old.Mode().IsRegular() { + return ActAnswer{}, fmt.Errorf("%s is a %s, not a file: write replaces a file whole", path, typeOf(old.Mode())) + } + if want, err = f.ownerOf(path); err != nil { + return ActAnswer{}, err + } + if !asked { + perm = permOf(old.Mode()) + } + if trashed, err = f.toTrash(a, path); err != nil { + return ActAnswer{}, err + } + } else if want, err = f.ownerOf(filepath.Dir(path)); err != nil { + return ActAnswer{}, err + } + if err := f.create(path, content, perm); err != nil { + if trashed != "" { + if _, rerr := f.restore(trashed); rerr != nil { + err = fmt.Errorf("%v (and the old file could not be put back from the trash, id %s: %v)", err, trashed, rerr) + } + } + return ActAnswer{}, err + } + if err := f.chownTo(path, want); err != nil { + return ActAnswer{}, fmt.Errorf("%s was written, but its owner could not be set: %v", path, err) + } + if perm&0o6000 != 0 { // the chown cleared setuid and setgid + if err := f.setMode(path, perm); err != nil { + return ActAnswer{}, err + } + } + return f.finish(a, "write", hits, ActAnswer{Done: "wrote " + path, Paths: []string{path}, Trashed: trashed}) +} + +// create makes a new file with a content and a mode, as the account, or through root where the folder is not its. +func (f *Files) create(path, content string, perm uint32) error { + out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + _, err = io.WriteString(out, content) + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + os.Remove(path) + return err + } + return f.setMode(path, perm) + } + if !denied(err) { + return err + } + tmp := filepath.Join(f.State, "tmp-"+f.NewID()) + if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil { + return err + } + defer os.Remove(tmp) + if err := f.asRoot("/usr/bin/cp", "--", tmp, path); err != nil { + return err + } + return f.setMode(path, perm) +} + +// copyTree copies a file, a link or a directory with what it holds, keeping modes, owners and times (cp -a). +func (f *Files) copyTree(from, to string) error { + info, err := os.Lstat(from) + if err != nil { + return err + } + id, err := f.ownerOf(from) + if err != nil { + return err + } + switch { + case info.Mode()&fs.ModeSymlink != 0: + target, err := os.Readlink(from) + if err != nil { + return err + } + if err := os.Symlink(target, to); err != nil { + return err + } + return f.chownTo(to, id) + case info.IsDir(): + if err := os.Mkdir(to, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(from) + if err != nil { + return err + } + for _, e := range entries { + if err := f.copyTree(filepath.Join(from, e.Name()), filepath.Join(to, e.Name())); err != nil { + return err + } + } + case info.Mode().IsRegular(): + in, err := os.Open(from) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, err = io.Copy(out, in) + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + default: + return fmt.Errorf("%s is a %s, which is not copied", from, typeOf(info.Mode())) + } + // The owner first: a chown clears setuid and setgid, which the mode then sets again. + if err := f.chownTo(to, id); err != nil { + return err + } + if err := f.setMode(to, permOf(info.Mode())); err != nil { + return err + } + return os.Chtimes(to, info.ModTime(), info.ModTime()) +} + +// relocate moves a path: a rename, a copy and removal across file systems, and, where the account may not, mv +// through root. It keeps owner and mode. +func (f *Files) relocate(from, to string) error { + err := os.Rename(from, to) + switch { + case err == nil: + return nil + case errors.Is(err, syscall.EXDEV): + if err := f.copyTree(from, to); err != nil { + os.RemoveAll(to) + return err + } + return f.removeAll(from) + case denied(err): + return f.asRoot("/usr/bin/mv", "--", from, to) + } + return err +} + +func (f *Files) removeAll(p string) error { + err := os.RemoveAll(p) + if err != nil && denied(err) { + return f.asRoot("/usr/bin/rm", "-rf", "--", p) + } + return err +} + +// target checks the new path of a copy or a move: on this node, in a folder that is there, and not the source, +// inside it or holding it. An existing target is overwritten by the caller. +func target(rawFrom, from, raw string) (string, error) { + if err := notRemote(raw); err != nil { + return "", err + } + to, err := absolute(raw) + if err != nil { + return "", err + } + if within(to, from) || within(from, to) { + return "", fmt.Errorf("%s and %s are the same path, or one is inside the other", from, to) + } + if _, err := os.Stat(filepath.Dir(to)); err != nil { + return "", fmt.Errorf("the folder of %s cannot be reached: %s", to, why(err)) + } + return to, nil +} + +// overwritten puts an existing target in the trash and says whose it was, so the new one can take its owner and +// mode; it is nothing for a target that is not there. +type overwritten struct { + trashed string + id Ident + perm uint32 + was bool +} + +func (f *Files) makeRoom(a Act, to string) (overwritten, error) { + info, err := os.Lstat(to) + if err != nil { + return overwritten{}, nil + } + id, err := f.ownerOf(to) + if err != nil { + return overwritten{}, err + } + tid, err := f.toTrash(a, to) + if err != nil { + return overwritten{}, err + } + return overwritten{trashed: tid, id: id, perm: permOf(info.Mode()), was: true}, nil +} + +// settle gives the new target the overwritten one's owner and mode, or, if the act failed, puts the old one back. +func (f *Files) settle(o overwritten, to string, actErr error) error { + if !o.was { + return actErr + } + if actErr != nil { + if _, rerr := f.restore(o.trashed); rerr != nil { + return fmt.Errorf("%v (and the old target could not be put back from the trash, id %s: %v)", actErr, o.trashed, rerr) + } + return actErr + } + if err := f.chownTo(to, o.id); err != nil { + return fmt.Errorf("%s is there, but its owner could not be set: %v", to, err) + } + if err := f.setMode(to, o.perm); err != nil { + return fmt.Errorf("%s is there, but its mode could not be set: %v", to, err) + } + return nil +} + +// Copy copies a file or a directory to a new path on this node, keeping owner and mode. +func (f *Files) Copy(a Act, rawFrom, rawTo string) (ActAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return ActAnswer{}, err + } + if err := notRemote(rawFrom); err != nil { + return ActAnswer{}, err + } + from, err := absolute(rawFrom) + if err != nil { + return ActAnswer{}, err + } + to, err := target(rawFrom, from, rawTo) + if err != nil { + return ActAnswer{}, err + } + hits, err := f.guard(a, true, to) // an existing target is replaced whole, with what it holds + if err != nil { + return ActAnswer{}, err + } + if _, err := os.Lstat(from); err != nil { + return ActAnswer{}, fmt.Errorf("%s cannot be copied: %s", from, why(err)) + } + room, err := f.makeRoom(a, to) + if err != nil { + return ActAnswer{}, err + } + err = f.copyTree(from, to) + if err != nil { + os.RemoveAll(to) + if denied(err) { + err = f.asRoot("/usr/bin/cp", "-a", "--", from, to) + } + if err != nil { + err = fmt.Errorf("%s cannot be copied: %v", from, err) + } + } + if err := f.settle(room, to, err); err != nil { + return ActAnswer{}, err + } + return f.finish(a, "copy", hits, ActAnswer{Done: "copied " + from + " to " + to, Paths: []string{from, to}, Trashed: room.trashed}) +} + +// Move moves or renames a file or a directory on this node, keeping owner and mode. +func (f *Files) Move(a Act, rawFrom, rawTo string) (ActAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return ActAnswer{}, err + } + if err := notRemote(rawFrom); err != nil { + return ActAnswer{}, err + } + from, err := absolute(rawFrom) + if err != nil { + return ActAnswer{}, err + } + to, err := target(rawFrom, from, rawTo) + if err != nil { + return ActAnswer{}, err + } + hits, err := f.guard(a, true, from) + if err != nil { + return ActAnswer{}, err + } + more, err := f.guard(a, true, to) + if err != nil { + return ActAnswer{}, err + } + hits = append(hits, more...) + if _, err := os.Lstat(from); err != nil { + return ActAnswer{}, fmt.Errorf("%s cannot be moved: %s", from, why(err)) + } + room, err := f.makeRoom(a, to) + if err != nil { + return ActAnswer{}, err + } + err = f.relocate(from, to) + if err != nil { + err = fmt.Errorf("%s cannot be moved: %v", from, err) + } + if err := f.settle(room, to, err); err != nil { + return ActAnswer{}, err + } + return f.finish(a, "move", hits, ActAnswer{Done: "moved " + from + " to " + to, Paths: []string{from, to}, Trashed: room.trashed}) +} + +// Delete moves a file or a directory into the node's trash. +func (f *Files) Delete(a Act, raw string, recursive bool) (ActAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return ActAnswer{}, err + } + path, err := absolute(raw) + if err != nil { + return ActAnswer{}, err + } + if path == "/" { + return ActAnswer{}, errors.New("/ is not deleted") + } + hits, err := f.guard(a, true, path) + if err != nil { + return ActAnswer{}, err + } + info, err := os.Lstat(path) + if err != nil { + return ActAnswer{}, fmt.Errorf("%s cannot be deleted: %s", path, why(err)) + } + if info.IsDir() && !recursive { + return ActAnswer{}, fmt.Errorf("%s is a directory: give recursive \"true\" to delete it with what it holds", path) + } + id, err := f.toTrash(a, path) + if err != nil { + return ActAnswer{}, err + } + return f.finish(a, "delete", hits, ActAnswer{Done: "deleted " + path + " into the trash", Paths: []string{path}, Trashed: id}) +} + +// Mkdir makes a directory, and with parents the missing ones above it; each takes its folder's owner. +func (f *Files) Mkdir(a Act, raw string, parents bool) (ActAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return ActAnswer{}, err + } + path, err := absolute(raw) + if err != nil { + return ActAnswer{}, err + } + hits, err := f.guard(a, false, path) + if err != nil { + return ActAnswer{}, err + } + if _, err := os.Lstat(path); err == nil { + return ActAnswer{}, fmt.Errorf("%s is there already", path) + } + var missing []string // from the first missing folder down + for p := path; ; p = filepath.Dir(p) { + if _, err := os.Lstat(p); err == nil { + break + } + missing = append([]string{p}, missing...) + if p == "/" { + break + } + } + if len(missing) > 1 && !parents { + return ActAnswer{}, fmt.Errorf("the folder of %s is not there: give parents \"true\" to make it too", path) + } + var made []string + for i, p := range missing { + err := os.Mkdir(p, 0o755) + if err == nil { + made = append(made, p) + continue + } + if !denied(err) { + return ActAnswer{}, err + } + // Root makes this folder and, with parents, every one below it in the same call. + args := []string{"--", p} + if parents { + args = []string{"-p", "--", path} + } + if err := f.asRoot("/usr/bin/mkdir", args...); err != nil { + return ActAnswer{}, err + } + made = append(made, missing[i:]...) + break + } + for _, p := range made { + if err := f.setMode(p, 0o755); err != nil { + return ActAnswer{}, err + } + if err := f.dirOwner(p); err != nil { + return ActAnswer{}, err + } + } + return f.finish(a, "mkdir", hits, ActAnswer{Done: "made " + path, Paths: []string{path}}) +} + +// dirOwner gives a new folder the owner and group of the folder it is in. +func (f *Files) dirOwner(p string) error { + id, err := f.ownerOf(filepath.Dir(p)) + if err != nil { + return err + } + return f.chownTo(p, id) +} + +var symbolic = regexp.MustCompile(`^([ugoa]*)([-+=])([rwxXst]*)$`) + +// newMode applies a mode as chmod says it: octal, or comma-separated clauses such as u+x,go-w. +func newMode(cur uint32, isDir bool, spec string) (uint32, error) { + spec = strings.TrimSpace(spec) + if m, err := parseMode(spec); err == nil { + return m, nil + } + for _, clause := range strings.Split(spec, ",") { + m := symbolic.FindStringSubmatch(clause) + if m == nil { + return 0, fmt.Errorf("%q is not a mode such as 0644 or u+x", spec) + } + who := m[1] + if who == "" || strings.Contains(who, "a") { + who = "ugo" + } + var bits, mask uint32 + for _, c := range m[3] { + switch c { + case 'r': + bits |= 0o444 + case 'w': + bits |= 0o222 + case 'x': + bits |= 0o111 + case 'X': + if isDir || cur&0o111 != 0 { + bits |= 0o111 + } + case 's': + bits |= 0o6000 + case 't': + bits |= 0o1000 + } + } + for _, w := range who { + switch w { + case 'u': + mask |= 0o4700 + case 'g': + mask |= 0o2070 + case 'o': + mask |= 0o1007 + } + } + bits &= mask + switch m[2] { + case "+": + cur |= bits + case "-": + cur &^= bits + case "=": + cur = cur&^(mask&0o777) | bits&0o777 + } + } + return cur, nil +} + +// walk lists a path and, when recursive, everything under it (links are not followed), children before parents. +func walk(path string, recursive bool) ([]string, error) { + if !recursive { + return []string{path}, nil + } + var all []string + err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + all = append(all, p) + return nil + }) + for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 { + all[i], all[j] = all[j], all[i] + } + return all, err +} + +// Chmod changes the mode of a path, and with recursive of everything under it. +func (f *Files) Chmod(a Act, raw, mode string, recursive bool) (ActAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return ActAnswer{}, err + } + path, err := absolute(raw) + if err != nil { + return ActAnswer{}, err + } + hits, err := f.guard(a, recursive, path) + if err != nil { + return ActAnswer{}, err + } + if _, err := newMode(0, false, mode); err != nil { + return ActAnswer{}, err + } + all, err := walk(path, recursive) + if err != nil { + return ActAnswer{}, fmt.Errorf("%s cannot be changed: %v", path, err) + } + for _, p := range all { + info, err := os.Lstat(p) + if err != nil { + return ActAnswer{}, err + } + if info.Mode()&fs.ModeSymlink != 0 { + continue + } + m, _ := newMode(permOf(info.Mode()), info.IsDir(), mode) + if err := f.setMode(p, m); err != nil { + return ActAnswer{}, fmt.Errorf("%s cannot be changed: %v", p, err) + } + } + return f.finish(a, "chmod", hits, ActAnswer{Done: "set the mode of " + path + " to " + mode, Paths: []string{path}}) +} + +// number resolves an account or group, by name or number, through this node's account files. +func (f *Files) number(kind, v string) (int, error) { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return n, nil + } + names := f.names() + if kind == "owner" { + for _, u := range names.users { + if u.Name == v { + return u.UID, nil + } + } + } else { + for _, g := range names.groups { + if g.Name == v { + return g.GID, nil + } + } + } + return 0, fmt.Errorf("%s %q is not an account or group of this node", kind, v) +} + +// Chown changes the owner, and with it the group, of a path, and with recursive of everything under it. +func (f *Files) Chown(a Act, raw, owner, group string, recursive bool) (ActAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return ActAnswer{}, err + } + path, err := absolute(raw) + if err != nil { + return ActAnswer{}, err + } + hits, err := f.guard(a, recursive, path) + if err != nil { + return ActAnswer{}, err + } + uid, err := f.number("owner", owner) + if err != nil { + return ActAnswer{}, err + } + gid := -1 + if strings.TrimSpace(group) != "" { + if gid, err = f.number("group", group); err != nil { + return ActAnswer{}, err + } + } + all, err := walk(path, recursive) + if err != nil { + return ActAnswer{}, fmt.Errorf("%s cannot be changed: %v", path, err) + } + for _, p := range all { + if err := f.lchown(p, uid, gid); err != nil { + return ActAnswer{}, fmt.Errorf("%s cannot be changed: %v", p, err) + } + } + return f.finish(a, "chown", hits, ActAnswer{Done: "gave " + path + " to " + owner, Paths: []string{path}}) +} diff --git a/modules/mesh-files/cmd/mesh-files/files_test.go b/modules/mesh-files/cmd/mesh-files/files_test.go new file mode 100644 index 00000000..d21eee72 --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/files_test.go @@ -0,0 +1,819 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "syscall" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// fakeRoot is the sudo -n runner of a test: it records each call, and answers a cat from a table. No test runs +// sudo (ADR 0391). +type fakeRoot struct { + mu sync.Mutex + calls []string + cat map[string]string + out map[string]string // the answer to a whole call, "bin arg arg" + err error +} + +func (r *fakeRoot) Run(bin string, args ...string) ([]byte, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, bin+" "+strings.Join(args, " ")) + if o, ok := r.out[bin+" "+strings.Join(args, " ")]; ok { + return []byte(o), nil + } + if bin == "/usr/bin/cat" { + if s, ok := r.cat[args[len(args)-1]]; ok { + return []byte(s), nil + } + } + if r.err != nil { + return nil, r.err + } + // What root would do in a folder the account may not write, done in the test's tree. + switch bin { + case "/usr/bin/cp": + dest := args[len(args)-1] + os.Chmod(filepath.Dir(dest), 0o700) + b, err := os.ReadFile(args[len(args)-2]) + if err != nil { + return nil, err + } + return nil, os.WriteFile(dest, b, 0o600) + case "/usr/bin/mkdir": + dest := args[len(args)-1] + for p := filepath.Dir(dest); p != "/"; p = filepath.Dir(p) { + if _, err := os.Stat(p); err == nil { + os.Chmod(p, 0o700) + break + } + } + return nil, os.MkdirAll(dest, 0o755) + } + return nil, nil +} + +// reach makes the fake answer, for a path, what root's readlink -e and stat would: a regular file that leads to itself. +func (r *fakeRoot) reach(p string) { + r.out["/usr/bin/readlink -e -- "+p] = p + "\n" + r.out["/usr/bin/stat -c "+statFormat+" -- "+p] = "regular file|1000|1000|600|-rw-------|1\n" +} + +func (r *fakeRoot) called(prefix string) bool { + for _, c := range r.calls { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +type rig struct { + *Files + root *fakeRoot + dir string + owners map[string]Ident + told []string +} + +// testFiles is a holder over a temporary tree: a fake root runner, a clock the test moves, owners the test +// names (a test cannot make a file of another account), and a chown that refuses what is not its own. +func testFiles(t *testing.T) *rig { + t.Helper() + r, dir := testReader(t) + g := &rig{root: &fakeRoot{cat: map[string]string{}, out: map[string]string{}}, dir: dir, owners: map[string]Ident{}} + r.Root = g.root + r.Now = func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) } + seq := 0 + g.Files = &Files{Reader: r, State: filepath.Join(dir, "state"), Owned: map[string]string{}, Node: "shanks", + OwnerOf: func(p string) (Ident, error) { + if id, ok := g.owners[p]; ok { + return id, nil + } + return ownerOnDisk(p) + }, + Lchown: func(p string, uid, gid int) error { return &fs.PathError{Op: "lchown", Path: p, Err: syscall.EPERM} }, + NewID: func() string { seq++; return fmt.Sprintf("t%02d", seq) }, + Tell: func(headline string, lines []string, subject string) error { + g.told = append(g.told, subject+" | "+headline+" | "+strings.Join(lines, " | ")) + return nil + }} + if err := os.MkdirAll(g.State, 0o700); err != nil { + t.Fatal(err) + } + return g +} + +var by = Act{Caller: "person.jochen", Reason: "a test"} + +func (g *rig) path(name string) string { return filepath.Join(g.dir, name) } + +func mustRead(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func modeOf(t *testing.T, p string) fs.FileMode { + t.Helper() + i, err := os.Lstat(p) + if err != nil { + t.Fatal(err) + } + return i.Mode().Perm() +} + +func actLines(t *testing.T, g *rig) []map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join(g.State, "acts.log")) + if err != nil { + return nil + } + var out []map[string]any + for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var m map[string]any + if err := json.Unmarshal([]byte(l), &m); err != nil { + t.Fatalf("a line of the act log is not JSON: %q", l) + } + out = append(out, m) + } + return out +} + +// ADR 0394 §1: every write names why. +func TestAWriteWithoutAReasonIsRefused(t *testing.T) { + g := testFiles(t) + if _, err := g.Write(Act{Caller: "x"}, g.path("a"), "x", ""); err == nil || !strings.Contains(err.Error(), "reason") { + t.Errorf("no reason: %v", err) + } + if _, err := os.Stat(g.path("a")); err == nil { + t.Error("the file was written") + } +} + +// ADR 0394 §3: a new file takes its folder's owner and group, and the mode asked. +func TestANewFileTakesItsFoldersOwner(t *testing.T) { + g := testFiles(t) + g.owners[g.dir] = Ident{4242, 4343} + if _, err := g.Write(by, g.path("new"), "hello\n", "0600"); err != nil { + t.Fatal(err) + } + if mustRead(t, g.path("new")) != "hello\n" || modeOf(t, g.path("new")) != 0o600 { + t.Errorf("content or mode wrong: %o", modeOf(t, g.path("new"))) + } + if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("new")) { + t.Errorf("the owner of the folder was not given: %v", g.root.calls) + } +} + +// ADR 0394 §3, §4: an overwrite keeps the old file's owner and mode, and the old file goes to the trash. +func TestAnOverwriteKeepsOwnerAndModeAndTrashesTheOld(t *testing.T) { + g := testFiles(t) + write(t, g.path("f"), "old\n") + os.Chmod(g.path("f"), 0o640) + g.owners[g.path("f")] = Ident{4242, 4343} + if _, err := g.Write(by, g.path("f"), "new\n", ""); err != nil { + t.Fatal(err) + } + if mustRead(t, g.path("f")) != "new\n" || modeOf(t, g.path("f")) != 0o640 { + t.Errorf("content %q mode %o", mustRead(t, g.path("f")), modeOf(t, g.path("f"))) + } + if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("f")) { + t.Errorf("the old owner was not given back: %v", g.root.calls) + } + tr, err := g.Trash() + if err != nil || len(tr.Items) != 1 || tr.Items[0].Path != g.path("f") { + t.Fatalf("trash: %+v %v", tr, err) + } + if mustRead(t, filepath.Join(g.State, "trash", tr.Items[0].ID, "item")) != "old\n" { + t.Error("the old content is not in the trash") + } +} + +// ADR 0394 §3: a copy keeps mode and owner, a directory with what it holds. +func TestACopyKeepsModeAndOwner(t *testing.T) { + g := testFiles(t) + write(t, g.path("src/a"), "A\n") + os.Chmod(g.path("src/a"), 0o750) + g.owners[g.path("src/a")] = Ident{4242, 4343} + if _, err := g.Copy(by, g.path("src"), g.path("dst")); err != nil { + t.Fatal(err) + } + if mustRead(t, g.path("dst/a")) != "A\n" || modeOf(t, g.path("dst/a")) != 0o750 { + t.Errorf("copy: %q %o", mustRead(t, g.path("dst/a")), modeOf(t, g.path("dst/a"))) + } + if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("dst/a")) { + t.Errorf("owner not kept: %v", g.root.calls) + } + if _, err := os.Stat(g.path("src/a")); err != nil { + t.Error("the source is gone") + } +} + +// ADR 0394 §3: a move keeps mode and owner, and the source is gone. +func TestAMoveKeepsModeAndOwner(t *testing.T) { + g := testFiles(t) + write(t, g.path("a"), "A\n") + os.Chmod(g.path("a"), 0o700) + if _, err := g.Move(by, g.path("a"), g.path("b")); err != nil { + t.Fatal(err) + } + if mustRead(t, g.path("b")) != "A\n" || modeOf(t, g.path("b")) != 0o700 { + t.Error("the move lost content or mode") + } + if _, err := os.Stat(g.path("a")); err == nil { + t.Error("the source is still there") + } +} + +// ADR 0394 §4: a delete goes to the trash, restore puts it back, and refuses when the path is taken now. +func TestADeleteGoesToTheTrashAndRestoreBringsItBack(t *testing.T) { + g := testFiles(t) + write(t, g.path("d/f"), "keep\n") + if _, err := g.Delete(by, g.path("d"), false); err == nil || !strings.Contains(err.Error(), "recursive") { + t.Errorf("a directory without recursive: %v", err) + } + if _, err := g.Delete(by, g.path("d"), true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(g.path("d")); err == nil { + t.Fatal("not deleted") + } + tr, _ := g.Trash() + if len(tr.Items) != 1 { + t.Fatalf("trash %+v", tr) + } + id := tr.Items[0].ID + write(t, g.path("d/other"), "x") + if _, err := g.Restore(by, id); err == nil || !strings.Contains(err.Error(), "there now") { + t.Errorf("restore over a taken path: %v", err) + } + os.RemoveAll(g.path("d")) + if _, err := g.Restore(by, id); err != nil { + t.Fatal(err) + } + if mustRead(t, g.path("d/f")) != "keep\n" { + t.Error("not restored") + } + if tr, _ := g.Trash(); len(tr.Items) != 0 { + t.Errorf("the restored item is still in the trash: %+v", tr) + } +} + +// ADR 0394 §4: an entry older than 7 days is emptied on a trash list and on a write call. +func TestATrashEntryOlderThanSevenDaysIsEmptied(t *testing.T) { + g := testFiles(t) + write(t, g.path("f"), "x") + if _, err := g.Delete(by, g.path("f"), false); err != nil { + t.Fatal(err) + } + now := g.Now() + g.Now = func() time.Time { return now.Add(6*24*time.Hour + 23*time.Hour) } + if tr, _ := g.Trash(); len(tr.Items) != 1 { + t.Fatalf("emptied too early: %+v", tr) + } + g.Now = func() time.Time { return now.Add(7*24*time.Hour + time.Minute) } + if tr, _ := g.Trash(); len(tr.Items) != 0 { + t.Fatalf("not emptied: %+v", tr) + } + entries, _ := os.ReadDir(filepath.Join(g.State, "trash")) + if len(entries) != 0 { + t.Errorf("the entry is still on disk: %v", entries) + } + // and on a write call + write(t, g.path("g"), "y") + g.Delete(by, g.path("g"), false) + g.Now = func() time.Time { return now.Add(30 * 24 * time.Hour) } + if _, err := g.Mkdir(by, g.path("m"), false); err != nil { + t.Fatal(err) + } + entries, _ = os.ReadDir(filepath.Join(g.State, "trash")) + if len(entries) != 0 { + t.Errorf("a write call left an old entry: %v", entries) + } +} + +// ADR 0394 §5: an owned path is refused naming its module and tool; with an override it is changed, logged and +// told to the operator. +func TestAnOwnedPathIsRefusedNamingItsOwnerAndPassesWithAnOverride(t *testing.T) { + g := testFiles(t) + g.Owned[g.path("etc")] = "mesh-hosts (node-hostname)" + write(t, g.path("etc/hosts"), "old\n") + for name, run := range map[string]func() error{ + "write": func() error { _, err := g.Write(by, g.path("etc/hosts"), "x", ""); return err }, + "delete": func() error { _, err := g.Delete(by, g.path("etc/hosts"), false); return err }, + "move": func() error { _, err := g.Move(by, g.path("etc/hosts"), g.path("elsewhere")); return err }, + "into": func() error { _, err := g.Move(by, g.path("elsewhere"), g.path("etc/new")); return err }, + "parent": func() error { _, err := g.Delete(by, g.dir, true); return err }, + } { + err := run() + if err == nil || !strings.Contains(err.Error(), "mesh-hosts (node-hostname)") { + t.Errorf("%s: %v", name, err) + } + } + if mustRead(t, g.path("etc/hosts")) != "old\n" { + t.Fatal("an owned file was changed") + } + if len(actLines(t, g)) != 0 || len(g.told) != 0 { + t.Error("a refused act was logged or told") + } + ov := by + ov.Override = "the module is down and the node cannot resolve" + if _, err := g.Write(ov, g.path("etc/hosts"), "new\n", ""); err != nil { + t.Fatal(err) + } + if mustRead(t, g.path("etc/hosts")) != "new\n" { + t.Error("the override did not write") + } + lines := actLines(t, g) + if len(lines) != 1 || lines[0]["override"] != ov.Override { + t.Errorf("log: %v", lines) + } + if len(g.told) != 1 || !strings.HasPrefix(g.told[0], "mesh-files on shanks | ") || + !strings.Contains(g.told[0], ov.Override) || !strings.Contains(g.told[0], "mesh-hosts (node-hostname)") || + !strings.Contains(g.told[0], "person.jochen") { + t.Errorf("told: %v", g.told) + } + if strings.Contains(g.told[0], "/") && strings.Contains(g.told[0], g.dir) { + t.Errorf("the notice carries a path, which the operator channel refuses: %v", g.told) + } + if l := actLines(t, g); !strings.Contains(fmt.Sprint(l[0]["owned"]), g.path("etc")) { + t.Errorf("the path is in the act log: %v", l[0]) + } + // an override on a path nobody owns says nothing + g.told = nil + if _, err := g.Write(ov, g.path("free"), "x", ""); err != nil || len(g.told) != 0 { + t.Errorf("an override that was not needed: %v %v", err, g.told) + } +} + +// ADR 0394 §7: a credential file is refused before it is read, but may be copied and moved. +func TestACredentialFileIsRefusedToReadButMayBeCopied(t *testing.T) { + g := testFiles(t) + write(t, g.path("home/.ssh/id_ed25519"), "key\n") + if _, err := g.Read(g.path("home/.ssh/id_ed25519"), ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("read: %v", err) + } + if g.root.called("/usr/bin/cat") { + t.Error("it went for the content with root") + } + if _, err := g.Copy(by, g.path("home/.ssh/id_ed25519"), g.path("backup_key")); err != nil { + t.Errorf("copy of a secret place: %v", err) + } + if _, err := g.Move(by, g.path("backup_key"), g.path("backup_key2")); err != nil { + t.Errorf("move of a secret place: %v", err) + } +} + +// ADR 0394 §1: a target on another node is refused until step 2. +func TestATargetOnAnotherNodeIsRefused(t *testing.T) { + g := testFiles(t) + write(t, g.path("a"), "x") + for _, to := range []string{"ace:/tmp/a", "novox:/etc/hosts"} { + for _, run := range []func() error{ + func() error { _, err := g.Copy(by, g.path("a"), to); return err }, + func() error { _, err := g.Move(by, g.path("a"), to); return err }, + func() error { _, err := g.Copy(by, to, g.path("b")); return err }, + } { + if err := run(); err == nil || !strings.Contains(err.Error(), "issue 903") { + t.Errorf("%s: %v", to, err) + } + } + } + if _, err := os.Stat(g.path("a")); err != nil { + t.Error("the source was touched") + } +} + +// ADR 0394 §2: a permission error repeats the act through sudo -n with a fixed binary and argument list. +func TestAPermissionErrorFallsBackToRoot(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("run as root, nothing is refused") + } + g := testFiles(t) + if err := os.MkdirAll(g.path("locked"), 0o500); err != nil { + t.Fatal(err) + } + defer os.Chmod(g.path("locked"), 0o700) + if _, err := g.Write(by, g.path("locked/f"), "x\n", ""); err != nil { + t.Fatal(err) + } + if !g.root.called("/usr/bin/cp -- ") || !g.root.called("/usr/bin/cp -- "+filepath.Join(g.State, "tmp")) { + t.Errorf("write did not go through root: %v", g.root.calls) + } + g.root.calls = nil + os.Chmod(g.path("locked"), 0o500) + if _, err := g.Mkdir(by, g.path("locked/sub"), true); err != nil { + t.Fatal(err) + } + if !g.root.called("/usr/bin/mkdir -p -- " + g.path("locked/sub")) { + t.Errorf("mkdir: %v", g.root.calls) + } + // a root that fails is said, not swallowed + os.Chmod(g.path("locked"), 0o500) + g.root.err = errors.New("sudo: a password is required") + if _, err := g.Mkdir(by, g.path("locked/sub2"), false); err == nil || !strings.Contains(err.Error(), "password") { + t.Errorf("a failing root: %v", err) + } + // a read the account may not do is done by cat + write(t, g.path("secretish"), "line one\nline two\n") + os.Chmod(g.path("secretish"), 0) + defer os.Chmod(g.path("secretish"), 0o600) + g.root.cat[g.path("secretish")] = "line one\nline two\n" + g.root.reach(g.path("secretish")) + a, err := g.Read(g.path("secretish"), ReadAsk{}) + if err != nil || a.Text != "line one\nline two\n" { + t.Errorf("read through root: %+v %v", a, err) + } + if !g.root.called("/usr/bin/cat -- " + g.path("secretish")) { + t.Errorf("calls: %v", g.root.calls) + } +} + +// The root runner is a fixed list of binaries, never a shell. +func TestTheRootRunnerRunsOnlyAFixedList(t *testing.T) { + for _, bin := range []string{"/bin/sh", "/usr/bin/bash", "/usr/bin/env", "cat", "/usr/bin/sudo", "/usr/bin/sh", "/usr/bin/grep"} { + if _, err := (sudoRoot{}).Run(bin, "-c", "id"); err == nil || !strings.Contains(err.Error(), "not one of") { + t.Errorf("%s: %v", bin, err) + } + } +} + +// ADR 0394 §6: every write act appends one JSON line, with the caller the runtime proved. +func TestEveryWriteActAppendsOneLogLine(t *testing.T) { + g := testFiles(t) + write(t, g.path("a"), "x") + acts := []struct { + verb string + run func() error + }{ + {"write", func() error { _, err := g.Write(by, g.path("w"), "x", ""); return err }}, + {"mkdir", func() error { _, err := g.Mkdir(by, g.path("m"), false); return err }}, + {"chmod", func() error { _, err := g.Chmod(by, g.path("a"), "0600", false); return err }}, + {"copy", func() error { _, err := g.Copy(by, g.path("a"), g.path("c")); return err }}, + {"move", func() error { _, err := g.Move(by, g.path("c"), g.path("d")); return err }}, + {"delete", func() error { _, err := g.Delete(by, g.path("d"), false); return err }}, + {"chown", func() error { _, err := g.Chown(by, g.path("a"), "ops", "", false); return err }}, + } + for i, a := range acts { + if err := a.run(); err != nil { + t.Fatalf("%s: %v", a.verb, err) + } + lines := actLines(t, g) + if len(lines) != i+1 { + t.Fatalf("after %s: %d lines", a.verb, len(lines)) + } + l := lines[i] + if l["verb"] != a.verb || l["caller"] != "person.jochen" || l["reason"] != "a test" || l["time"] == nil || l["paths"] == nil { + t.Errorf("%s: %v", a.verb, l) + } + } + // a refused act adds none + g.Mkdir(by, g.path("a/b/c"), false) + if len(actLines(t, g)) != len(acts) { + t.Error("a failed act was logged") + } +} + +// ADR 0159 / issue 365: the tool takes the caller from the runtime, never from the arguments. +func TestTheToolRecordsTheCallerTheRuntimeNamed(t *testing.T) { + g := testFiles(t) + var tool stdio.Tool + for _, x := range tools(g.Files) { + if x.Name == Seat+".mkdir" { + tool = x + } + } + args := map[string]any{"path": g.path("m"), "reason": "why", "caller": "person.mallory"} + if _, err := tool.Run(args); err != nil { + t.Fatal(err) + } + if l := actLines(t, g); len(l) != 1 || l[0]["caller"] != stdio.Unproven { + t.Errorf("log: %v", l) + } +} + +func TestMkdirChmodAndChown(t *testing.T) { + g := testFiles(t) + if _, err := g.Mkdir(by, g.path("a/b"), false); err == nil { + t.Error("a missing parent was made without parents") + } + g.owners[g.dir] = Ident{4242, 4343} + if _, err := g.Mkdir(by, g.path("a/b"), true); err != nil { + t.Fatal(err) + } + if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("a")) { + t.Errorf("a made parent takes the owner of its folder: %v", g.root.calls) + } + write(t, g.path("a/b/f"), "x") + os.Chmod(g.path("a/b/f"), 0o600) + if _, err := g.Chmod(by, g.path("a"), "u+x,g+r", true); err != nil { + t.Fatal(err) + } + if modeOf(t, g.path("a/b/f")) != 0o740 { + t.Errorf("recursive symbolic chmod: %o", modeOf(t, g.path("a/b/f"))) + } + if _, err := g.Chmod(by, g.path("a/b/f"), "0644", false); err != nil || modeOf(t, g.path("a/b/f")) != 0o644 { + t.Errorf("octal: %v %o", err, modeOf(t, g.path("a/b/f"))) + } + if _, err := g.Chmod(by, g.path("a/b/f"), "banana", false); err == nil { + t.Error("a bad mode") + } + g.root.calls = nil + if _, err := g.Chown(by, g.path("a/b/f"), "ops", "wheel", false); err != nil { + t.Fatal(err) + } + if !g.root.called("/usr/bin/chown 1000:998 -- " + g.path("a/b/f")) { + t.Errorf("chown by name: %v", g.root.calls) + } + if _, err := g.Chown(by, g.path("a/b/f"), "nobody-here", "", false); err == nil { + t.Error("an unknown account") + } +} + +// ADR 0394 §5: without the word the module owns nothing and starts; a bad word is refused. +func TestWithoutOwnedPathsItOwnsNothing(t *testing.T) { + if o, err := ownedFrom(""); err != nil || len(o) != 0 { + t.Errorf("none: %v %v", o, err) + } + if o, err := ownedFrom(`{"/etc/hosts":"mesh-hosts (node-hostname)"}`); err != nil || o["/etc/hosts"] == "" { + t.Errorf("a word: %v %v", o, err) + } + if _, err := ownedFrom(`["/etc/hosts"]`); err == nil || !strings.Contains(err.Error(), OwnedWord) { + t.Errorf("a list: %v", err) + } +} + +func TestItServesTheWriteVerbsToo(t *testing.T) { + g := testFiles(t) + got := map[string]bool{} + for _, tool := range tools(g.Files) { + got[tool.Name] = true + } + for _, v := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move", + "delete", "mkdir", "chmod", "chown", "trash", "restore"} { + if !got[Seat+"."+v] { + t.Errorf("no %s", v) + } + } +} + +// ADR 0394 §3, §4: a copy or a move onto an existing target overwrites it: the old one goes to the trash, and +// the new one keeps the old one's owner and mode. +func TestACopyOrMoveOntoAnExistingTargetOverwritesIt(t *testing.T) { + g := testFiles(t) + write(t, g.path("new"), "new\n") + write(t, g.path("old"), "old\n") + os.Chmod(g.path("old"), 0o640) + g.owners[g.path("old")] = Ident{4242, 4343} + a, err := g.Copy(by, g.path("new"), g.path("old")) + if err != nil { + t.Fatal(err) + } + if mustRead(t, g.path("old")) != "new\n" || modeOf(t, g.path("old")) != 0o640 { + t.Errorf("copy: %q %o", mustRead(t, g.path("old")), modeOf(t, g.path("old"))) + } + if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("old")) { + t.Errorf("the old owner was not kept: %v", g.root.calls) + } + if a.Trashed == "" || mustRead(t, filepath.Join(g.State, "trash", a.Trashed, "item")) != "old\n" { + t.Errorf("the old target is not in the trash: %+v", a) + } + write(t, g.path("m"), "moved\n") + os.Chmod(g.path("old"), 0o600) + b, err := g.Move(by, g.path("m"), g.path("old")) + if err != nil || mustRead(t, g.path("old")) != "moved\n" || b.Trashed == "" { + t.Fatalf("move: %v %+v", err, b) + } + if _, err := os.Stat(g.path("m")); err == nil { + t.Error("the source is still there") + } + if modeOf(t, g.path("old")) != 0o600 { + t.Errorf("the overwritten file's mode is kept: %o", modeOf(t, g.path("old"))) + } + // a target inside the source, or a source inside the target, is not an overwrite + write(t, g.path("d/f"), "x") + if _, err := g.Copy(by, g.path("d"), g.path("d/in")); err == nil { + t.Error("a copy into itself") + } + if _, err := g.Move(by, g.path("d/f"), g.path("d")); err == nil { + t.Error("a move over its own folder") + } + if mustRead(t, g.path("d/f")) != "x" { + t.Error("the file was lost") + } +} + +// ADR 0394 §7: only read and search refuse a place a secret is kept; the writes do not. +func TestTheWritesDoNotRefuseASecretPlace(t *testing.T) { + g := testFiles(t) + k := g.path("home/.ssh/id_ed25519") + write(t, k, "key\n") + for name, run := range map[string]func() error{ + "write": func() error { _, err := g.Write(by, k, "new\n", ""); return err }, + "chmod": func() error { _, err := g.Chmod(by, k, "0600", false); return err }, + "mkdir": func() error { _, err := g.Mkdir(by, g.path("home/.ssh/sub"), false); return err }, + "delete": func() error { _, err := g.Delete(by, k, false); return err }, + } { + if err := run(); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} + +// ADR 0394 §2: where the account's own rights refuse a read, stat, readlink, list and search repeat it through +// root with a fixed binary; a secret place is refused by name first, and root is not asked. +func TestTheReadsFallBackToRoot(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("run as root, nothing is refused") + } + g := testFiles(t) + locked := g.path("locked") + write(t, locked+"/inner/f", "x\n") + os.Chmod(locked, 0) + defer os.Chmod(locked, 0o700) + in := locked + "/inner" + g.root.out["/usr/bin/stat -c %F|%u|%g|%a|%A|%s -- "+in] = "directory|1000|1000|700|drwx------|4096\n" + g.root.out["/usr/bin/stat -c %F|%u|%g|%a|%A|%s -- "+in+"/f"] = "regular file|1000|998|4640|-rwSr-----|2\n" + st, err := g.Stat([]string{in + "/f"}, false) + if err != nil { + t.Fatal(err) + } + cs := st.Paths[0].Components + last := cs[len(cs)-1] + if st.Paths[0].Stopped != "" || last.Type != "file" || last.Mode != "4640" || last.Owner != "ops" || + last.Group != "wheel" || last.Size == nil || *last.Size != 2 || cs[len(cs)-2].Type != "directory" { + t.Errorf("stat through root: %+v", st.Paths[0]) + } + // readlink + g.root.out["/usr/bin/readlink -- "+in+"/l"] = "f\n" + g.root.out["/usr/bin/readlink -e -- "+in+"/l"] = in + "/f\n" + rl, err := g.Readlink(in + "/l") + if err != nil || !rl.IsLink || rl.Link != "f" || rl.LeadsTo != in+"/f" || !rl.Exists { + t.Errorf("readlink through root: %+v %v", rl, err) + } + // list + g.root.out["/usr/bin/find -H "+in+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\x00"] = + "f\t1000\t998\t640\t2\t\tf\x00d\t1000\t1000\t755\t4096\t\tsub dir\x00l\t1000\t1000\t777\t1\tf\tl\x00" + ls, err := g.List(in) + if err != nil || ls.Count != 3 { + t.Fatalf("list through root: %+v %v", ls, err) + } + byName := map[string]Entry{} + for _, e := range ls.Entries { + byName[e.Name] = e + } + if e := byName["f"]; e.Type != "file" || e.Mode != "0640" || e.Group != "wheel" || e.Size == nil || *e.Size != 2 { + t.Errorf("entry f: %+v", e) + } + if byName["sub dir"].Type != "directory" || byName["l"].Type != "link" || byName["l"].Link != "f" { + t.Errorf("entries: %+v", ls.Entries) + } + // search: the files are listed by find, the secret ones skipped by name, the rest read through cat + g.root.out["/usr/bin/find -H "+locked+" -maxdepth 5 -xdev -type f -printf %p\x00"] = + in + "/f\x00" + locked + "/.ssh/id_rsa\x00" + g.root.cat[in+"/f"] = "one\nneedle here\n" + g.root.reach(in + "/f") + sr, err := g.Search(SearchAsk{Text: "needle", Path: locked}) + if err != nil || len(sr.Matches) != 1 || sr.Matches[0].File != in+"/f" || sr.Matches[0].Line != 2 { + t.Fatalf("search through root: %+v %v", sr, err) + } + if len(sr.Skipped) != 1 || !strings.HasSuffix(sr.Skipped[0], "id_rsa") { + t.Errorf("a credential file was not skipped by name: %+v", sr.Skipped) + } + if g.root.called("/usr/bin/cat -- " + locked + "/.ssh/id_rsa") { + t.Error("root read a credential file") + } + // a secret place is refused by name, and root is never asked + g.root.calls = nil + if _, err := g.List(g.path("home/.ssh")); err == nil { + t.Error("a secret place was listed") + } + if _, err := g.Read(locked+"/.ssh/id_rsa", ReadAsk{}); err == nil || len(g.root.calls) != 0 { + t.Errorf("a credential file was read: %v %v", err, g.root.calls) + } +} + +// Review 1, finding 3: before root reads a file, root says where it leads and what it is. A link into a refused +// place, and a file that is not a regular file, are refused before any cat. +func TestRootNeverCatsAThroughALinkIntoASecretPlaceOrANonFile(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("run as root, nothing is refused") + } + g := testFiles(t) + locked := g.path("locked") + write(t, locked+"/x", "x\n") + os.Chmod(locked, 0) + defer os.Chmod(locked, 0o700) + g.root.out["/usr/bin/readlink -e -- "+locked+"/link"] = "/etc/ssl/private/k\n" + g.root.cat["/etc/ssl/private/k"] = "KEY\n" + g.root.cat[locked+"/link"] = "KEY\n" + if _, err := g.Read(locked+"/link", ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("a link into a secret place: %v", err) + } + g.root.out["/usr/bin/readlink -e -- "+locked+"/fifo"] = locked + "/fifo\n" + g.root.out["/usr/bin/stat -c "+statFormat+" -- "+locked+"/fifo"] = "fifo|1000|1000|600|prw-------|0\n" + g.root.cat[locked+"/fifo"] = "never\n" + if _, err := g.Read(locked+"/fifo", ReadAsk{}); err == nil || !strings.Contains(err.Error(), "not a file") { + t.Errorf("a fifo: %v", err) + } + // a search meets the same two + g.root.out["/usr/bin/find -H "+locked+" -maxdepth 5 -xdev -type f -printf %p\x00"] = locked + "/link\x00" + locked + "/fifo\x00" + sr, err := g.Search(SearchAsk{Text: "KEY", Path: locked}) + if err != nil || len(sr.Matches) != 0 { + t.Errorf("search: %+v %v", sr, err) + } + for _, c := range g.root.calls { + if strings.HasPrefix(c, "/usr/bin/cat") { + t.Errorf("root read: %s", c) + } + } +} + +// Review 1, finding 4: a directory target that holds an owned path is replaced only with an override. +func TestOverwritingAFolderThatHoldsAnOwnedPathIsRefused(t *testing.T) { + g := testFiles(t) + g.Owned[g.path("dst/etc")] = "mesh-hosts (node-hostname)" + write(t, g.path("dst/etc/hosts"), "old\n") + write(t, g.path("src/a"), "A\n") + if _, err := g.Copy(by, g.path("src"), g.path("dst")); err == nil || !strings.Contains(err.Error(), "mesh-hosts") { + t.Errorf("copy: %v", err) + } + write(t, g.path("src2/a"), "A\n") + if _, err := g.Move(by, g.path("src2"), g.path("dst")); err == nil || !strings.Contains(err.Error(), "mesh-hosts") { + t.Errorf("move: %v", err) + } + if mustRead(t, g.path("dst/etc/hosts")) != "old\n" { + t.Error("the owned file was replaced") + } +} + +// Review 1, finding 5: a copy keeps setuid and setgid: the owner is set before the mode, as a chown clears them. +func TestACopyKeepsSetuid(t *testing.T) { + g := testFiles(t) + g.Lchown = func(p string, uid, gid int) error { return os.Lchown(p, os.Getuid(), os.Getgid()) } + write(t, g.path("tool"), "x\n") + if err := os.Chmod(g.path("tool"), 0o755|fs.ModeSetuid|fs.ModeSetgid); err != nil { + t.Fatal(err) + } + g.owners[g.path("tool")] = Ident{4242, 4343} + if _, err := g.Copy(by, g.path("tool"), g.path("tool2")); err != nil { + t.Fatal(err) + } + info, _ := os.Lstat(g.path("tool2")) + if info.Mode()&fs.ModeSetuid == 0 || info.Mode()&fs.ModeSetgid == 0 { + t.Errorf("the copy lost setuid or setgid: %v", info.Mode()) + } +} + +// Review 1, finding 1: every seat verb the code asks is declared in invokes. +func TestEverySeatVerbTheCodeAsksIsDeclared(t *testing.T) { + raw, _ := os.ReadFile("../../module.json") + var m struct{ Invokes []string } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + declared := map[string]bool{} + for _, i := range m.Invokes { + declared[i] = true + } + files, _ := filepath.Glob("*.go") + asked := map[string]bool{} + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src := mustRead(t, f) + for _, hit := range regexp.MustCompile(`"(seat:[a-z-]+\.[a-z_-]+)"`).FindAllStringSubmatch(src, -1) { + asked[hit[1]] = true + } + } + if len(asked) == 0 { + t.Fatal("found no seat verb asked") + } + for a := range asked { + if !declared[a] { + t.Errorf("%s is asked but not in invokes %v", a, m.Invokes) + } + } + for d := range declared { + if !asked[d] { + t.Errorf("%s is declared but never asked", d) + } + } +} diff --git a/modules/mesh-files/cmd/mesh-files/main.go b/modules/mesh-files/cmd/mesh-files/main.go new file mode 100644 index 00000000..8dc5ad20 --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/main.go @@ -0,0 +1,328 @@ +// mesh-files (novox/hq ADR 0394, to-be 41 WP9): the holder of node-files. A node's files and accounts, read and +// managed through the mesh — the owner and mode of each component of a path, a link, a directory, a text file, +// a search by fixed string, an account or a group; and the writes: write, copy, move, delete, mkdir, chmod, +// chown, and the trash that delete and overwrite use. +// +// It runs as the tool runner's account, the node's operator account, and acts with that account's own rights +// first; where that fails with a permission error it repeats the act through `sudo -n` with a fixed binary +// (sudo.go), never a shell. It refuses by name the places a secret is kept (refuse.go), withholds any piece of +// an answer shaped like a secret (mesh-sdk's outward), and refuses a change of a path a module owns unless a +// reason is given (files.go). Every write act is one line in the act log (files.go). +// +// stdout is the MCP channel; what this module says, it says on stderr. +package main + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat is the role this module holds. +const Seat = "node-files" + +// PlacesWord is where the controller names this node's secret places (novox/hq ADR 0394 §5). +const PlacesWord = "MESH_SECRET_PLACES" + +func main() { + places, err := placesFrom(os.Getenv(PlacesWord)) + if err != nil { + fmt.Fprintf(os.Stderr, "[mesh-files] %v\n", err) + os.Exit(1) + } + f, err := newFiles(places) + if err != nil { + fmt.Fprintf(os.Stderr, "[mesh-files] %v\n", err) + os.Exit(1) + } + if err := stdio.Serve("", tools(f)); err != nil { + fmt.Fprintf(os.Stderr, "[mesh-files] %v\n", err) + os.Exit(1) + } +} + +// StateWord is where the module's state directory (the trash and the act log) is named. +const StateWord = "MESH_FILES_STATE" + +func newFiles(places []string) (*Files, error) { + state := os.Getenv(StateWord) + if strings.TrimSpace(state) == "" { + return nil, fmt.Errorf("%s is not given: the trash and the act log have no place", StateWord) + } + owned, err := ownedFrom(os.Getenv(OwnedWord)) + if err != nil { + return nil, err + } + if len(owned) == 0 { + fmt.Fprintf(os.Stderr, "[mesh-files] %s is not given, so this node's owned paths are unknown and nothing is "+ + "refused as owned (novox/hq ADR 0394 §5): a controller that knows node-files composes it\n", OwnedWord) + } + r := newReader(places) + r.Root = sudoRoot{} + node := os.Getenv("MESH_NODE") + return &Files{Reader: r, State: state, Owned: owned, Node: node, Tell: tellOperator, + OwnerOf: ownerOnDisk, Lchown: os.Lchown, NewID: newID}, nil +} + +// tellOperator tells the operator a plain notice, through the operator channel's tell verb: no condition is +// opened, and nothing is kept open. +func tellOperator(headline string, lines []string, subject string) error { + _, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"headline": headline, "lines": lines, "subject": subject}) + return err +} + +// placesFrom reads the controller's list. Absent is refused: without it, a module's own-secret directory +// would be read wherever the account's modes allow, and a refusal the decision promises would be silent. +func placesFrom(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf("%s is not given, so this node's secret places are unknown and nothing would be "+ + "refused by them (novox/hq ADR 0394 §5): a controller that knows node-files composes it", PlacesWord) + } + var places []string + if err := json.Unmarshal([]byte(raw), &places); err != nil { + return nil, fmt.Errorf("%s is not a list of paths: %v", PlacesWord, err) + } + return places, nil +} + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func arg(a map[string]any, k string) string { + switch v := a[k].(type) { + case string: + return strings.TrimSpace(v) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + return strconv.FormatBool(v) + } + return "" +} + +func number(a map[string]any, k string) (int, error) { + s := arg(a, k) + if s == "" { + return 0, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("%s is a whole number, and %q was given", k, s) + } + return n, nil +} + +// pathsArg is the paths given, one per line — or, from a caller that sends a list, the list. +func pathsArg(a map[string]any) []string { + var raw []string + switch v := a["paths"].(type) { + case string: + raw = strings.Split(v, "\n") + case []any: + for _, p := range v { + if s, ok := p.(string); ok { + raw = append(raw, s) + } + } + } + var out []string + for _, p := range raw { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// bounded is the last bound (novox/hq ADR 0394): whatever a verb made, no answer is larger than MaxAnswer. +func bounded(answer any, err error) (any, error) { + if err != nil { + return nil, err + } + body, err := json.Marshal(answer) + if err != nil { + return nil, err + } + if len(body) > MaxAnswer { + return nil, fmt.Errorf("the answer is %d bytes, and an answer of node-files is at most %d: ask for less", len(body), MaxAnswer) + } + return answer, nil +} + +// verb is one of the seat's verbs: listed as `.`, so the runtime serves it on the seat's subject. +// The descriptions are the controller's (internal/catalogue/reads.go), said again here for a caller of the +// module's own address. +func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, + Run: func(a map[string]any) (any, error) { return bounded(run(a)) }} +} + +func object(props map[string]any, required ...string) map[string]any { + if required == nil { + required = []string{} + } + return map[string]any{"type": "object", "properties": props, "required": required} +} + +// The descriptions of the reads are the controller's (internal/catalogue/files.go): the reads' own, with +// where they are read as restated for the operator's account. +const asAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " + + "secret is kept, or a known credential file, is refused by name" + +func boolArg(a map[string]any, k string) bool { return arg(a, k) == "true" } + +func actOf(a map[string]any) Act { + return Act{Caller: stdio.CallerOf(a), Reason: arg(a, "reason"), Override: arg(a, "override")} +} + +// write is one of the write verbs: it takes reason and override beside its own inputs. +func writeVerb(name, description string, props map[string]any, required []string, + run func(act Act, a map[string]any) (any, error)) stdio.Tool { + props["reason"] = str("why the file is changed, for the audit trail") + props["override"] = str("a reason, to change a path a module owns anyway (optional)") + return verb(name, description+" Refuses a path a module owns unless override names a reason; done as the node's "+ + "operator account, through sudo -n where its rights do not reach.", object(props, append(required, "reason")...), + func(a map[string]any) (any, error) { return run(actOf(a), a) }) +} + +func tools(f *Files) []stdio.Tool { + r := f.Reader + return append(readTools(r), + writeVerb("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.", + map[string]any{"path": str("an absolute path"), "content": str("the text to write"), + "mode": str("the file's mode, such as 0644 (optional)")}, []string{"path", "content"}, + func(act Act, a map[string]any) (any, error) { + content, _ := a["content"].(string) // as given: the text is not trimmed + return f.Write(act, arg(a, "path"), content, arg(a, "mode")) + }), + writeVerb("copy", "Copy a file or directory to a path on this node. A target on another node (:/path) is "+ + "refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+ + "kept in the node's trash for 7 days.", + map[string]any{"from": str("an absolute path"), "to": str("an absolute path on this node")}, + []string{"from", "to"}, + func(act Act, a map[string]any) (any, error) { return f.Copy(act, arg(a, "from"), arg(a, "to")) }), + writeVerb("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+ + "comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.", + map[string]any{"from": str("an absolute path"), "to": str("an absolute path")}, []string{"from", "to"}, + func(act Act, a map[string]any) (any, error) { return f.Move(act, arg(a, "from"), arg(a, "to")) }), + writeVerb("delete", "Delete a file or directory into the node's trash, where restore can bring it back.", + map[string]any{"path": str("an absolute path"), + "recursive": str("\"true\": delete a directory with what it holds")}, []string{"path"}, + func(act Act, a map[string]any) (any, error) { + return f.Delete(act, arg(a, "path"), boolArg(a, "recursive")) + }), + writeVerb("mkdir", "Make a directory.", + map[string]any{"path": str("an absolute path"), + "parents": str("\"true\": make the missing parents too")}, []string{"path"}, + func(act Act, a map[string]any) (any, error) { + return f.Mkdir(act, arg(a, "path"), boolArg(a, "parents")) + }), + writeVerb("chmod", "Change the mode of a file or directory.", + map[string]any{"path": str("an absolute path"), "mode": str("the mode, such as 0644 or u+x"), + "recursive": str("\"true\": also everything under a directory")}, []string{"path", "mode"}, + func(act Act, a map[string]any) (any, error) { + return f.Chmod(act, arg(a, "path"), arg(a, "mode"), boolArg(a, "recursive")) + }), + writeVerb("chown", "Change the owner and group of a file or directory.", + map[string]any{"path": str("an absolute path"), "owner": str("an account's name or number"), + "group": str("a group's name or number (optional)"), + "recursive": str("\"true\": also everything under a directory")}, []string{"path", "owner"}, + func(act Act, a map[string]any) (any, error) { + return f.Chown(act, arg(a, "path"), arg(a, "owner"), arg(a, "group"), boolArg(a, "recursive")) + }), + verb("trash", "What the node's trash holds: each deleted or replaced item with its id, original path and when "+ + "it went.", object(map[string]any{}), + func(map[string]any) (any, error) { return f.Trash() }), + verb("restore", "Put an item of the node's trash back where it was; refuses when something is there now.", + object(map[string]any{"id": str("an id from trash"), + "reason": str("why it is put back, for the audit trail"), + "override": str("a reason, to change a path a module owns anyway (optional)")}, "id", "reason"), + func(a map[string]any) (any, error) { return f.Restore(actOf(a), arg(a, "id")) }), + ) +} + +// readTools are the reads, each saying how it reads. +func readTools(r Reader) []stdio.Tool { + ts := rawReadTools(r) + for i := range ts { + if !strings.Contains(ts[i].Description, asAccount) { + ts[i].Description += " Read " + asAccount + "." + } + } + return ts +} + +func rawReadTools(r Reader) []stdio.Tool { + return []stdio.Tool{ + verb("stat", "Each component of one path or several, from / down: its type, owner and group (name and "+ + "number), mode, whether it is a link and to what, and whether it is a mount point and of which file "+ + "system. A link is not followed unless follow is \"true\". At most 200 paths in one call. Read "+asAccount+".", + object(map[string]any{"paths": str("one absolute path, or several separated by line breaks"), + "follow": str("\"true\": also say where the path leads once every link is followed")}, "paths"), + func(a map[string]any) (any, error) { return r.Stat(pathsArg(a), arg(a, "follow") == "true") }), + verb("readlink", "What one link points to, as written, and where the path leads once every link is "+ + "followed, and whether that exists.", + object(map[string]any{"path": str("an absolute path")}, "path"), + func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }), + verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+ + "target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+ + "refused by name.", + object(map[string]any{"path": str("an absolute path to a directory")}, "path"), + func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }), + verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+ + "is asked), cut at a line and said so. A negative from counts from the end, as tail does. A binary file "+ + "is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped "+ + "like a secret is withheld and its number said. /proc and /sys are read as far as the account may. Read "+asAccount+".", + object(map[string]any{"path": str("an absolute path to a file"), + "from": str("the first line to answer, counted from 1; negative: that many lines from the end (optional)"), + "lines": str("how many lines to answer (optional)"), + "bytes": str("the most bytes to answer, up to 262144 (optional, 65536 unless given)")}, "path"), + func(a map[string]any) (any, error) { + var ask ReadAsk + var err error + if ask.From, err = number(a, "from"); err != nil { + return nil, err + } + if ask.Lines, err = number(a, "lines"); err != nil { + return nil, err + } + if ask.Bytes, err = number(a, "bytes"); err != nil { + return nil, err + } + return r.Read(arg(a, "path"), ask) + }), + verb("search", "Find a fixed string (never a pattern) in one file or under one directory: every matching "+ + "line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories "+ + "deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds, on the file system of the path "+ + "named; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+ + "a secret is withheld. The answer says which bound it met.", + object(map[string]any{"text": str("the fixed string to find"), + "path": str("an absolute path to a file or a directory"), + "depth": str("how many directories deep, up to 8 (optional)"), + "matches": str("the most matches to answer, up to 500 (optional)"), + "case": str("\"false\": match whatever the case (optional)")}, "text", "path"), + func(a map[string]any) (any, error) { + text, _ := a["text"].(string) // as given: a space at either end is part of what is found + ask := SearchAsk{Text: text, Path: arg(a, "path"), CaseInsensitive: arg(a, "case") == "false"} + var err error + if ask.Depth, err = number(a, "depth"); err != nil { + return nil, err + } + if ask.Matches, err = number(a, "matches"); err != nil { + return nil, err + } + return r.Search(ask) + }), + verb("account", "One account or group by name or number, as this node's account files say: an account's "+ + "number, group, home, shell and every group it is in; a group's number and members. With neither, every "+ + "account and group. Never a password field.", + object(map[string]any{"name": str("an account's name or number (optional)"), + "group": str("a group's name or number (optional)")}), + func(a map[string]any) (any, error) { return r.Account(arg(a, "name"), arg(a, "group")) }), + } +} diff --git a/modules/mesh-files/cmd/mesh-files/manifest_test.go b/modules/mesh-files/cmd/mesh-files/manifest_test.go new file mode 100644 index 00000000..5d7def24 --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/manifest_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +// ADR 0394 §2: the holder runs as the tool runner's account, the node's operator account: it declares no +// runs-as and makes no account, claims node-files once on each node and serves every verb of the seat, keeps +// a state directory for the trash and the act log, and may tell the operator's channel of an override. +func TestTheHolderRunsAsTheToolRunnersAccount(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + RunsAs string `json:"runs-as"` + Invokes []string `json:"invokes"` + Claims []struct { + Name, Scope string + Serves []string + } `json:"claims"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.RunsAs != "" { + t.Errorf("runs as %q", m.RunsAs) + } + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || m.Claims[0].Scope != "node" { + t.Fatalf("claims %v", m.Claims) + } + served := map[string]bool{} + for _, v := range m.Claims[0].Serves { + served[v] = true + } + for _, tool := range tools(testFiles(t).Files) { + name := tool.Name[len(Seat)+1:] + if !served[name] { + t.Errorf("the manifest does not serve %s", name) + } + delete(served, name) + } + if len(served) > 0 { + t.Errorf("the manifest serves what the module does not: %v", served) + } + state := false + for _, r := range m.Resources { + if r["type"] == "user" { + t.Errorf("the module makes an account: %v", r) + } + if r["id"] == "state" && r["type"] == "directory" { + state = true + } + } + if !state { + t.Error("no state directory for the trash and the act log") + } + if len(m.Invokes) != 1 || m.Invokes[0] != "seat:operator-channel.tell" { + t.Errorf("invokes %v", m.Invokes) + } +} diff --git a/modules/mesh-files/cmd/mesh-files/reads.go b/modules/mesh-files/cmd/mesh-files/reads.go new file mode 100644 index 00000000..db438d4d --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/reads.go @@ -0,0 +1,1271 @@ +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() +} diff --git a/modules/mesh-files/cmd/mesh-files/reads_test.go b/modules/mesh-files/cmd/mesh-files/reads_test.go new file mode 100644 index 00000000..60d8c8fa --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/reads_test.go @@ -0,0 +1,534 @@ +package main + +import ( + crand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "testing" + "time" +) + +// A token shaped as a secret, written into files the tests read. +const aToken = "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + +func testReader(t *testing.T, places ...string) (Reader, string) { + t.Helper() + dir := t.TempDir() + passwd := filepath.Join(dir, "passwd") + group := filepath.Join(dir, "group") + mountinfo := filepath.Join(dir, "mountinfo") + write(t, passwd, "root:x:0:0::/root:/usr/bin/bash\nops:x:1000:1000:The Operator:/home/ops:/usr/bin/zsh\n"+ + "mesh-files:x:961:961::/var/lib/mesh-files:/usr/bin/nologin\n") + write(t, group, "root:x:0:\nwheel:x:998:ops\nops:x:1000:\nmesh-files:x:961:\ndocker:x:970:ops,builder\n") + data := filepath.Join(dir, "data") + write(t, mountinfo, "22 1 0:21 / / rw,relatime - btrfs /dev/nvme0n1p2 rw\n"+ + "40 22 0:40 / "+strings.ReplaceAll(data, " ", `\040`)+" rw - nfs4 home-server:/tank/data rw\n") + r := Reader{Refuser: Refuser{Places: places}, Passwd: passwd, Group: group, MountInfo: mountinfo, + Now: time.Now} + return r, dir +} + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// ADR 0394 §5: without the controller's list of secret places it does not start: a refusal it promises would be +// silent. +func TestItDoesNotStartWithoutTheNodesSecretPlaces(t *testing.T) { + if _, err := placesFrom(""); err == nil || !strings.Contains(err.Error(), PlacesWord) { + t.Errorf("no places: %v", err) + } + if p, err := placesFrom(`["/var/lib/mesh-vault"]`); err != nil || len(p) != 1 { + t.Errorf("a list: %v %v", p, err) + } + if p, err := placesFrom(`[]`); err != nil || len(p) != 0 { + t.Errorf("an empty list is a node with no secret place: %v %v", p, err) + } +} + +// ADR 0394 §5: a path under the vault's directory, a module's own-secret directory and a known credential file +// are each refused by name before any read — though the account's modes would allow it — and a link to one is +// refused as the place is. +func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) { + r0, dir := testReader(t) + vault := filepath.Join(dir, "var/lib/mesh-vault") + own := filepath.Join(dir, "var/lib/desk-channel") + r, _ := r0, dir + r.Places = []string{vault, own} + for _, p := range []string{ + filepath.Join(vault, "ledger/entries.json"), + filepath.Join(own, "broker"), + filepath.Join(own, "settings.json"), + filepath.Join(dir, "home/ops/.claude/.credentials.json"), + filepath.Join(dir, "home/ops/.ssh/config"), + filepath.Join(dir, "home/ops/.pgpass"), + filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key"), + filepath.Join(dir, "etc/shadow"), + filepath.Join(dir, "srv/tls/server.key"), + } { + write(t, p, "harmless words\n") + if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("%s was read: %v", p, err) + } + } + if _, err := r.List(vault); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("the vault's directory was listed: %v", err) + } + link := filepath.Join(dir, "tmp/innocent") + write(t, filepath.Join(dir, "tmp/x"), "") + if err := os.Symlink(filepath.Join(own, "broker"), link); err != nil { + t.Fatal(err) + } + if _, err := r.Read(link, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "leads to") { + t.Errorf("a link to a secret place was read: %v", err) + } + // A search under a directory holding a secret place skips it and names it. + write(t, filepath.Join(dir, "home/ops/notes.txt"), "harmless words\n") + a, err := r.Search(SearchAsk{Text: "harmless", Path: filepath.Join(dir, "home"), Depth: 8}) + if err != nil { + t.Fatal(err) + } + if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "notes.txt") || len(a.Skipped) != 3 { + t.Errorf("search: matches %v, skipped %v", a.Matches, a.Skipped) + } + // What a public key or a certificate is, is not refused. + for _, p := range []string{filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key.pub"), filepath.Join(dir, "etc/ssl/certs/ca.pem")} { + write(t, p, "harmless words\n") + if _, err := r.Read(p, ReadAsk{}); err != nil { + t.Errorf("%s was refused: %v", p, err) + } + } +} + +// ADR 0394 §3: what the account's file modes refuse is refused — the first bound, below the names. +func TestAFileTheAccountMayNotReadIsRefusedByItsMode(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("run as root, a file's mode refuses nothing") + } + r, dir := testReader(t) + p := filepath.Join(dir, "operators-only") + write(t, p, "the operator's words\n") + if err := os.Chmod(p, 0o000); err != nil { + t.Fatal(err) + } + if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "may not reach it") { + t.Errorf("a file of mode 0000: %v", err) + } + closed := filepath.Join(dir, "closed") + write(t, filepath.Join(closed, "inside"), "x") + if err := os.Chmod(closed, 0o700&^0o100); err != nil { + t.Fatal(err) + } + defer os.Chmod(closed, 0o755) + a, err := r.Stat([]string{filepath.Join(closed, "inside")}, false) + if err != nil { + t.Fatal(err) + } + if got := a.Paths[0]; !strings.Contains(got.Stopped, "may not reach") { + t.Errorf("a path through a directory it may not search: %+v", got) + } +} + +// ADR 0394 §2 and the bounds: a read over the size bound is cut at a line and says so; more than the most is +// refused; lines and a tail are answered as asked. +func TestAReadIsBounded(t *testing.T) { + r, dir := testReader(t) + p := filepath.Join(dir, "big") + var b strings.Builder + for i := 1; i <= 20000; i++ { + fmt.Fprintf(&b, "line %d of the file\n", i) + } + write(t, p, b.String()) + a, err := r.Read(p, ReadAsk{}) + if err != nil { + t.Fatal(err) + } + if len(a.Text) > DefaultReadSize || !strings.Contains(a.Cut, fmt.Sprint(DefaultReadSize)) || a.From != 1 { + t.Errorf("a default read: %d bytes, cut %q", len(a.Text), a.Cut) + } + if _, err := r.Read(p, ReadAsk{Bytes: MaxReadSize + 1}); err == nil { + t.Error("more than the most a read answers was answered") + } + a, err = r.Read(p, ReadAsk{From: 10, Lines: 2}) + if err != nil || a.Text != "line 10 of the file\nline 11 of the file\n" || a.From != 10 || a.Cut != "" { + t.Errorf("lines 10 and 11: %+v %v", a, err) + } + a, err = r.Read(p, ReadAsk{From: -2}) + if err != nil || a.Text != "line 19999 of the file\nline 20000 of the file\n" || a.From != 19999 { + t.Errorf("the last two lines: %q from %d, %v", a.Text, a.From, err) + } + if _, err := r.Read(p, ReadAsk{From: 30000}); err == nil || !strings.Contains(err.Error(), "20000 lines") { + t.Errorf("a line past the end: %v", err) + } +} + +// A binary file, a directory and a fifo are refused, and the fifo without waiting for a writer. +func TestOnlyATextFileIsRead(t *testing.T) { + r, dir := testReader(t) + bin := filepath.Join(dir, "bin") + write(t, bin, "ELF\x00\x01\x02") + if _, err := r.Read(bin, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "binary") { + t.Errorf("a binary file: %v", err) + } + if _, err := r.Read(dir, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "directory") { + t.Errorf("a directory: %v", err) + } + fifo := filepath.Join(dir, "fifo") + if err := syscall.Mkfifo(fifo, 0o644); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, err := r.Read(fifo, ReadAsk{}); done <- err }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "fifo") { + t.Errorf("a fifo: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("a read of a fifo waits for a writer") + } +} + +// ADR 0394 §4: a line shaped like a secret is withheld, its number said, and the rest is answered. +func TestALineShapedLikeASecretIsWithheld(t *testing.T) { + r, dir := testReader(t) + p := filepath.Join(dir, "app.conf") + write(t, p, "listen = /run/app.sock\ntoken = "+aToken+"\nuser = app\n") + a, err := r.Read(p, ReadAsk{}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(a.Text, aToken) || !strings.Contains(a.Text, "user = app") || + !strings.Contains(a.Text, "listen = /run/app.sock") || fmt.Sprint(a.Withheld) != "[2]" { + t.Errorf("read: %q withheld %v", a.Text, a.Withheld) + } + m, err := r.Search(SearchAsk{Text: "token", Path: dir}) + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(m) + if strings.Contains(string(body), aToken) || len(m.Matches) != 1 || len(m.Withheld) != 1 { + t.Errorf("search: %s", body) + } +} + +// The bounds of a search: matches, depth, the most asked, and time — each met is said. +func TestASearchIsBounded(t *testing.T) { + r, dir := testReader(t) + for i := 0; i < 30; i++ { + write(t, filepath.Join(dir, fmt.Sprintf("f%02d", i)), "needle\nneedle\n") + } + a, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: 7}) + if err != nil { + t.Fatal(err) + } + if len(a.Matches) != 7 || !strings.HasPrefix(a.Bound, "matches") { + t.Errorf("seven matches asked: %d, bound %q", len(a.Matches), a.Bound) + } + if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: MaxMatches + 1}); err == nil { + t.Error("more matches than the most were asked and answered") + } + if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Depth: MaxDepth + 1}); err == nil { + t.Error("deeper than the most was asked and answered") + } + deep := t.TempDir() + write(t, filepath.Join(deep, "a/b/c/shallow"), "needle\n") + write(t, filepath.Join(deep, "a/b/c/d/e/deep"), "needle\n") + a, err = r.Search(SearchAsk{Text: "needle", Path: deep, Depth: 3}) + if err != nil { + t.Fatal(err) + } + if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "shallow") { + t.Errorf("three deep: %+v", a.Matches) + } + // Case: as asked. + write(t, filepath.Join(deep, "case"), "NEEDLE\n") + if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case")}); len(a.Matches) != 0 { + t.Error("a match whatever the case, unasked") + } + if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case"), CaseInsensitive: true}); len(a.Matches) != 1 { + t.Error("no match whatever the case, asked") + } + // Time: a clock past the bound stops the walk and says so. + start := time.Now() + calls := 0 + r.Now = func() time.Time { calls++; return start.Add(time.Duration(calls) * SearchTime / 3) } + a, err = r.Search(SearchAsk{Text: "needle", Path: dir}) + if err != nil || !strings.HasPrefix(a.Bound, "time") { + t.Errorf("the time bound: %q %v", a.Bound, err) + } + // A fixed string: a pattern's characters are themselves. + write(t, filepath.Join(deep, "pattern"), "a.c\nabc\n") + if a, _ := r.Search(SearchAsk{Text: "a.c", Path: filepath.Join(deep, "pattern")}); len(a.Matches) != 1 || a.Matches[0].Line != 1 { + t.Errorf("a.c as a fixed string: %+v", a.Matches) + } +} + +// The survey issue 418 was asked for: each component of a path with its owner, group, mode, whether it is a +// link and to what, and whether it is a mount point — the link not followed unless asked. +func TestStatSaysEachComponent(t *testing.T) { + r, dir := testReader(t) + data := filepath.Join(dir, "data") + write(t, filepath.Join(data, "real/file"), "x") + if err := os.Chmod(filepath.Join(data, "real"), os.ModeSetgid|0o750); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(data, "link")); err != nil { + t.Fatal(err) + } + a, err := r.Stat([]string{filepath.Join(data, "link"), filepath.Join(data, "real/file")}, false) + if err != nil { + t.Fatal(err) + } + link := a.Paths[0] + last := link.Components[len(link.Components)-1] + if link.Components[0].Path != "/" || last.Type != "link" || last.Link != "real" || link.LeadsTo != "" { + t.Errorf("the link, not followed: %+v", link) + } + var dataC, realC Component + for _, c := range a.Paths[1].Components { + switch c.Path { + case data: + dataC = c + case filepath.Join(data, "real"): + realC = c + } + } + if dataC.Mount == nil || dataC.Mount.FS != "nfs4" { + t.Errorf("the mount point is not said: %+v", dataC) + } + if realC.Mode != "2750" || realC.Type != "directory" || realC.UID == nil || realC.Perms == "" { + t.Errorf("a component's mode: %+v", realC) + } + if uid := *realC.UID; uid == 1000 && realC.Owner != "ops" { + t.Errorf("owner %d named %q", uid, realC.Owner) + } + a, err = r.Stat([]string{filepath.Join(data, "link")}, true) + if err != nil { + t.Fatal(err) + } + if a.Paths[0].LeadsTo != filepath.Join(data, "real") || a.Paths[0].Leads == nil || a.Paths[0].Leads.Type != "directory" { + t.Errorf("followed: %+v", a.Paths[0]) + } + a, err = r.Stat([]string{filepath.Join(dir, "missing/deeper")}, false) + if err != nil || !strings.Contains(a.Paths[0].Stopped, "does not exist") { + t.Errorf("a missing path: %+v %v", a, err) + } + if _, err := r.Stat([]string{"relative/path"}, false); err == nil { + t.Error("a relative path was answered") + } + many := make([]string, MaxPaths+1) + for i := range many { + many[i] = "/" + } + if _, err := r.Stat(many, false); err == nil { + t.Error("more paths than the most were answered") + } +} + +func TestReadlinkSaysTheTargetAndWhereItLeads(t *testing.T) { + r, dir := testReader(t) + write(t, filepath.Join(dir, "target"), "") + if err := os.Symlink("target", filepath.Join(dir, "l1")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("l1", filepath.Join(dir, "l2")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("nowhere", filepath.Join(dir, "dangling")); err != nil { + t.Fatal(err) + } + a, err := r.Readlink(filepath.Join(dir, "l2")) + if err != nil || !a.IsLink || a.Link != "l1" || a.LeadsTo != filepath.Join(dir, "target") || !a.Exists { + t.Errorf("l2: %+v %v", a, err) + } + a, err = r.Readlink(filepath.Join(dir, "dangling")) + if err != nil || !a.IsLink || a.Exists || !strings.Contains(a.Stopped, "does not exist") { + t.Errorf("dangling: %+v %v", a, err) + } +} + +func TestListSaysEveryEntry(t *testing.T) { + r, dir := testReader(t) + write(t, filepath.Join(dir, "d/a"), "abc") + if err := os.Symlink("a", filepath.Join(dir, "d/b")); err != nil { + t.Fatal(err) + } + a, err := r.List(filepath.Join(dir, "d")) + if err != nil { + t.Fatal(err) + } + if a.Count != 2 || a.Entries[0].Name != "a" || *a.Entries[0].Size != 3 || a.Entries[1].Type != "link" || a.Entries[1].Link != "a" { + t.Errorf("list: %+v", a) + } + for i := 0; i < MaxEntries+5; i++ { + write(t, filepath.Join(dir, "many", fmt.Sprintf("e%05d", i)), "") + } + a, err = r.List(filepath.Join(dir, "many")) + if err != nil || len(a.Entries) != MaxEntries || a.Count != MaxEntries+5 || a.Cut == "" { + t.Errorf("a long directory: %d of %d, cut %q, %v", len(a.Entries), a.Count, a.Cut, err) + } +} + +// getent passwd, getent group and id: an account with every group it is in, a group with its members, never a +// password field. +func TestAccountAndGroupLookup(t *testing.T) { + r, _ := testReader(t) + a, err := r.Account("ops", "") + if err != nil { + t.Fatal(err) + } + u := a.Accounts[0] + sort.Strings(u.Groups) + if u.UID != 1000 || u.Group != "ops" || u.Shell != "/usr/bin/zsh" || fmt.Sprint(u.Groups) != "[docker ops wheel]" { + t.Errorf("ops: %+v", u) + } + if a, err := r.Account("961", ""); err != nil || a.Accounts[0].Name != "mesh-files" { + t.Errorf("by number: %+v %v", a, err) + } + g, err := r.Account("", "docker") + if err != nil || g.Groups[0].GID != 970 || fmt.Sprint(g.Groups[0].Members) != "[ops builder]" { + t.Errorf("docker: %+v %v", g, err) + } + if _, err := r.Account("nobody-here", ""); err == nil { + t.Error("a missing account was answered") + } + all, err := r.Account("", "") + if err != nil || len(all.Accounts) != 3 || len(all.Groups) != 5 { + t.Errorf("all: %+v %v", all, err) + } + body, _ := json.Marshal(all) + if strings.Contains(string(body), `"x"`) || strings.Contains(string(body), "password") { + t.Errorf("a password field is answered: %s", body) + } +} + +// No answer is larger than the bound, whatever a verb made. +func TestNoAnswerIsLargerThanTheBound(t *testing.T) { + if _, err := bounded(strings.Repeat("x", MaxAnswer+1), nil); err == nil { + t.Error("an answer over the bound was given") + } + if _, err := bounded("small", nil); err != nil { + t.Error(err) + } +} + +// The review of #244 (required 1): a link flipped between a harmless file and a secret place, while it is read +// and searched, never answers the secret place's content — what was opened is judged, not the name. +func TestALinkFlippedDuringAReadNeverReadsASecretPlace(t *testing.T) { + r, dir := testReader(t) + secret := filepath.Join(dir, "var/lib/mesh-files") + r.Places = []string{secret} + write(t, filepath.Join(secret, "broker"), "THE-BROKER-CREDENTIAL\n") + write(t, filepath.Join(dir, "plain"), "THE-PLAIN-WORDS\n") + link := filepath.Join(dir, "shared/link") + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + targets := []string{filepath.Join(dir, "plain"), filepath.Join(secret, "broker")} + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + tmp := link + ".new" + os.Remove(tmp) + if os.Symlink(targets[i%2], tmp) == nil { + os.Rename(tmp, link) + } + } + }() + leaked := 0 + for i := 0; i < 1000; i++ { + if a, err := r.Read(link, ReadAsk{}); err == nil && strings.Contains(a.Text, "BROKER") { + leaked++ + } + if a, err := r.Search(SearchAsk{Text: "BROKER", Path: filepath.Dir(link)}); err == nil && len(a.Matches) > 0 { + leaked++ + } + } + close(stop) + <-done + if leaked > 0 { + t.Errorf("a secret place was answered %d times through a flipped link", leaked) + } +} + +// The review of #244 (required 2): every line of a private key's block is withheld, in a read and in a search, +// though the file's name is not refused. +func TestEveryLineOfAKeyBlockIsWithheld(t *testing.T) { + r, dir := testReader(t) + key, err := rsa.GenerateKey(crand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + body := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) + p := filepath.Join(dir, "server.pem") + write(t, p, "# the server's key\n"+body+"after = the block\n") + a, err := r.Read(p, ReadAsk{}) + if err != nil { + t.Fatal(err) + } + lines := strings.Count(body, "\n") + for _, l := range strings.Split(strings.TrimSpace(body), "\n")[1:] { + if strings.Contains(a.Text, l) && !strings.HasPrefix(l, "-----END") { + t.Fatalf("a line of the key was answered: %q", l) + } + } + if len(a.Withheld) != lines || !strings.Contains(a.Text, "after = the block") || !strings.Contains(a.Text, "the server's key") { + t.Errorf("withheld %d of the block's %d lines; text:\n%s", len(a.Withheld), lines, a.Text) + } + // A search for a character every base64 line holds answers none of the key. + m, err := r.Search(SearchAsk{Text: "A", Path: p, Matches: MaxMatches}) + if err != nil { + t.Fatal(err) + } + for _, x := range m.Matches { + if !strings.HasPrefix(x.Text, "‹withheld") { + t.Errorf("a search answered a line of the key: %q", x.Text) + } + } +} + +// The review of #244 (required 3 and should): histories, browser profiles, a process's private entries and the +// cluster credentials are refused by name. +func TestHistoriesProfilesAndProcessesAreRefusedByName(t *testing.T) { + var r Refuser + for _, p := range []string{"/home/ops/.zsh_history", "/home/ops/.bash_history", "/root/.psql_history", + "/home/ops/.viminfo", "/home/ops/.mozilla/firefox/x.default/logins.json", + "/home/ops/.config/google-chrome/Default/Cookies", "/home/ops/.config/chromium/Default/Login Data", + "/proc/self/environ", "/proc/1234/mem", "/proc/self/fd/3", "/proc/thread-self/root/etc/shadow", + "/etc/rancher/k3s/k3s.yaml", "/etc/kubernetes/admin.conf"} { + if r.Refused(p) == "" { + t.Errorf("%s is not refused", p) + } + } + for _, p := range []string{"/proc/mdstat", "/proc/self/status", "/home/ops/.config/i3/config", "/etc/hosts"} { + if why := r.Refused(p); why != "" { + t.Errorf("%s is refused: %s", p, why) + } + } +} diff --git a/modules/mesh-files/cmd/mesh-files/refuse.go b/modules/mesh-files/cmd/mesh-files/refuse.go new file mode 100644 index 00000000..df2d5586 --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/refuse.go @@ -0,0 +1,129 @@ +package main + +import ( + "path/filepath" + "strings" + + "git.novox.be/novox/mesh-sdk/go/outward" +) + +// What is refused by name, before any read (novox/hq ADR 0394 §5), and what an answer withholds (§4). +// +// **Two lists of places.** The places the mesh knows a secret is kept on this node — every module's own-secret +// directory, every credential file and grant directory, the vault's directories — come from the controller, +// which composes them from the manifests (MESH_SECRET_PLACES). The known credential files of the programs +// on a machine are this module's list, below: the mesh does not write them, so nothing else names them. Both +// hold whatever the account's file modes would allow: the file modes are the first bound, the names the second. + +// knownDirs are directories that hold nothing but credentials, wherever they are: an account's keys. +var knownDirs = map[string]bool{ + ".ssh": true, ".gnupg": true, ".password-store": true, ".aws": true, ".kube": true, ".azure": true, + ".mozilla": true, ".thunderbird": true, // browser and mail profiles: saved logins and cookies +} + +// knownNames are file names that are a credential whatever directory they are in. +var knownNames = map[string]bool{ + ".credentials.json": true, // the agent's credentials (Claude Code) + ".netrc": true, ".pgpass": true, ".git-credentials": true, ".npmrc": true, ".pypirc": true, + ".my.cnf": true, ".vault-token": true, "credentials": true, "credentials.json": true, + "id_rsa": true, "id_dsa": true, "id_ecdsa": true, "id_ed25519": true, "id_ecdsa_sk": true, "id_ed25519_sk": true, + "shadow": true, "gshadow": true, "shadow-": true, "gshadow-": true, "opasswd": true, + "broker": true, // the mesh's name for a module's bus credential + // Shell and program histories: where a token is typed. + ".bash_history": true, ".zsh_history": true, ".histfile": true, ".python_history": true, ".psql_history": true, + ".mysql_history": true, ".lesshst": true, ".viminfo": true, ".node_repl_history": true, ".sqlite_history": true, + ".rediscli_history": true, +} + +// knownSuffixes are file name endings a credential has. +var knownSuffixes = []string{".key", ".p12", ".pfx", ".jks", ".keystore", ".creds", ".nk", ".kdbx", "_key"} + +// knownPaths are the places on a machine that hold credentials and are not a module's. +var knownPaths = []string{ + "/etc/ssl/private", "/etc/wireguard", "/etc/NetworkManager/system-connections", "/etc/ipsec.d/private", + "/var/lib/iwd", "/etc/docker/key.json", "/root/.docker/config.json", "/var/lib/sss/secrets", + "/etc/rancher", "/etc/kubernetes/admin.conf", +} + +// knownProfiles are directories under an account's .config that hold a browser's saved logins and cookies. +var knownProfiles = map[string]bool{"google-chrome": true, "chromium": true, "BraveSoftware": true, "vivaldi": true, + "microsoft-edge": true} + +// procPrivate are the entries of a process under /proc that reach its memory, its environment or its files. +var procPrivate = map[string]bool{"environ": true, "mem": true, "fd": true, "map_files": true, "root": true, + "cwd": true} + +// Refuser says whether a path is a place a secret is kept. +type Refuser struct { + // Places are the mesh's, from the controller: refused with everything beneath them. + Places []string +} + +// Refused says why a path may not be read, or "" when it may. The path is clean and absolute. +func (r Refuser) Refused(path string) string { + path = filepath.Clean(path) + for _, p := range r.Places { + if within(path, p) { + return "it is a place the mesh keeps a secret (" + p + ")" + } + } + for _, p := range knownPaths { + if within(path, p) { + return "it is a place a machine keeps credentials (" + p + ")" + } + } + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]] { + return "it reaches a process's " + parts[2] + } + for i, part := range parts { + if i > 0 && parts[i-1] == ".config" && knownProfiles[part] { + return "it is " + part + "'s profile, which keeps saved logins" + } + if knownDirs[part] { + return "it is in " + part + ", which holds an account's credentials" + } + if i == len(parts)-1 { + if knownNames[part] { + return part + " is a known credential file" + } + for _, s := range knownSuffixes { + if strings.HasSuffix(part, s) && len(part) > len(s) { + return part + " is named as a key or a credential (" + s + ")" + } + } + if strings.HasSuffix(part, ".pem") && (strings.Contains(part, "key") || strings.Contains(part, "private")) { + return part + " is named as a private key" + } + if part == "config.json" && i > 0 && parts[i-1] == ".docker" { + return "a container registry login is kept there" + } + } + } + return "" +} + +func within(path, place string) bool { + return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/") +} + +// Withheld is one piece of an answer the content rule kept back: where it was, and the class it carried. +type Withheld struct { + At string `json:"at"` + Class string `json:"why"` +} + +// clean is the content rule's secret class (mesh-sdk outward, novox/hq ADR 0394 §4) over one piece of an +// answer: the piece, or a mark in its place when it is shaped like a secret. +// +// **What a file holds, not what the file system names.** It judges a file's lines, a matching line, a link's +// target, a mount's source and an account's words. A path and an entry's name are not judged: they are what +// the caller named or the file system calls a thing, and the rule's random-run test takes a temporary +// directory's or a container layer's name for a token, which would withhold the very survey asked for. A +// place where a secret sits by name is refused before any read instead (Refuser). +func clean(s string) (string, string) { + if refusal, ok := outward.CheckSecret(s); !ok { + return "‹withheld: " + refusal.What + "›", refusal.What + } + return s, "" +} diff --git a/modules/mesh-files/cmd/mesh-files/sudo.go b/modules/mesh-files/cmd/mesh-files/sudo.go new file mode 100644 index 00000000..2905072d --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/sudo.go @@ -0,0 +1,74 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os/exec" + "sort" + "strings" + "time" +) + +// Root repeats an act with more rights than the account this runs as has (novox/hq ADR 0394 §2). It is an +// interface so a unit test uses a fake: no test runs sudo. +type Root interface { + // Run runs one fixed binary with the arguments given, never through a shell, and answers what it wrote. + Run(bin string, args ...string) ([]byte, error) +} + +// rootBins are the only binaries that run as root. The argument list is the caller's, but each caller in this +// module builds it from fixed words and one path, and ends its options with `--`. +var rootBins = map[string]bool{ + "/usr/bin/cat": true, "/usr/bin/cp": true, "/usr/bin/mv": true, "/usr/bin/stat": true, "/usr/bin/chmod": true, + "/usr/bin/chown": true, "/usr/bin/mkdir": true, "/usr/bin/rm": true, "/usr/bin/readlink": true, "/usr/bin/find": true, +} + +func rootBinList() string { + var l []string + for b := range rootBins { + l = append(l, b) + } + sort.Strings(l) + return strings.Join(l, ", ") +} + +// sudoRoot is the real runner: `sudo -n` (never a password prompt) with a binary from rootBins. +type sudoRoot struct{} + +// capped keeps at most n bytes and drops the rest. +type capped struct { + buf bytes.Buffer + n int +} + +func (c *capped) Write(p []byte) (int, error) { + if room := c.n - c.buf.Len(); room > 0 { + if len(p) < room { + room = len(p) + } + c.buf.Write(p[:room]) + } + return len(p), nil +} + +func (sudoRoot) Run(bin string, args ...string) ([]byte, error) { + if !rootBins[bin] { + return nil, fmt.Errorf("%s is not one of the binaries that run as root here (%s)", bin, rootBinList()) + } + ctx, cancel := context.WithTimeout(context.Background(), SearchTime+10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "/usr/bin/sudo", append([]string{"-n", "--", bin}, args...)...) + out, errs := &capped{n: MaxScan}, &capped{n: 4096} + cmd.Stdout, cmd.Stderr = out, errs + cmd.Stdin = io.LimitReader(strings.NewReader(""), 0) + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(errs.buf.String()) + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("sudo -n %s: %s", bin, msg) + } + return out.buf.Bytes(), nil +} diff --git a/modules/mesh-files/cmd/mesh-files/trash.go b/modules/mesh-files/cmd/mesh-files/trash.go new file mode 100644 index 00000000..2da033ab --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/trash.go @@ -0,0 +1,215 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// TrashKeep is how long a deleted or replaced item stays in the trash (novox/hq ADR 0394 §4). +const TrashKeep = 7 * 24 * time.Hour + +// trashMeta is the small JSON beside a trashed item: what it was, who it belonged to, and who put it there. +type trashMeta struct { + ID string `json:"id"` + Path string `json:"path"` + UID int `json:"uid"` + GID int `json:"gid"` + Mode uint32 `json:"mode"` + Kind string `json:"kind"` + Caller string `json:"caller"` + Reason string `json:"reason"` + Time time.Time `json:"time"` +} + +// TrashItem is one item of the trash, as trash answers it. +type TrashItem struct { + ID string `json:"id"` + Path string `json:"path"` + Kind string `json:"kind"` + Owner string `json:"owner"` + Mode string `json:"mode"` + Caller string `json:"caller"` + Reason string `json:"reason"` + Time string `json:"time"` + Until string `json:"emptied-after"` +} + +// TrashAnswer is what trash answers. +type TrashAnswer struct { + Items []TrashItem `json:"items"` +} + +func (f *Files) trashDir() string { return filepath.Join(f.State, "trash") } + +func newID() string { + b := make([]byte, 5) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func (f *Files) id() string { + if f.NewID != nil { + return f.NewID() + } + return newID() +} + +// toTrash moves a path into a folder of its own in the trash, with a note of what it was. +func (f *Files) toTrash(a Act, path string) (string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", fmt.Errorf("%s cannot be put in the trash: %s", path, why(err)) + } + id, err := f.ownerOf(path) + if err != nil { + return "", err + } + tid := f.id() + dir := filepath.Join(f.trashDir(), tid) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("the trash cannot be written: %v", err) + } + if err := f.relocate(path, filepath.Join(dir, "item")); err != nil { + os.RemoveAll(dir) + return "", fmt.Errorf("%s cannot be put in the trash: %v", path, err) + } + meta, _ := json.Marshal(trashMeta{ID: tid, Path: path, UID: id.UID, GID: id.GID, Mode: permOf(info.Mode()), + Kind: typeOf(info.Mode()), Caller: a.Caller, Reason: a.Reason, Time: f.Now().UTC()}) + if err := os.WriteFile(filepath.Join(dir, "meta.json"), meta, 0o600); err != nil { + return "", fmt.Errorf("%s is in the trash, but its note cannot be written: %v", path, err) + } + return tid, nil +} + +func (f *Files) metaOf(id string) (trashMeta, error) { + var m trashMeta + raw, err := os.ReadFile(filepath.Join(f.trashDir(), id, "meta.json")) + if err != nil { + return m, err + } + return m, json.Unmarshal(raw, &m) +} + +// entries are the ids in the trash. +func (f *Files) entries() []string { + dirs, err := os.ReadDir(f.trashDir()) + if err != nil { + return nil + } + var ids []string + for _, d := range dirs { + if d.IsDir() { + ids = append(ids, d.Name()) + } + } + return ids +} + +// sweep empties what is older than TrashKeep. It runs on every write call and every trash list; a failure is +// said on stderr and tried again at the next one. +func (f *Files) sweep() { + for _, id := range f.entries() { + m, err := f.metaOf(id) + if err != nil || f.Now().Sub(m.Time) <= TrashKeep { + continue + } + if err := f.removeAll(filepath.Join(f.trashDir(), id)); err != nil { + fmt.Fprintf(os.Stderr, "[mesh-files] the trash entry %s cannot be emptied: %v\n", id, err) + } + } +} + +// Trash lists what the trash holds, oldest first. +func (f *Files) Trash() (TrashAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.sweep() + names := f.names() + ans := TrashAnswer{Items: []TrashItem{}} + var metas []trashMeta + for _, id := range f.entries() { + if m, err := f.metaOf(id); err == nil { + metas = append(metas, m) + } + } + sort.Slice(metas, func(i, j int) bool { return metas[i].Time.Before(metas[j].Time) }) + for _, m := range metas { + owner := names.user(m.UID) + if owner == "" { + owner = fmt.Sprint(m.UID) + } + if g := names.group(m.GID); g != "" { + owner += ":" + g + } + ans.Items = append(ans.Items, TrashItem{ID: m.ID, Path: m.Path, Kind: m.Kind, Owner: owner, + Mode: fmt.Sprintf("%04o", m.Mode), Caller: m.Caller, Reason: m.Reason, + Time: m.Time.Format(time.RFC3339), Until: m.Time.Add(TrashKeep).Format(time.RFC3339)}) + } + return ans, nil +} + +var idShape = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +// restore puts an item back where it was. It refuses when something is there now. +func (f *Files) restore(id string) (trashMeta, error) { + if !idShape.MatchString(id) || strings.HasPrefix(id, ".") { + return trashMeta{}, fmt.Errorf("%q is not an id from trash", id) + } + m, err := f.metaOf(id) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return m, fmt.Errorf("the trash holds no %s (it may have been emptied after %d days)", id, int(TrashKeep.Hours()/24)) + } + return m, err + } + if _, err := os.Lstat(m.Path); err == nil { + return m, fmt.Errorf("%s is there now: delete or move it first, then restore", m.Path) + } + if _, err := os.Stat(filepath.Dir(m.Path)); err != nil { + return m, fmt.Errorf("the folder of %s cannot be reached: %s", m.Path, why(err)) + } + dir := filepath.Join(f.trashDir(), id) + if err := f.relocate(filepath.Join(dir, "item"), m.Path); err != nil { + return m, fmt.Errorf("%s cannot be restored: %v", m.Path, err) + } + if err := f.chownTo(m.Path, Ident{m.UID, m.GID}); err != nil { + return m, fmt.Errorf("%s is back, but its owner could not be set: %v", m.Path, err) + } + return m, f.removeAll(dir) +} + +// Restore puts an item of the trash back where it was. +func (f *Files) Restore(a Act, id string) (ActAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return ActAnswer{}, err + } + id = strings.TrimSpace(id) + var hits []hit + if idShape.MatchString(id) { + if m, err := f.metaOf(id); err == nil { + var gerr error + if hits, gerr = f.guard(a, false, m.Path); gerr != nil { + return ActAnswer{}, gerr + } + } + } + m, err := f.restore(id) + if err != nil { + return ActAnswer{}, err + } + return f.finish(a, "restore", hits, ActAnswer{Done: "restored " + m.Path, Paths: []string{m.Path}}) +} diff --git a/modules/mesh-files/go.mod b/modules/mesh-files/go.mod new file mode 100644 index 00000000..89a969c6 --- /dev/null +++ b/modules/mesh-files/go.mod @@ -0,0 +1,5 @@ +module mesh-files + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 diff --git a/modules/mesh-files/go.sum b/modules/mesh-files/go.sum new file mode 100644 index 00000000..480a5760 --- /dev/null +++ b/modules/mesh-files/go.sum @@ -0,0 +1,4 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2 h1:1E5iapAO1o4vkwV/8CUBQvNfL3OjXRtiQGBaKHXJVW8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-files/module.json b/modules/mesh-files/module.json new file mode 100644 index 00000000..867222a3 --- /dev/null +++ b/modules/mesh-files/module.json @@ -0,0 +1,56 @@ +{ + "module": "mesh-files", + "version": "1", + "claims": [ + { + "name": "node-files", + "scope": "node", + "serves": [ + "stat", + "readlink", + "list", + "read", + "search", + "account", + "write", + "copy", + "move", + "delete", + "mkdir", + "chmod", + "chown", + "trash", + "restore" + ] + } + ], + "invokes": [ + "seat:operator-channel.tell" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-files", + "binary": "mesh-files", + "loads": [ + "mesh-files" + ], + "env": { + "MESH_FILES_STATE": "${dir:state}" + } + } + ] + } +}