mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
175 lines
5.7 KiB
Go
175 lines
5.7 KiB
Go
package store
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Round 6, internal/store: the exported functions no TestSec_* named. The
|
||
|
|
// PutBlob* family is the client's own ingest door — the third one, after the
|
||
|
|
// hub (round 2) and the archive importer (round 4) — so the question is the
|
||
|
|
// same: does what it stores hash to the key it stores it under?
|
||
|
|
|
||
|
|
func secapiStore(t *testing.T) *Store {
|
||
|
|
t.Helper()
|
||
|
|
s, err := Open(filepath.Join(t.TempDir(), "vol"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func secapiSum(b []byte) string {
|
||
|
|
h := sha256.Sum256(b)
|
||
|
|
return hex.EncodeToString(h[:])
|
||
|
|
}
|
||
|
|
|
||
|
|
// A blob store is only content-addressed if every door computes the address
|
||
|
|
// from the bytes it is about to write. All three PutBlob entry points must
|
||
|
|
// agree with sha256 of what actually landed — no caller-supplied key, no
|
||
|
|
// trusting a name that came in with the content.
|
||
|
|
func TestSec_Store_EveryBlobDoorAddressesTheBytesItStored(t *testing.T) {
|
||
|
|
s := secapiStore(t)
|
||
|
|
body := []byte("salaries: alice 1, bob 2\n")
|
||
|
|
want := secapiSum(body)
|
||
|
|
|
||
|
|
file := filepath.Join(t.TempDir(), "src.txt")
|
||
|
|
if err := os.WriteFile(file, body, 0o600); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
doors := map[string]func() (string, int64, error){
|
||
|
|
"PutBlobBytes": func() (string, int64, error) { return s.PutBlobBytes(body) },
|
||
|
|
"PutBlobReader": func() (string, int64, error) { return s.PutBlobReader(bytes.NewReader(body)) },
|
||
|
|
"PutBlobFile": func() (string, int64, error) { return s.PutBlobFile(file) },
|
||
|
|
}
|
||
|
|
for name, put := range doors {
|
||
|
|
sum, n, err := put()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("%s: %v", name, err)
|
||
|
|
}
|
||
|
|
if sum != want {
|
||
|
|
t.Errorf("%s returned %s for content hashing to %s", name, sum, want)
|
||
|
|
}
|
||
|
|
if n != int64(len(body)) {
|
||
|
|
t.Errorf("%s returned size %d, want %d", name, n, len(body))
|
||
|
|
}
|
||
|
|
got, err := os.ReadFile(s.BlobPath(sum))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("%s: blob not readable at its own path: %v", name, err)
|
||
|
|
}
|
||
|
|
if !bytes.Equal(got, body) {
|
||
|
|
t.Errorf("%s stored %q under the address of %q", name, got, body)
|
||
|
|
}
|
||
|
|
if secapiSum(got) != sum {
|
||
|
|
t.Errorf("%s: stored bytes hash to %s, stored under %s", name, secapiSum(got), sum)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A second, different body must never be reachable under the first key —
|
||
|
|
// dedupe is by address, so it can only ever collapse identical content.
|
||
|
|
other := []byte("salaries: alice 9999\n")
|
||
|
|
sum2, _, err := s.PutBlobBytes(other)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if sum2 == want {
|
||
|
|
t.Fatalf("two different bodies landed on one address")
|
||
|
|
}
|
||
|
|
got, err := os.ReadFile(s.BlobPath(want))
|
||
|
|
if err != nil || !bytes.Equal(got, body) {
|
||
|
|
t.Errorf("the first blob's content changed under it: %q (%v)", got, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Blob files ARE the private project's file contents, sitting in a 0755
|
||
|
|
// $BDRIVE_HOME on a shared machine. Round 4 hardened the journals, state cache
|
||
|
|
// and sync state; round 5 added the read spool. The blob store itself was named
|
||
|
|
// as unasserted in both gap lists and is the one that holds the actual bytes.
|
||
|
|
func TestSec_Store_BlobContentIsNotWorldReadable(t *testing.T) {
|
||
|
|
s := secapiStore(t)
|
||
|
|
sum, _, err := s.PutBlobBytes([]byte("board minutes\n"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
fi, err := os.Stat(s.BlobPath(sum))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if fi.Mode().Perm()&0o077 != 0 {
|
||
|
|
t.Errorf("blob %s is mode %04o — readable by every account on the machine", sum, fi.Mode().Perm())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The session note is transient provenance ("claude-code session <id>") that
|
||
|
|
// gets stamped into every op the next scan commits. Same file class as the
|
||
|
|
// journals round 4 closed.
|
||
|
|
func TestSec_Store_SessionNoteIsNotWorldReadable(t *testing.T) {
|
||
|
|
s := secapiStore(t)
|
||
|
|
if err := s.SaveNote("claude-code session 7f3a-secret", time.Hour); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got := s.LoadNote(); got != "claude-code session 7f3a-secret" {
|
||
|
|
t.Fatalf("control: LoadNote = %q", got)
|
||
|
|
}
|
||
|
|
fi, err := os.Stat(s.notePath())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if fi.Mode().Perm()&0o077 != 0 {
|
||
|
|
t.Errorf("note.json is mode %04o — readable by every account on the machine", fi.Mode().Perm())
|
||
|
|
}
|
||
|
|
if err := s.ClearNote(); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if s.LoadNote() != "" {
|
||
|
|
t.Error("ClearNote left the note readable")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// state-<mount>.json decides which paths the next cycle believes it is
|
||
|
|
// tracking, and its keys are joined onto the working folder by both the scan's
|
||
|
|
// delete pass and materialize's. Neither of those joins is guarded (writeFile
|
||
|
|
// is; the delete side is not), so the guard has to be here, where the keys are
|
||
|
|
// read: a cache key that is not a clean path inside the mount is not a path
|
||
|
|
// this device may act on.
|
||
|
|
func TestSec_Store_CacheKeysCannotNameAPathOutsideTheVolume(t *testing.T) {
|
||
|
|
s := secapiStore(t)
|
||
|
|
|
||
|
|
// Control: an ordinary cache round-trips, so the plant below is read the
|
||
|
|
// same way a real one is.
|
||
|
|
if err := s.SaveCache("m1", map[string]CachedFile{"notes/hello.md": {Blob: "x", Size: 2}}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if c, err := s.LoadCache("m1"); err != nil || len(c) != 1 {
|
||
|
|
t.Fatalf("control: LoadCache = %v, %v", c, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
hostile := map[string]CachedFile{
|
||
|
|
"../../../../etc/beardrive-owned": {Size: 1},
|
||
|
|
"/etc/beardrive-absolute": {Size: 1},
|
||
|
|
".bdrive/config.json": {Size: 1},
|
||
|
|
"..": {Size: 1},
|
||
|
|
}
|
||
|
|
if err := s.SaveCache("m2", hostile); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
got, err := s.LoadCache("m2")
|
||
|
|
if err != nil {
|
||
|
|
return // refusing to load it at all is a fine answer
|
||
|
|
}
|
||
|
|
for rel := range got {
|
||
|
|
clean := filepath.ToSlash(filepath.Clean(rel))
|
||
|
|
if rel == "" || strings.HasPrefix(rel, "/") || clean == ".." || strings.HasPrefix(clean, "../") {
|
||
|
|
t.Errorf("LoadCache handed back %q — a key the cycle joins onto the working folder "+
|
||
|
|
"and neither the scan's nor materialize's delete pass re-checks", rel)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|