Merge pull request 'Add upload and download to node-files' verbs, so mesh-files may serve them (issue 1718)' (#63) from feat/files-through-the-store-files-verbs into main

This commit was merged in pull request #63.
This commit is contained in:
2026-10-21 14:40:48 +00:00
2 changed files with 110 additions and 2 deletions
+16
View File
@@ -74,6 +74,22 @@ func filesVerbs() []Verb {
map[string]string{"path": "an absolute path", "owner": "an account's name or number", 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"}, "group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"), []string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"),
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"})},
// 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"})},
Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " + Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
"original path and when it went.", "original path and when it went.",
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}},
+94 -2
View File
@@ -2,6 +2,7 @@ package catalogue
import ( import (
"encoding/json" "encoding/json"
"sort"
"strings" "strings"
"testing" "testing"
) )
@@ -22,7 +23,8 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
if v.Optional { if v.Optional {
t.Errorf("%s.%s is optional", FilesSeat, v.Name) t.Errorf("%s.%s is optional", FilesSeat, v.Name)
} }
if len(v.Replaces) == 0 { // upload and download (ADR 0468 §9, issue 1718) move bytes through the object store: no command is theirs.
if len(v.Replaces) == 0 && v.Name != "upload" && v.Name != "download" {
t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name) t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name)
} }
for _, c := range v.Replaces { for _, c := range v.Replaces {
@@ -30,7 +32,7 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
} }
} }
for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move", for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move",
"delete", "mkdir", "chmod", "chown", "trash", "restore"} { "delete", "mkdir", "chmod", "chown", "upload", "download", "trash", "restore"} {
if !have[name] { if !have[name] {
t.Errorf("%s serves no verb %s", FilesSeat, name) t.Errorf("%s serves no verb %s", FilesSeat, name)
} }
@@ -109,3 +111,93 @@ func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) {
} }
t.Error("no restore verb") t.Error("no restore verb")
} }
// 1718/AC1: node-files names upload and download, in mesh-files' order, before trash.
func TestNodeFilesNamesUploadAndDownloadBeforeTrash(t *testing.T) {
s, _ := SeatNamed(FilesSeat)
var names []string
for _, v := range s.Serves {
names = append(names, v.Name)
}
joined := strings.Join(names, ",")
if !strings.Contains(joined, "chown,upload,download,trash") {
t.Errorf("1718/AC1: node-files' verbs are %s, want upload and download between chown and trash", joined)
}
}
// 1718/AC2: a holder serving every verb including upload and download is accepted; one naming a verb outside the
// contract is still refused, and one leaving upload out is refused.
func TestAHolderOfNodeFilesServesUploadAndDownloadAndNothingElse(t *testing.T) {
s, _ := SeatNamed(FilesSeat)
var verbs []string
for _, v := range s.Serves {
verbs = append(verbs, v.Name)
}
hold := func(serves []string) error {
return CanHold(Manifest{Module: "mesh-files", Version: "1",
Claims: []Claim{{Name: FilesSeat, Scope: ScopeNode, Serves: serves}}}, s)
}
if err := hold(verbs); err != nil {
t.Errorf("1718/AC2: a holder serving every verb was refused: %v", err)
}
if err := hold(append(append([]string{}, verbs...), "sync")); err == nil || !strings.Contains(err.Error(), "does not promise") {
t.Errorf("1718/AC2: a verb outside the contract was accepted: %v", err)
}
var without []string
for _, v := range verbs {
if v != "upload" {
without = append(without, v)
}
}
if err := hold(without); err == nil || !strings.Contains(err.Error(), "upload") {
t.Errorf("1718/AC2: a holder leaving upload out was accepted: %v", err)
}
}
// 1718/AC3: upload requires path and bucket; download requires bucket, key, path and reason; sha256, key (upload)
// and override are plain strings, not true/false switches.
func TestNodeFilesUploadAndDownloadInputs(t *testing.T) {
s, _ := SeatNamed(FilesSeat)
want := map[string]struct {
required string
strs []string
}{
"upload": {"path,bucket", []string{"path", "bucket", "key"}},
"download": {"bucket,key,path,reason", []string{"bucket", "key", "path", "sha256", "reason", "override"}},
}
seen := 0
for _, v := range s.Serves {
w, ok := want[v.Name]
if !ok {
continue
}
seen++
req, _ := v.Input["required"].([]string)
got := append([]string{}, req...)
sortStrings(got)
exp := strings.Split(w.required, ",")
sortStrings(exp)
if strings.Join(got, ",") != strings.Join(exp, ",") {
t.Errorf("1718/AC3: %s requires %v, want %s", v.Name, req, w.required)
}
props, _ := v.Input["properties"].(map[string]any)
if len(props) != len(w.strs) {
t.Errorf("1718/AC3: %s has %d inputs, want %v", v.Name, len(props), w.strs)
}
for _, name := range w.strs {
p, has := props[name].(map[string]any)
if !has {
t.Errorf("1718/AC3: %s has no input %s", v.Name, name)
continue
}
if _, enum := p["enum"]; enum || p["type"] != "string" {
t.Errorf("1718/AC3: %s input %s is %v, want a plain string", v.Name, name, p)
}
}
}
if seen != 2 {
t.Errorf("1718/AC3: found %d of upload and download", seen)
}
}
func sortStrings(s []string) { sort.Strings(s) }