Files
mesh-host/internal/compose/resolution/files.go
T
jschoubben d68e241724 Take the catalogue's composition rules into the node-engine
ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk
carries no composition rule. The catalogue functions only the
node-engine's composition reaches (Resolve, Resolution's Compose,
Declaration, DataItems, LeftOut, Rules and the rest, the settings,
placeholder and template refusals, the rendering helpers) are copied
with their tests into internal/compose/resolution; a method of a
mesh-sdk shape becomes a function taking it first. The composition
calls its own copies, and the captured catalogue its tests read lives
here now, shared by mesh-compose's tests.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 19:15:55 +01:00

153 lines
8.2 KiB
Go

package resolution
import (
"encoding/json"
"fmt"
"path"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const filesAsAccount = "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"
const overrideWord = "a reason, to change a path a module owns anyway (optional)"
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
// with their descriptions restated for the operator's account, and the writes.
func filesVerbs() []mcat.Verb {
var verbs []mcat.Verb
for _, v := range readsVerbs() {
v.Description = strings.Replace(v.Description,
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
"", 1)
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
verbs = append(verbs, v)
}
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) mcat.Verb {
in["reason"] = "why the file is changed, for the audit trail"
in["override"] = overrideWord
return mcat.Verb{Name: name, Description: 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.",
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
}
return append(verbs,
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
map[string]string{"path": "an absolute path", "content": "the text to write",
"mode": "the file's mode, such as 0644 (optional)"},
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
write("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]string{"from": "an absolute path", "to": "an absolute path on this node"},
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
write("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]string{"from": "an absolute path", "to": "an absolute path"},
[]string{"from", "to"}, nil, "mv"),
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
write("mkdir", "Make a directory.",
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
write("chmod", "Change the mode of a file or directory.",
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
"recursive": "\"true\": also everything under a directory"},
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
write("chown", "Change the owner and group of a file or directory.",
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"), mcat.Verb{Name: "upload", Description: "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 " + filesAsAccount + ".",
Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into",
"key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})}, mcat.
// download is a plain Verb, not through write(): write() types its optional arguments as true/false
// switches, which a hash and a reason are not (issue 1719).
Verb{Name: "download", Description: "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. 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.",
Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path",
"sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)",
"reason": "why the file is changed, for the audit trail",
"override": overrideWord}, []string{"bucket", "key", "path", "reason"})}, mcat.Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
"original path and when it went.",
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
)
}
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
// module's manifest places (declaredPaths). A seat's path wins where both name one.
func resolutionOwnedPaths(r mcat.Resolution) map[string]string {
owned := map[string]string{}
for _, m := range r.Modules {
for _, p := range sortedKeys(declaredPaths(m)) {
owned[p] = m.Module + " (its own tools)"
}
}
for _, s := range mcat.Seats() {
for _, sp := range s.Paths {
for _, m := range r.Modules {
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) && !resolutionStepsBackHere(r, m, sp) {
owned[sp.Path] = m.Module + " (" + s.Name + ")"
}
}
}
}
return owned
}
// stepsBackHere says m's seat path steps back on this machine (novox/hq ADR 0287): it names a seat whose holder here
// declares the path too, so that holder owns it, as stepsBack leaves m's own rendering of it out. Without it the owner
// told was the last seat in the order of the seat table, which the controller reads sorted by name and a node in the
// order compiled (issue 1126, C3: /etc/resolv.conf, node-uplink against node-resolver).
func resolutionStepsBackHere(r mcat.Resolution, m mcat.Manifest, sp mcat.SeatPath) bool {
if sp.StepsBackFor == "" {
return false
}
for _, o := range r.Modules {
if o.Module != m.Module && holdsSeat(o, sp.StepsBackFor) && declaresPath(o, path.Clean(sp.Path)) {
return true
}
}
return false
}
// filesWords is what the holder of node-files is given beside its own words: the node's secret places and the
// paths modules own here.
func resolutionFilesWords(r mcat.Resolution, m mcat.Manifest, words map[string]string, with mcat.Rendering) (map[string]string, error) {
if !m.ClaimsSeat(mcat.FilesSeat) {
return words, nil
}
places, err := resolutionSecretPlaces(r, with)
if err != nil {
return nil, err
}
places2, err := json.Marshal(places)
if err != nil {
return nil, err
}
owned, err := json.Marshal(resolutionOwnedPaths(r))
if err != nil {
return nil, err
}
if words == nil {
words = map[string]string{}
}
for _, w := range []string{mcat.ReadsSecretPlaces, mcat.FilesOwnedPaths} {
if _, has := words[w]; has {
return nil, fmt.Errorf("%s gives its own %s; the node's secret places and owned paths are the mesh's to "+
"say (novox/hq ADR 0394 §5)", m.Module, w)
}
}
words[mcat.ReadsSecretPlaces] = string(places2)
words[mcat.FilesOwnedPaths] = string(owned)
return words, nil
}