// 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, Link: askLink}, 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("upload", "Send one file of this node into a bucket of the object store, over a short signed link and never over "+ "the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. At most 100 MiB. "+ "A place a secret is kept, a known credential file, or a link to one is refused by name, before anything is sent. "+ "Read "+asAccount+".", object(map[string]any{"path": str("an absolute path to a file"), "bucket": str("the bucket the file goes into"), "key": str("the object's key (optional; the file's SHA-256 when not given)")}, "path", "bucket"), func(a map[string]any) (any, error) { return f.Upload(arg(a, "path"), arg(a, "bucket"), arg(a, "key")) }), writeVerb("download", "Write one object of a bucket to a file of this node, over a short signed link and never over "+ "the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to the key "+ "when the key is a SHA-256; otherwise no file is written.", map[string]any{"bucket": str("the bucket"), "key": str("the object's key"), "path": str("an absolute path"), "sha256": str("the SHA-256 the bytes must have (optional; the key when that is a SHA-256)")}, []string{"bucket", "key", "path"}, func(act Act, a map[string]any) (any, error) { return f.Download(act, arg(a, "bucket"), arg(a, "key"), arg(a, "path"), arg(a, "sha256")) }), 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")) }), } }