mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* feat(sync): delta sync — large files move as content-defined chunks Files over 4 MiB push as chunks/<sha256> pieces plus a manifests/<sha256> chunk list keyed by the whole file's hash, so Op.Blob alone locates it and the journal format is byte-identical. A 1-byte edit to a 20 MiB file now transfers ~2 MB instead of ~21 MB, both directions; chunk boundaries come from a rolling hash (restic/chunker), so front insertions stay cheap. The hub reassembles whole blobs on demand (spool, verify, backfill, serve), which is the entire backward-compatibility story: old clients ask for blobs/<sha> and never learn anything changed. Proven by e2e tests that build the real pre-change binary from the pinned merge-base commit. The push skip-proof is one Exists per chunk — three cheaper proxies (local basis, manifest existence, stored manifest content) each proved false or forgeable across four CTO review rounds and are recorded in the code comment. Hub-side, manifests are write-once and must name only chunks the store holds; reassembly is bounded at 256 MiB against amplified manifests. Also: per-file sync ceiling 32 -> 100 MiB; import refuses archives whose journals reference content they do not hold (--allow-incomplete overrides). Deploy hubs before clients: old hubs refuse chunk keys (push degrades to offline-retry), and old clients cap reads at 32 MiB so 32-100 MiB files report "blob corrupt on remote" until the client upgrades. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7 * fix(test): fetch the pinned pre-delta commit on shallow CI clones buildOldBinary archives the pinned merge-base sha, which a fetch-depth-1 actions/checkout does not have — all three old-binary e2e tests failed in CI with exit 128 while passing on any full local clone. On archive failure, fetch just that commit (--depth=1, one object; actions/checkout persists credentials so the in-job fetch works) and retry. Verified against a real GitHub shallow clone: archive fails, the single-sha fetch succeeds, archive then yields the pre-delta tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
730 lines
26 KiB
Go
730 lines
26 KiB
Go
package main
|
||
|
||
import (
|
||
"archive/tar"
|
||
"bytes"
|
||
"compress/gzip"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/runbear-io/beardrive/internal/config"
|
||
"github.com/runbear-io/beardrive/internal/remote"
|
||
"github.com/runbear-io/beardrive/internal/store"
|
||
"github.com/runbear-io/beardrive/internal/webapp"
|
||
)
|
||
|
||
// Round 8 — the five CLI commands with zero TestSec_* coverage: import, stop,
|
||
// read-log, autostart, daemon. `import` is the one that matters, and it is the
|
||
// only command in the CLI whose TARGET is chosen by an untrusted file.
|
||
//
|
||
// Helpers here are prefixed sec8.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// a real hub, because the finding is about a real route's semantics
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const (
|
||
sec8Email = "owner@example.com"
|
||
sec8Pass = "password1"
|
||
)
|
||
|
||
// sec8Hub starts a hub wired the way cmd/bdrive/web.go wires one (auth + orgs
|
||
// + projects + the /store proxy over a file:// root) and signs this device in
|
||
// through the real device-code flow, leaving a usable settings.json in an
|
||
// isolated BDRIVE_HOME. Returns the hub and a signed-in browser client.
|
||
//
|
||
// A stubbed hub would decide the outcome of the import test by itself, and
|
||
// create-or-join-by-name is exactly the semantic under attack — so this uses
|
||
// the actual ProjectDB and the actual POST /api/projects handler.
|
||
func sec8Hub(t *testing.T) (*httptest.Server, *http.Client) {
|
||
t.Helper()
|
||
state := t.TempDir()
|
||
be, err := remote.Open(context.Background(), "file://"+filepath.Join(state, "storage"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
projects, err := webapp.OpenProjectDB(filepath.Join(state, "projects.json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
auth, err := webapp.OpenBuiltinAuth(filepath.Join(state, "auth.json"), true, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
orgs, err := webapp.OpenOrgDB(filepath.Join(state, "orgs.json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
devices, err := webapp.OpenDeviceRegistry(filepath.Join(state, "devices.json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
srv := &webapp.Server{
|
||
Root: be, Projects: projects, Auth: auth, Devices: devices,
|
||
Dir: webapp.LocalDirectory{OrgDB: orgs},
|
||
Device: webapp.Identity{ID: "hubdev", Name: "hub", Author: "hub@test"},
|
||
Upload: webapp.UploadConfig{Enabled: true},
|
||
}
|
||
ts := httptest.NewServer(srv.Handler())
|
||
t.Cleanup(ts.Close)
|
||
|
||
jar, _ := cookiejar.New(nil)
|
||
browser := &http.Client{Jar: jar, Timeout: 10 * time.Second}
|
||
resp, err := browser.PostForm(ts.URL+"/auth/signup", url.Values{
|
||
"email": {sec8Email}, "name": {"Owner"}, "password": {sec8Pass},
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp.Body.Close()
|
||
if len(jar.Cookies(sec8URL(t, ts.URL))) == 0 {
|
||
t.Fatal("signup left no session cookie")
|
||
}
|
||
if _, err := orgs.Create("default", sec8Email); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// The real headless device flow: start (anonymous) → approve in the
|
||
// browser → poll. This is how a CLI gets a token, and it leaves a token
|
||
// the hub genuinely accepts.
|
||
var start struct{ Code string }
|
||
sec8JSON(t, browser, "POST", ts.URL+"/api/auth/device/start",
|
||
map[string]string{"device": "sec8-device", "os": "test"}, &start, "")
|
||
if start.Code == "" {
|
||
t.Fatal("device/start returned no code")
|
||
}
|
||
if resp, err := browser.PostForm(ts.URL+"/auth/device/"+start.Code, nil); err != nil {
|
||
t.Fatal(err)
|
||
} else {
|
||
resp.Body.Close()
|
||
}
|
||
var poll struct{ Token string }
|
||
sec8JSON(t, browser, "POST", ts.URL+"/api/auth/device/poll",
|
||
map[string]string{"code": start.Code, "device": "sec8-device"}, &poll, "")
|
||
if poll.Token == "" {
|
||
t.Fatal("device/poll returned no token")
|
||
}
|
||
|
||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
s, err := config.LoadSettings()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s.Server, s.Token, s.Email, s.Name = ts.URL, poll.Token, sec8Email, "Owner"
|
||
if err := config.SaveSettings(s); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return ts, browser
|
||
}
|
||
|
||
func sec8URL(t *testing.T, raw string) *url.URL {
|
||
t.Helper()
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return u
|
||
}
|
||
|
||
func sec8JSON(t *testing.T, c *http.Client, method, u string, body, out any, token string) {
|
||
t.Helper()
|
||
var rd io.Reader
|
||
if body != nil {
|
||
b, err := json.Marshal(body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rd = bytes.NewReader(b)
|
||
}
|
||
req, err := http.NewRequest(method, u, rd)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
resp, err := c.Do(req)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp.Body.Close()
|
||
data, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("%s %s: %d %s", method, u, resp.StatusCode, data)
|
||
}
|
||
if out != nil {
|
||
if err := json.Unmarshal(data, out); err != nil {
|
||
t.Fatalf("%s %s: bad body %s: %v", method, u, data, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// sec8Archive builds a bdrive export archive whose manifest names `project`
|
||
// and which carries one journal and one honest blob. blobBody, when non-nil,
|
||
// replaces the blob's tar member entirely (for the size attack).
|
||
func sec8Archive(t *testing.T, project string, blobSize int64) string {
|
||
t.Helper()
|
||
path := filepath.Join(t.TempDir(), "hostile-export.tar.gz")
|
||
f, err := os.Create(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer f.Close()
|
||
gz := gzip.NewWriter(f)
|
||
tw := tar.NewWriter(gz)
|
||
|
||
man, _ := json.Marshal(map[string]any{
|
||
"project": project, "remote": "https://attacker.example/p/x",
|
||
"exported_at": time.Now().UTC(),
|
||
})
|
||
sec8Member(t, tw, "beardrive-export.json", man)
|
||
|
||
content := []byte("planted by the archive\n")
|
||
sum := sha256.Sum256(content)
|
||
sha := hex.EncodeToString(sum[:])
|
||
op, _ := json.Marshal(map[string]any{
|
||
"seq": 1, "lamport": 1, "time": time.Now().UTC().Format(time.RFC3339Nano),
|
||
"kind": "put", "path": "PLANTED.md", "blob": sha, "size": len(content),
|
||
"device": "attacker", "user": "attacker@evil.example",
|
||
})
|
||
sec8Member(t, tw, "journal/attacker.jsonl", append(op, '\n'))
|
||
|
||
if blobSize > 0 {
|
||
// A gzip bomb: the member declares blobSize bytes of zeros, which
|
||
// compress to almost nothing on the wire.
|
||
if err := tw.WriteHeader(&tar.Header{
|
||
Name: "blobs/" + strings.Repeat("a", 64), Mode: 0o644, Size: blobSize,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := io.CopyN(tw, sec8Zeros{}, blobSize); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
} else {
|
||
sec8Member(t, tw, "blobs/"+sha, content)
|
||
}
|
||
if err := tw.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := gz.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return path
|
||
}
|
||
|
||
type sec8Zeros struct{}
|
||
|
||
func (sec8Zeros) Read(p []byte) (int, error) {
|
||
for i := range p {
|
||
p[i] = 0
|
||
}
|
||
return len(p), nil
|
||
}
|
||
|
||
func sec8Member(t *testing.T, tw *tar.Writer, name string, body []byte) {
|
||
t.Helper()
|
||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := tw.Write(body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. bdrive import — the archive picks the project
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// importCmd's whole target selection is three lines:
|
||
//
|
||
// man, first, err := readManifest(tr) // manifest is INSIDE the archive
|
||
// if name == "" { name = man.Project }
|
||
// p, created, err := createProject(server, token, name, "")
|
||
//
|
||
// POST /api/projects is create-or-JOIN-by-name (that is its documented
|
||
// contract, and `created` in the response is how the caller learns which
|
||
// happened — import prints "joined" and carries on). The "must be empty"
|
||
// guard runs AFTER:
|
||
//
|
||
// if existing, err := be.List(ctx, "journal/"); ... len(existing) > 0 { refuse }
|
||
//
|
||
// So the sequence for an archive a user was handed — the docs say to pass
|
||
// these around, that is the whole anti-lock-in story — is:
|
||
//
|
||
// 1. the archive names one of the user's OWN projects
|
||
// 2. import joins it without asking
|
||
// 3. the emptiness check passes if that project has never synced
|
||
// 4. the attacker's journals, blobs and fabricated authorship land in it
|
||
//
|
||
// Step 4 is not cosmetic: every device that syncs that project pulls those
|
||
// ops, so files appear in teammates' folders with History crediting an
|
||
// attacker-chosen account, and the manifest's own field decided which project
|
||
// on which hub. The user typed a filename; the file chose the destination.
|
||
//
|
||
// Secure behaviour: import writes into a project the USER named (--name, or a
|
||
// project it created for this archive). An untrusted manifest may propose a
|
||
// name; it may not select an existing project.
|
||
func TestSec_Import_AHostileArchiveCannotLandInAProjectTheUserNeverNamed(t *testing.T) {
|
||
hub, browser := sec8Hub(t)
|
||
|
||
// The victim already has a project. It is empty — never synced — which is
|
||
// the ordinary state of a project created in the UI and not yet mounted.
|
||
var made struct {
|
||
Project struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
} `json:"project"`
|
||
Created bool `json:"created"`
|
||
}
|
||
sec8JSON(t, browser, "POST", hub.URL+"/api/projects", map[string]string{"name": "quarterly-plans"}, &made, "")
|
||
if !made.Created || made.Project.ID == "" {
|
||
t.Fatalf("setup: victim project not created: %+v", made)
|
||
}
|
||
|
||
// An archive that names it. Nothing else about the archive is unusual.
|
||
archive := sec8Archive(t, "quarterly-plans", 0)
|
||
|
||
t.Chdir(t.TempDir())
|
||
out, err := seccliRun(t, importCmd(), []string{archive})
|
||
t.Logf("import: err=%v out=%q", err, out)
|
||
|
||
// Whatever import decided, the victim's existing project must not now hold
|
||
// the archive's content.
|
||
var tree struct {
|
||
Entries []struct {
|
||
Path string `json:"path"`
|
||
} `json:"entries"`
|
||
}
|
||
sec8JSON(t, browser, "GET", hub.URL+"/api/p/"+made.Project.ID+"/tree", nil, &tree, "")
|
||
for _, e := range tree.Entries {
|
||
if strings.Contains(e.Path, "PLANTED") {
|
||
t.Fatalf("an archive's own manifest chose the project it landed in: %q now holds %q\n"+
|
||
"(importCmd takes `name` from man.Project and posts it to POST /api/projects, which is\n"+
|
||
"create-or-join-by-name; the \"must be empty\" guard runs after the join)",
|
||
made.Project.Name, e.Path)
|
||
}
|
||
}
|
||
if strings.Contains(out, "joined") {
|
||
t.Fatalf("import joined an existing project the user never named: %s", out)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. bdrive import — a bounded archive must not become an unbounded local write
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// spoolBlob is io.Copy into os.CreateTemp with no cap:
|
||
//
|
||
// n, err := io.Copy(io.MultiWriter(tmp, h), r)
|
||
//
|
||
// The reader is a tar member inside a GZIP stream, and the member's declared
|
||
// size is the attacker's number too. Zeros compress about a thousand to one,
|
||
// so a 1 MB file that looks exactly like a bdrive export writes a gigabyte to
|
||
// the importer's temp filesystem before the first check runs — the sha
|
||
// comparison that would reject it happens after the copy, by construction.
|
||
//
|
||
// This is row 18's own threat model ("a hostile archive"), on the one path
|
||
// round 4 did not measure: the archive IS the file the docs tell users to pass
|
||
// around, and `bdrive import <file>` is what they are told to run on it.
|
||
//
|
||
// Every other reader in this codebase that consumes a remote party's bytes is
|
||
// bounded — journalOps and every JSON decoder use io.LimitReader, round 7
|
||
// capped httpBackend.List at 8 MiB. This one is not.
|
||
//
|
||
// Secure behaviour asserted, deliberately fix-agnostic and very generous: an
|
||
// archive of N bytes must not spool more than 1000×N to local disk. Any real
|
||
// cap (a flag, a fixed ceiling, a ratio) satisfies it; no cap does not.
|
||
func TestSec_Import_ABoundedArchiveCannotSpoolUnboundedBytesToDisk(t *testing.T) {
|
||
const bomb = 512 << 20 // 512 MiB of zeros in the archive's blob member
|
||
|
||
archive := sec8Archive(t, "anything", bomb)
|
||
fi, err := os.Stat(archive)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Logf("archive on disk: %d bytes, declared blob member: %d bytes (%.0fx)",
|
||
fi.Size(), int64(bomb), float64(bomb)/float64(fi.Size()))
|
||
|
||
f, err := os.Open(archive)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer f.Close()
|
||
gz, err := gzip.NewReader(f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tr := tar.NewReader(gz)
|
||
if _, _, err := readManifest(tr); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
spooled := &sec8CountingBackend{}
|
||
before := sec8TempBytes(t)
|
||
stop, watched := make(chan struct{}), make(chan struct{})
|
||
var peak atomic.Int64
|
||
go func() {
|
||
defer close(watched)
|
||
for {
|
||
select {
|
||
case <-stop:
|
||
return
|
||
case <-time.After(5 * time.Millisecond):
|
||
}
|
||
if n := sec8TempBytes(t) - before; n > peak.Load() {
|
||
peak.Store(n)
|
||
}
|
||
}
|
||
}()
|
||
_, _, _, ierr := importStore(context.Background(), spooled, tr, nil, false)
|
||
close(stop)
|
||
<-watched
|
||
peakBytes := peak.Load()
|
||
t.Logf("importStore: err=%v, peak temp bytes: %d", ierr, peakBytes)
|
||
|
||
limit := fi.Size() * 1000
|
||
if peakBytes > limit {
|
||
t.Fatalf("a %d-byte archive spooled %d bytes to the local temp filesystem (%.0fx)\n"+
|
||
"(spoolBlob is an unbounded io.Copy into os.CreateTemp; the sha check that would reject\n"+
|
||
"these bytes runs after the copy, and the archive is the file the docs say to pass around)",
|
||
fi.Size(), peakBytes, float64(peakBytes)/float64(fi.Size()))
|
||
}
|
||
}
|
||
|
||
// sec8TempBytes is the total size of this process's bdrive-import spool files.
|
||
func sec8TempBytes(t *testing.T) int64 {
|
||
t.Helper()
|
||
matches, _ := filepath.Glob(filepath.Join(os.TempDir(), "bdrive-import-*"))
|
||
var total int64
|
||
for _, m := range matches {
|
||
if fi, err := os.Stat(m); err == nil {
|
||
total += fi.Size()
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
// sec8CountingBackend accepts every Put and stores nothing: the attack is what
|
||
// happens before Put is ever reached.
|
||
type sec8CountingBackend struct{ puts int }
|
||
|
||
func (b *sec8CountingBackend) Put(_ context.Context, _ string, r io.Reader, _ int64) error {
|
||
b.puts++
|
||
_, err := io.Copy(io.Discard, r)
|
||
return err
|
||
}
|
||
func (b *sec8CountingBackend) Get(context.Context, string) (io.ReadCloser, error) {
|
||
return nil, fmt.Errorf("no such object")
|
||
}
|
||
func (b *sec8CountingBackend) List(context.Context, string) ([]remote.Object, error) {
|
||
return nil, nil
|
||
}
|
||
func (b *sec8CountingBackend) Exists(context.Context, string) (bool, error) { return false, nil }
|
||
func (b *sec8CountingBackend) Close() error { return nil }
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. bdrive stop — a folder's config chooses which project is paused
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// .bdrive/config.json travels with the folder — a zip, a clone, a colleague's
|
||
// copy — which is why syncBlocked exists and says, in as many words, that its
|
||
// presence alone is not consent to sync: only `bdrive init` enrolls a device.
|
||
//
|
||
// `bdrive stop` does not consult it. It resolves the folder with mustProject
|
||
// (ResolveMount, which self-heals the registry) and then pauses whatever mount
|
||
// id that file names:
|
||
//
|
||
// vdir, _ := config.VolumeDir(proj.ID)
|
||
// daemon.Stop(vdir)
|
||
// store.SetPaused(vdir, true)
|
||
//
|
||
// So a folder that arrives on the machine — a repo someone cloned, an archive
|
||
// they unpacked — can carry the id of a project this device really does sync,
|
||
// and one `bdrive stop` in that folder stops and PAUSES the real one. The
|
||
// pause outlives the daemon by design ("cleared by bdrive init"), so the
|
||
// victim's project silently stops syncing until someone re-inits it. Silent
|
||
// staleness is the failure mode this product exists to prevent.
|
||
//
|
||
// Secure behaviour: stop pauses the project the user is standing in, and a
|
||
// folder this device never enrolled is not one of them.
|
||
func TestSec_Stop_AClonedFolderCannotPauseAProjectItOnlyNames(t *testing.T) {
|
||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
|
||
// The real, enrolled mount.
|
||
real := t.TempDir()
|
||
proj, err := config.SaveProject(real, config.Project{Volume: "wiki", Remote: "file://" + t.TempDir()})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := config.EnrollMount(real); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
vdir, err := config.VolumeDir(proj.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// A folder that merely arrived, carrying a config that names the same id.
|
||
clone := t.TempDir()
|
||
if err := os.MkdirAll(filepath.Join(clone, ".bdrive"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
body, _ := json.Marshal(proj)
|
||
if err := os.WriteFile(filepath.Join(clone, ".bdrive", "config.json"), body, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
out, err := seccliRun(t, stopCmd(), []string{clone})
|
||
t.Logf("stop in the cloned folder: err=%v out=%q", err, out)
|
||
|
||
if sec8Paused(t, vdir) {
|
||
t.Fatalf("`bdrive stop` in a folder this device never enrolled paused the real project %q\n"+
|
||
"(mustProject/ResolveMount trusts .bdrive/config.json, which travels with the folder;\n"+
|
||
"syncBlocked exists precisely because its presence is not consent, and stop never asks it)",
|
||
proj.ID)
|
||
}
|
||
}
|
||
|
||
func sec8Paused(t *testing.T, vdir string) bool {
|
||
t.Helper()
|
||
return store.Paused(vdir) // the same question syncBlocked asks
|
||
}
|
||
|
||
// The same untrusted file, one layer deeper, and this is the sharper form.
|
||
//
|
||
// ResolveMount — which every folder-taking command calls — re-points the
|
||
// registry unconditionally:
|
||
//
|
||
// mi, registered := mounts[p.ID]
|
||
// if !registered || mi.Path != folder || mi.Volume != p.Volume || mi.Remote != p.Remote {
|
||
// mounts[p.ID] = MountInfo{Path: folder, Volume: p.Volume, Remote: p.Remote}
|
||
//
|
||
// That is the "renames and moves are free" self-heal, and it cannot tell a
|
||
// move from a COPY: the mount id lives in a file that travels with the folder.
|
||
// Rounds 4 and 5 validated the SHAPE of Project.ID (it is joined onto
|
||
// $BDRIVE_HOME) and the ORIGIN of Project.Remote (it chose where the token
|
||
// went). Nothing validated its AUTHORITY — that this device ever agreed this
|
||
// folder is that mount.
|
||
//
|
||
// So one `bdrive stop` (or status, sync, log, share, export — any of them) in
|
||
// a folder that merely arrived rewrites the registry row for a real project:
|
||
// its Path becomes the arriving folder, and its Volume and Remote become that
|
||
// folder's too. `bdrive resume` and the login autostart both read that row, so
|
||
// at the next login the daemon for the user's real project runs on the
|
||
// arriving folder — the real folder stops syncing entirely, and the arriving
|
||
// folder's contents are what gets pushed to the project.
|
||
//
|
||
// Secure behaviour: the self-heal follows a mount that MOVED. A registry row
|
||
// whose recorded path still holds that mount's own config is not stale, and a
|
||
// second folder claiming the same id does not get to take it.
|
||
func TestSec_Stop_AnArrivingFolderCannotStealAnEnrolledMountsRegistryRow(t *testing.T) {
|
||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
|
||
real := t.TempDir()
|
||
proj, err := config.SaveProject(real, config.Project{Volume: "wiki", Remote: "file://" + t.TempDir()})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := config.EnrollMount(real); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
clone := t.TempDir()
|
||
if err := os.MkdirAll(filepath.Join(clone, ".bdrive"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
hostile := proj
|
||
hostile.Volume = "wiki"
|
||
hostile.Remote = "file://" + t.TempDir() // a remote of the arriving folder's choosing
|
||
body, _ := json.Marshal(hostile)
|
||
if err := os.WriteFile(filepath.Join(clone, ".bdrive", "config.json"), body, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
out, err := seccliRun(t, stopCmd(), []string{clone})
|
||
t.Logf("stop in the arriving folder: err=%v out=%q", err, out)
|
||
|
||
mounts, err := config.LoadMounts()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mi := mounts[proj.ID]
|
||
if mi.Path != real {
|
||
t.Fatalf("mount %s now points at the folder that merely arrived\n was: %s\n now: %s\n"+
|
||
"(ResolveMount re-points the registry row for any folder carrying the id; `bdrive resume`\n"+
|
||
"and the login autostart both start the daemon from this row)", proj.ID, real, mi.Path)
|
||
}
|
||
if mi.Remote != proj.Remote {
|
||
t.Fatalf("mount %s now syncs to a remote the arriving folder chose: %s (was %s)",
|
||
proj.ID, mi.Remote, proj.Remote)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. bdrive read-log — what a hook event may claim was read
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// sec8ReadMount enrols a folder for read-log (LoadProject + a registered,
|
||
// unpaused mount, which is what logReads requires) and returns the folder and
|
||
// its volume dir.
|
||
func sec8ReadMount(t *testing.T) (string, string) {
|
||
t.Helper()
|
||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
folder := t.TempDir()
|
||
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki", Remote: "file://" + t.TempDir()})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := config.EnrollMount(folder); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
vdir, err := config.VolumeDir(proj.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return folder, vdir
|
||
}
|
||
|
||
// sec8Spooled drains the volume's read spool.
|
||
func sec8Spooled(t *testing.T, vdir string) []string {
|
||
t.Helper()
|
||
st, err := store.Open(vdir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
events, err := st.PendingReads()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var out []string
|
||
for _, e := range events {
|
||
out = append(out, e.Path)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func sec8ReadLog(t *testing.T, folder string, event any) {
|
||
t.Helper()
|
||
body, err := json.Marshal(event)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cmd := readLogCmd()
|
||
cmd.SetIn(bytes.NewReader(body))
|
||
cmd.SetArgs([]string{folder})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("read-log: %v", err)
|
||
}
|
||
}
|
||
|
||
// matchCandidates turns a search tool's output into read paths by taking each
|
||
// line whole AND up to its first colon:
|
||
//
|
||
// out = append(out, line)
|
||
// if i := strings.IndexByte(line, ':'); i > 0 { out = append(out, line[:i]) }
|
||
//
|
||
// "path:12:matched text" is the shape it is written for. But a colon is a
|
||
// perfectly legal character in a synced filename — journal.SafePath allows
|
||
// every byte from 0x20 up except DEL — so a file named "CLAUDE.md:notes"
|
||
// produces the grep line
|
||
//
|
||
// CLAUDE.md:notes:12:needle
|
||
//
|
||
// and the first-colon split names CLAUDE.md, a DIFFERENT file that was never
|
||
// opened. statFiles then confirms it exists, which is the guard that is
|
||
// supposed to turn a heuristic into a trustworthy read, and it agrees.
|
||
//
|
||
// The file that does this travels: it is an ordinary file in a shared project,
|
||
// so any member can put it there and it lands in every teammate's folder. From
|
||
// then on every agent search that matches it reports reads of a path of the
|
||
// planter's choosing, under the VICTIM's device id, into the project's read
|
||
// heat — which is the signal the Dashboard's staleness quadrant and the folder
|
||
// heat dots are drawn from.
|
||
//
|
||
// Row 10 spent three rounds making sure a member cannot report reads under a
|
||
// peer's device id. This is the same forgery from the other end: the device is
|
||
// genuine and the read is not.
|
||
//
|
||
// Secure behaviour: a mined candidate is a read only if it is the file the
|
||
// match actually came from.
|
||
func TestSec_ReadLog_AFilenameCannotChargeItsReadsToAnotherFile(t *testing.T) {
|
||
folder, vdir := sec8ReadMount(t)
|
||
victim := "CLAUDE.md"
|
||
planted := "CLAUDE.md:notes"
|
||
for _, name := range []string{victim, planted} {
|
||
if err := os.WriteFile(filepath.Join(folder, name), []byte("needle\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// Control: an ordinary grep hit records the file it matched in, so the
|
||
// harness is known to reach the spool at all.
|
||
sec8ReadLog(t, folder, map[string]any{
|
||
"tool_name": "Grep",
|
||
"tool_response": planted + ":12:needle",
|
||
})
|
||
got := sec8Spooled(t, vdir)
|
||
if len(got) == 0 {
|
||
t.Fatalf("control: nothing was spooled at all; the test proves nothing")
|
||
}
|
||
for _, p := range got {
|
||
if p == victim {
|
||
t.Fatalf("a grep that matched %q recorded a read of %q\n"+
|
||
"(matchCandidates splits every response line at its first colon, and a colon is a legal\n"+
|
||
"character in a synced filename — so a file any project member can plant charges its\n"+
|
||
"reads to a path of their choosing, under the reader's own device id)", planted, victim)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The containment property read-log must hold whatever the event says: nothing
|
||
// outside the mount is ever spooled. The event is written by the agent
|
||
// platform and its strings are ultimately file content and command lines, so
|
||
// every field here is attacker-reachable.
|
||
func TestSec_ReadLog_NoEventShapeSpoolsAPathOutsideTheMount(t *testing.T) {
|
||
folder, vdir := sec8ReadMount(t)
|
||
outside := filepath.Join(t.TempDir(), "secrets.txt")
|
||
if err := os.WriteFile(outside, []byte("private\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(folder, "inside.md"), []byte("x\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
events := []any{
|
||
map[string]any{"tool_name": "Read", "tool_input": map[string]any{"file_path": outside}},
|
||
map[string]any{"tool_name": "Read", "tool_input": map[string]any{"file_path": "../" + filepath.Base(filepath.Dir(outside)) + "/secrets.txt"}},
|
||
map[string]any{"tool_name": "Read", "tool_input": map[string]any{"path": "/etc/hosts"}},
|
||
map[string]any{"tool_name": "Read", "tool_input": map[string]any{"paths": []string{outside, "/etc/hosts"}}},
|
||
map[string]any{"tool_name": "Bash", "tool_input": map[string]any{"command": "cat " + outside}},
|
||
map[string]any{"tool_name": "Grep", "tool_response": outside + ":1:private"},
|
||
map[string]any{"tool_name": "Read", "tool_input": map[string]any{"file_path": folder + "/../" + filepath.Base(folder) + "/inside.md"}},
|
||
}
|
||
for _, e := range events {
|
||
sec8ReadLog(t, folder, e)
|
||
}
|
||
for _, p := range sec8Spooled(t, vdir) {
|
||
if p == "inside.md" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(p, "..") || strings.HasPrefix(p, "/") || strings.Contains(p, "secrets.txt") || strings.Contains(p, "hosts") {
|
||
t.Errorf("read-log spooled %q, which is not inside the mount", p)
|
||
}
|
||
}
|
||
}
|