Files
mesh-catalog/modules/mesh-files/cmd/mesh-files/transfer_test.go
T

302 lines
9.9 KiB
Go

package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// 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)
}
}
// 1597/AC9: a transfer slower than its bound is refused naming it, and no object or file is left.
func TestASlowTransferIsRefusedNamingTheBoundAndKeepsNothing(t *testing.T) {
g := testFiles(t)
g.Bound = 150 * time.Millisecond
release := make(chan struct{})
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Write([]byte("half of the document"))
w.(http.Flusher).Flush()
}
select { // a store that does not finish: nothing is kept of a PUT whose body it never took whole
case <-release:
case <-r.Context().Done():
}
}))
t.Cleanup(func() { close(release); slow.Close() })
g.Link = func(bucket, key, method string, seconds int) (string, error) {
return slow.URL + "/" + bucket + "/" + key, nil
}
write(t, g.path("in.bin"), "a document")
if _, err := g.Upload(g.path("in.bin"), "b", "k"); err == nil || !strings.Contains(err.Error(), "did not finish within 150ms") ||
!strings.Contains(err.Error(), "nothing is kept") {
t.Fatalf("upload error = %v", err)
}
if err := os.MkdirAll(g.path("out"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := g.Download(by, "b", "k", g.path("out/doc"), ""); err == nil || !strings.Contains(err.Error(), "did not finish within 150ms") {
t.Fatalf("download error = %v", err)
}
if e, _ := os.ReadDir(g.path("out")); len(e) != 0 {
t.Errorf("a file is left: %v", e)
}
if TransferTime != 25*time.Second {
t.Errorf("the bound is %s, want 25s", TransferTime)
}
}