diff --git a/modules/mesh-files/cmd/mesh-files/files.go b/modules/mesh-files/cmd/mesh-files/files.go index c8eea476..98dffcd1 100644 --- a/modules/mesh-files/cmd/mesh-files/files.go +++ b/modules/mesh-files/cmd/mesh-files/files.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "io/fs" + "net/http" "os" "path/filepath" "regexp" @@ -69,6 +70,10 @@ type Files struct { OwnerOf func(path string) (Ident, error) Lchown func(path string, uid, gid int) error NewID func() string + // Link asks the object store for a link (seat:object-store.link) and HTTP moves the bytes over it; upload and + // download use them (transfer.go). Tests replace both. + Link func(bucket, key, method string, seconds int) (string, error) + HTTP *http.Client mu sync.Mutex } diff --git a/modules/mesh-files/cmd/mesh-files/main.go b/modules/mesh-files/cmd/mesh-files/main.go index 8dc5ad20..c6b7af43 100644 --- a/modules/mesh-files/cmd/mesh-files/main.go +++ b/modules/mesh-files/cmd/mesh-files/main.go @@ -65,7 +65,7 @@ func newFiles(places []string) (*Files, error) { 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 + 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 @@ -235,6 +235,22 @@ func tools(f *Files) []stdio.Tool { 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() }), diff --git a/modules/mesh-files/cmd/mesh-files/manifest_test.go b/modules/mesh-files/cmd/mesh-files/manifest_test.go index 5d7def24..4b0ae531 100644 --- a/modules/mesh-files/cmd/mesh-files/manifest_test.go +++ b/modules/mesh-files/cmd/mesh-files/manifest_test.go @@ -58,7 +58,17 @@ func TestTheHolderRunsAsTheToolRunnersAccount(t *testing.T) { if !state { t.Error("no state directory for the trash and the act log") } - if len(m.Invokes) != 1 || m.Invokes[0] != "seat:operator-channel.tell" { + // 1597/AC7: the tell verb, and the object store's link verb that upload and download ask for. + if len(m.Invokes) != 2 || m.Invokes[0] != "seat:operator-channel.tell" || m.Invokes[1] != "seat:object-store.link" { t.Errorf("invokes %v", m.Invokes) } + for _, v := range []string{"upload", "download"} { + found := false + for _, s := range m.Claims[0].Serves { + found = found || s == v + } + if !found { + t.Errorf("1597/AC7: the manifest does not serve %s", v) + } + } } diff --git a/modules/mesh-files/cmd/mesh-files/transfer.go b/modules/mesh-files/cmd/mesh-files/transfer.go new file mode 100644 index 00000000..4307e8cf --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/transfer.go @@ -0,0 +1,241 @@ +package main + +// upload and download (novox/hq ADR 0468 §9, issue 1597): a file of this node goes into a bucket of the object store, +// and an object comes out of one into a file of this node, over a short signed link and never over the bus. +// +// upload sends a file's content off the node, so it is judged as a read (ADR 0394 §7): before anything else it +// refuses by name what a read refuses, a link's target included. download writes a file, so it is judged as a write +// (ADR 0394 §3 to §5): a path a module owns is refused without an override, the old file goes to the trash, and the +// act is one line in the act log. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/objectlink" +) + +// LinkSeconds is how long the link asked for one transfer lives, and TransferTime how long a transfer may take. +const ( + LinkSeconds = 300 + TransferTime = 5 * time.Minute +) + +var sha256Key = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// askLink asks the object store's seat for one link (ADR 0468 §8). The answer is {link, method, seconds, expires-at}. +func askLink(bucket, key, method string, seconds int) (string, error) { + raw, err := stdio.Ask("seat:object-store.link", map[string]any{"bucket": bucket, "key": key, "method": method, "seconds": seconds}) + if err != nil { + return "", fmt.Errorf("the object store gave no %s link: %w", method, err) + } + var a struct { + Link string `json:"link"` + Answer json.RawMessage `json:"answer"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return "", fmt.Errorf("the object store's answer is not JSON: %w", err) + } + if a.Link == "" && len(a.Answer) > 0 { + _ = json.Unmarshal(a.Answer, &a) + } + if a.Link == "" { + return "", errors.New("the object store's answer holds no link") + } + return a.Link, nil +} + +func (f *Files) link(bucket, key, method string) (string, error) { + if f.Link == nil { + return "", errors.New("this holder has no way to ask the object store for a link") + } + return f.Link(bucket, key, method, LinkSeconds) +} + +func (f *Files) client() *http.Client { + if f.HTTP != nil { + return f.HTTP + } + return &http.Client{Timeout: TransferTime} +} + +// UploadAnswer is what upload answers. +type UploadAnswer struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// Upload sends one file of this node into a bucket. The key is the file's SHA-256 when none is given; the hash is +// then made first (one read of the file), so the link is asked for the key it will be used for. +func (f *Files) Upload(raw, bucket, key string) (UploadAnswer, error) { + if strings.TrimSpace(bucket) == "" { + return UploadAnswer{}, errors.New("bucket is needed: name the bucket the file goes into") + } + path, err := absolute(raw) + if err != nil { + return UploadAnswer{}, err + } + // Refused by name before anything else: no link is asked and nothing is read for a place a secret is kept. + if err := f.refusedRead(path); err != nil { + return UploadAnswer{}, err + } + in, err := os.Open(path) + if err != nil { + return UploadAnswer{}, fmt.Errorf("%s cannot be read by this account: %s", path, why(err)) + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return UploadAnswer{}, err + } + if !info.Mode().IsRegular() { + return UploadAnswer{}, fmt.Errorf("%s is a %s, not a file: upload sends one file", path, typeOf(info.Mode())) + } + if info.Size() > objectlink.MaxDocument { + return UploadAnswer{}, fmt.Errorf("%s is %d bytes, over the %d a document may be (novox/hq ADR 0468 §3)", path, info.Size(), objectlink.MaxDocument) + } + want := "" + if strings.TrimSpace(key) == "" { + sum, err := hashOf(in) + if err != nil { + return UploadAnswer{}, fmt.Errorf("%s could not be read: %v", path, err) + } + if _, err := in.Seek(0, io.SeekStart); err != nil { + return UploadAnswer{}, err + } + key, want = sum, sum + } + link, err := f.link(bucket, key, "put") + if err != nil { + return UploadAnswer{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), TransferTime) + defer cancel() + res, err := objectlink.Put(ctx, f.client(), link, in, info.Size(), objectlink.MaxDocument) + if err != nil { + return UploadAnswer{}, fmt.Errorf("%s could not be sent: %w", path, err) + } + if want != "" && res.SHA256 != want { + return UploadAnswer{}, fmt.Errorf("%s changed while it was sent: it was stored under %s but hashes to %s", path, want, res.SHA256) + } + return UploadAnswer{Bucket: bucket, Key: key, Size: res.Size, SHA256: res.SHA256}, nil +} + +func hashOf(r io.Reader) (string, error) { + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// DownloadAnswer is what download answers. +type DownloadAnswer struct { + ActAnswer + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// Download writes one object of a bucket to a file of this node. The bytes must hash to sha256 when it is given, or +// to the key when the key is itself a SHA-256 (the store keeps a document under its SHA-256, ADR 0468 §2); a +// mismatch leaves no file, and an old file is left as it was. +func (f *Files) Download(a Act, bucket, key, raw, wantSHA string) (DownloadAnswer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.begin(a); err != nil { + return DownloadAnswer{}, err + } + if strings.TrimSpace(bucket) == "" || strings.TrimSpace(key) == "" { + return DownloadAnswer{}, errors.New("bucket and key are needed: name the object to fetch") + } + path, err := absolute(raw) + if err != nil { + return DownloadAnswer{}, err + } + hits, err := f.guard(a, false, path) + if err != nil { + return DownloadAnswer{}, err + } + dir := filepath.Dir(path) + if _, err := os.Stat(dir); err != nil { + return DownloadAnswer{}, fmt.Errorf("the folder of %s cannot be reached: %s", path, why(err)) + } + expect := strings.ToLower(strings.TrimSpace(wantSHA)) + if expect == "" && sha256Key.MatchString(key) { + expect = key + } + var perm uint32 = 0o644 + want, exists := Ident{}, false + if old, err := os.Lstat(path); err == nil { + if !old.Mode().IsRegular() { + return DownloadAnswer{}, fmt.Errorf("%s is a %s, not a file: download replaces a file whole", path, typeOf(old.Mode())) + } + exists, perm = true, permOf(old.Mode()) + if want, err = f.ownerOf(path); err != nil { + return DownloadAnswer{}, err + } + } else if want, err = f.ownerOf(dir); err != nil { + return DownloadAnswer{}, err + } + link, err := f.link(bucket, key, "get") + if err != nil { + return DownloadAnswer{}, err + } + tmp, err := os.CreateTemp(dir, ".download-*") + if err != nil { + return DownloadAnswer{}, fmt.Errorf("a file cannot be made in the folder of %s by this account: %s", path, why(err)) + } + done := false + defer func() { + tmp.Close() + if !done { + os.Remove(tmp.Name()) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), TransferTime) + defer cancel() + res, err := objectlink.Get(ctx, f.client(), link, tmp, objectlink.MaxDocument, expect) + if err != nil { + return DownloadAnswer{}, fmt.Errorf("%s/%s could not be fetched: %w", bucket, key, err) + } + if err := tmp.Close(); err != nil { + return DownloadAnswer{}, err + } + if err := f.setMode(tmp.Name(), perm); err != nil { + return DownloadAnswer{}, err + } + if err := f.chownTo(tmp.Name(), want); err != nil { + return DownloadAnswer{}, fmt.Errorf("the owner of %s could not be set: %v", path, err) + } + trashed := "" + if exists { + if trashed, err = f.toTrash(a, path); err != nil { + return DownloadAnswer{}, err + } + } + if err := os.Rename(tmp.Name(), path); err != nil { + if trashed != "" { + if _, rerr := f.restore(trashed); rerr != nil { + err = fmt.Errorf("%v (and the old file could not be put back from the trash, id %s: %v)", err, trashed, rerr) + } + } + return DownloadAnswer{}, err + } + done = true + ans, err := f.finish(a, "download", hits, ActAnswer{Done: "wrote " + path, Paths: []string{path}, Trashed: trashed}) + return DownloadAnswer{ActAnswer: ans, Size: res.Size, SHA256: res.SHA256}, err +} diff --git a/modules/mesh-files/cmd/mesh-files/transfer_test.go b/modules/mesh-files/cmd/mesh-files/transfer_test.go new file mode 100644 index 00000000..aff55cad --- /dev/null +++ b/modules/mesh-files/cmd/mesh-files/transfer_test.go @@ -0,0 +1,262 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// fakeStore stands for the object store behind signed links: a PUT keeps the body under its path, a GET answers it. +// objectlink (mesh-sdk go/objectlink) is the real client of both. +type fakeStore struct { + mu sync.Mutex + objects map[string][]byte + puts []string + srv *httptest.Server +} + +func newFakeStore(t *testing.T) *fakeStore { + t.Helper() + s := &fakeStore{objects: map[string][]byte{}} + s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + switch r.Method { + case http.MethodPut: + b, _ := io.ReadAll(r.Body) + s.objects[r.URL.Path] = b + s.puts = append(s.puts, r.URL.Path) + case http.MethodGet: + b, ok := s.objects[r.URL.Path] + if !ok { + w.WriteHeader(404) + return + } + w.Write(b) + } + })) + t.Cleanup(s.srv.Close) + return s +} + +// linkVerb is the fake object-store.link: it records each ask and answers a link to the fake store. +type linkVerb struct { + store *fakeStore + asks []string + err error +} + +func (l *linkVerb) ask(bucket, key, method string, seconds int) (string, error) { + l.asks = append(l.asks, method+" "+bucket+"/"+key) + if l.err != nil { + return "", l.err + } + return l.store.srv.URL + "/" + bucket + "/" + key + "?sig=1", nil +} + +func transferRig(t *testing.T, places ...string) (*rig, *fakeStore, *linkVerb) { + t.Helper() + g := testFiles(t) + g.Refuser.Places = places + st := newFakeStore(t) + lv := &linkVerb{store: st} + g.Link = lv.ask + return g, st, lv +} + +func sum(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } + +// 1597/AC1: the store receives exactly the file's bytes and the answer is {key: SHA-256, size, sha256}. +func TestUploadSendsTheFileUnderItsHash(t *testing.T) { + g, st, lv := transferRig(t) + body := bytes.Repeat([]byte("a document\n"), 1000) + write(t, g.path("home/ops/doc.pdf"), string(body)) + ans, err := g.Upload(g.path("home/ops/doc.pdf"), "mesh-shanks-brother", "") + if err != nil { + t.Fatal(err) + } + want := sum(body) + if ans.Key != want || ans.SHA256 != want || ans.Size != int64(len(body)) { + t.Fatalf("answer %+v", ans) + } + if !bytes.Equal(st.objects["/mesh-shanks-brother/"+want], body) { + t.Fatal("the store did not receive exactly the file's bytes") + } + if len(lv.asks) != 1 || lv.asks[0] != "put mesh-shanks-brother/"+want { + t.Fatalf("link asks %v", lv.asks) + } +} + +// 1597/AC2: a key given is the key it is stored under. +func TestUploadStoresUnderTheKeyGiven(t *testing.T) { + g, st, _ := transferRig(t) + write(t, g.path("a.txt"), "hello") + ans, err := g.Upload(g.path("a.txt"), "b", "scans/a.txt") + if err != nil { + t.Fatal(err) + } + if ans.Key != "scans/a.txt" || string(st.objects["/b/scans/a.txt"]) != "hello" || ans.SHA256 != sum([]byte("hello")) { + t.Fatalf("answer %+v, store %v", ans, st.puts) + } +} + +// 1597/AC3: places a read refuses are refused by name, and no link is asked and nothing reaches the store. +func TestUploadRefusesWhatAReadRefuses(t *testing.T) { + dir := t.TempDir() + // A vault directory and a module's own-secret directory are the controller's secret places (MESH_SECRET_PLACES). + places := []string{filepath.Join(dir, "var/lib/vault"), filepath.Join(dir, "var/lib/brother")} + g, st, lv := transferRig(t, places...) + secret := map[string]string{ + "vault": filepath.Join(dir, "var/lib/vault/box"), + "own-secret": filepath.Join(dir, "var/lib/brother/admin-password.secret"), + "key file": filepath.Join(dir, "srv/tls/server.key"), + "ssh key": filepath.Join(dir, "home/ops/.ssh/id_ed25519"), + "known profile": filepath.Join(dir, "home/ops/.mozilla/firefox/logins.json"), + } + for name, p := range secret { + write(t, p, "SECRET") + if _, err := g.Upload(p, "b", ""); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("%s: not refused by name: %v", name, err) + } + } + link := filepath.Join(dir, "home/ops/innocent.txt") + if err := os.Symlink(secret["ssh key"], link); err != nil { + t.Fatal(err) + } + if _, err := g.Upload(link, "b", ""); err == nil || !strings.Contains(err.Error(), "leads to") { + t.Errorf("a link to a secret: %v", err) + } + if len(lv.asks) != 0 || len(st.puts) != 0 { + t.Fatalf("a link was asked %v or the store was reached %v", lv.asks, st.puts) + } +} + +// 1597/AC4: a file over 100 MiB is refused before a link is asked. +func TestUploadRefusesOverOneHundredMiB(t *testing.T) { + g, st, lv := transferRig(t) + p := g.path("big.bin") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(100<<20 + 1); err != nil { // a sparse file: no bytes are written + t.Fatal(err) + } + f.Close() + if _, err := g.Upload(p, "b", ""); err == nil || !strings.Contains(err.Error(), "over the") { + t.Fatalf("not refused: %v", err) + } + if len(lv.asks) != 0 || len(st.puts) != 0 { + t.Fatal("a link was asked for a file over the limit") + } +} + +// 1597/AC5: download writes the object to the path; a module-owned path is refused without an override; a hash that +// does not match leaves no file. +func TestDownloadWritesTheObject(t *testing.T) { + g, st, lv := transferRig(t) + st.objects["/b/k.txt"] = []byte("from the store") + ans, err := g.Download(by, "b", "k.txt", g.path("out/k.txt"), "") + if err == nil { + t.Fatal("the folder does not exist, yet download succeeded") + } + if err := os.MkdirAll(g.path("out"), 0o755); err != nil { + t.Fatal(err) + } + ans, err = g.Download(by, "b", "k.txt", g.path("out/k.txt"), "") + if err != nil { + t.Fatal(err) + } + if mustRead(t, g.path("out/k.txt")) != "from the store" || ans.SHA256 != sum([]byte("from the store")) || ans.Size != 14 { + t.Fatalf("answer %+v", ans) + } + if len(lv.asks) != 1 || lv.asks[0] != "get b/k.txt" { + t.Fatalf("asks %v", lv.asks) + } + if lines := actLines(t, g); len(lines) != 1 || lines[0]["verb"] != "download" { + t.Fatalf("act log %v", lines) + } +} + +func TestDownloadRefusesAModuleOwnedPathWithoutOverride(t *testing.T) { + g, st, lv := transferRig(t) + st.objects["/b/k"] = []byte("x") + owned := g.path("etc/brother.conf") + write(t, owned, "old") + g.Owned[owned] = "brother (settings)" + _, err := g.Download(by, "b", "k", owned, "") + if err == nil || !strings.Contains(err.Error(), "is owned by brother") { + t.Fatalf("not refused: %v", err) + } + if mustRead(t, owned) != "old" || len(lv.asks) != 0 { + t.Fatal("the owned file changed or a link was asked") + } + if _, err := g.Download(Act{Caller: by.Caller, Reason: "a test", Override: "because"}, "b", "k", owned, ""); err != nil { + t.Fatalf("with an override: %v", err) + } +} + +func TestDownloadWithAWrongHashLeavesNoFile(t *testing.T) { + g, st, _ := transferRig(t) + st.objects["/b/"+sum([]byte("expected"))] = []byte("something else") // the key says a hash the bytes do not have + dir := g.path("out") + os.MkdirAll(dir, 0o755) + write(t, filepath.Join(dir, "doc"), "old content") + _, err := g.Download(by, "b", sum([]byte("expected")), filepath.Join(dir, "doc"), "") + if err == nil || !strings.Contains(err.Error(), "hashes to") { + t.Fatalf("not refused: %v", err) + } + if mustRead(t, filepath.Join(dir, "doc")) != "old content" { + t.Fatal("the old file was replaced") + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("a temporary file is left: %v", entries) + } + // An explicit sha256 is judged too, for a key that is not a hash. + st.objects["/b/plain"] = []byte("abc") + if _, err := g.Download(by, "b", "plain", filepath.Join(dir, "plain"), sum([]byte("other"))); err == nil { + t.Fatal("a wrong sha256 was accepted") + } + if _, err := os.Stat(filepath.Join(dir, "plain")); err == nil { + t.Fatal("a file was left for a wrong sha256") + } +} + +// 1597/AC6: upload then download keeps the SHA-256 across the round trip. +func TestUploadThenDownloadKeepsTheHash(t *testing.T) { + g, _, _ := transferRig(t) + body := bytes.Repeat([]byte{0, 1, 2, 3, 250}, 50000) + write(t, g.path("in.bin"), string(body)) + up, err := g.Upload(g.path("in.bin"), "b", "") + if err != nil { + t.Fatal(err) + } + down, err := g.Download(by, "b", up.Key, g.path("out.bin"), "") + if err != nil { + t.Fatal(err) + } + if up.SHA256 != down.SHA256 || sum([]byte(mustRead(t, g.path("out.bin")))) != up.SHA256 { + t.Fatalf("up %+v down %+v", up, down) + } +} + +// 1597/AC1: the verbs are listed on the seat with their inputs. +func TestTheSeatListsUploadAndDownload(t *testing.T) { + g, _, _ := transferRig(t) + names := map[string]bool{} + for _, tool := range tools(g.Files) { + names[tool.Name] = true + } + if !names[Seat+".upload"] || !names[Seat+".download"] { + t.Fatalf("tools %v", names) + } +} diff --git a/modules/mesh-files/go.mod b/modules/mesh-files/go.mod index 89a969c6..46f7fe53 100644 --- a/modules/mesh-files/go.mod +++ b/modules/mesh-files/go.mod @@ -1,5 +1,5 @@ module mesh-files -go 1.25.0 +go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f diff --git a/modules/mesh-files/go.sum b/modules/mesh-files/go.sum index 480a5760..35a7408b 100644 --- a/modules/mesh-files/go.sum +++ b/modules/mesh-files/go.sum @@ -2,3 +2,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2 h1:1E5iapAO git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/mesh-files/module.json b/modules/mesh-files/module.json index cc1df162..915d2aef 100644 --- a/modules/mesh-files/module.json +++ b/modules/mesh-files/module.json @@ -20,12 +20,15 @@ "chmod", "chown", "trash", - "restore" + "restore", + "upload", + "download" ] } ], "invokes": [ - "seat:operator-channel.tell" + "seat:operator-channel.tell", + "seat:object-store.link" ], "resources": [ {