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

329 lines
15 KiB
Go

// 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 `<seat>.<verb>`, 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 (<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")) }),
}
}