Serve node-files.upload and download: a machine's file goes into a bucket and an object comes out of one, by short links, never over the bus (issue 1597)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user