From 90b20f5a3bda658be5fa3c868f0d9de1876521a0 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 15:21:16 +0200 Subject: [PATCH] =?UTF-8?q?Add=20upload=20and=20download=20to=20node-files?= =?UTF-8?q?'=20verbs,=20as=20ADR=200468=20=C2=A79=20decides,=20so=20mesh-f?= =?UTF-8?q?iles=20may=20serve=20them=20(issue=201718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/catalogue/files.go | 16 +++++++ go/catalogue/files_test.go | 96 +++++++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/go/catalogue/files.go b/go/catalogue/files.go index f1356ed..a1231a3 100644 --- a/go/catalogue/files.go +++ b/go/catalogue/files.go @@ -74,6 +74,22 @@ func filesVerbs() []Verb { 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"), + 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, " + "original path and when it went.", Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, diff --git a/go/catalogue/files_test.go b/go/catalogue/files_test.go index 63f6923..6cd1742 100644 --- a/go/catalogue/files_test.go +++ b/go/catalogue/files_test.go @@ -2,6 +2,7 @@ package catalogue import ( "encoding/json" + "sort" "strings" "testing" ) @@ -22,7 +23,8 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) { if v.Optional { 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) } 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", - "delete", "mkdir", "chmod", "chown", "trash", "restore"} { + "delete", "mkdir", "chmod", "chown", "upload", "download", "trash", "restore"} { if !have[name] { t.Errorf("%s serves no verb %s", FilesSeat, name) } @@ -109,3 +111,93 @@ func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) { } 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) }