feat(cli): bdrive verify proves this folder matches the hub (BEA-161)

"Your folder is the same everywhere" was a belief with no receipt. `bdrive
status` counts pending ops and unscanned changes but never reads a byte of
content, so a file whose bytes changed while its size and mtime stayed put was
invisible to every check we shipped.

`bdrive verify` hashes every synced file and compares it against
journal.Replay(AllOps), reporting drifted / never-pushed / missing-locally /
not-yet-scanned, and with --remote also missing-on-hub. Exit 0 when every
category is empty, 1 otherwise, so it composes as a pre-flight check.

The logic lives in internal/syncer/verify.go next to Drift and Explain — the
two read-only siblings it completes — because neverSync, chunkThreshold and
loadFilter are unexported there, and because a multi-device test cannot drive
a func main package. cmd/bdrive/verify.go is the thin cobra shell.

Two things that would have made it silently wrong:

- --remote must probe BOTH blobs/<sha> and manifests/<sha>. Files over 4 MiB
  are pushed as chunks plus a manifest keyed by the file's own sha, so a check
  asking only blobs/ would call every large file missing from the hub. Size
  only orders the probe — it can never be a filter, because browser uploads
  always write blobs/<sha> at any size, pushChunked falls back to a whole blob
  when the manifest key is refused, and pre-delta-sync history is whole blobs
  regardless.
- missing-locally applies filter.Skip + neverSync, the same guard materialize
  uses. The rules are symmetric in scan and materialize, so a path the local
  filter excludes is legitimately absent — without this, every project narrowed
  by `bdrive scope --only` would report its whole out-of-scope set as missing.

Pure read throughout: no Session, no volume flock, no ops, no journal writes,
no materialize, and no network without --remote. LoadProject rather than
ResolveMount and remote.Open rather than openSession, so a read never enrolls
the device. An unreachable hub degrades to a printed warning and the local
verdict still decides.

The command says its own caveat out loud: the journals it replays are this
device's local copies, so it proves "this folder matches what I last pulled".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee
2026-08-21 09:24:23 -07:00
co-authored by Claude Opus 5
parent 899a840439
commit 46cfeb7898
8 changed files with 918 additions and 1 deletions
+1
View File
@@ -253,6 +253,7 @@ hub's own storage, never something a syncing client points at directly:
| `bdrive scope [add\|rm <dirs...>]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes, so no one hand-writes negation syntax. The daemon picks changes up in seconds; `rm` deletes nothing, locally or on the hub. `--explain` lists every path in the folder split into what syncs and what does not, so you can verify what leaves this machine (pure read — no daemon, no lock, no network) |
| `bdrive grep <pattern> [folder]` | Search the text **inside** the files a project syncs — Go RE2 regexp, or a literal with `-F`; `-i` ignores case, `-l` prints matching paths only, `-n` caps the lines printed (default 200, `0` = all). Output is `path:line: text`. Only files the project actually syncs are searched, so a `.bdriveignore` rule or a narrowed `bdrive scope` excludes a file from search exactly as it excludes it from sync; binary files are skipped. Pure local read — no daemon, no lock, no network, works offline and never blocks a sync in progress. Exit status 0 on match, 1 on none, so it composes in scripts |
| `bdrive stale [folder]` | Find docs the code has outgrown: synced markdown (`.md`/`.markdown`) that links to a file written **after** the doc itself. Staleness here is not age — a doc goes stale when what it describes moves. Write times come from the **journal**, not `os.Stat`: materialize stamps a peer's file with this device's mtime, so on a freshly synced machine every mtime is identical and only the journal still knows. `-l` prints outgrown paths only, `-n` caps the docs printed (default 50, `0` = all). A reference that does not resolve to a file this project syncs — a URL, a `../` escape, a made-up path — is silently ignored. Pure local read — no daemon, no lock, no network. **Exit status is 0 whether or not anything is stale**: this is advisory, not a gate |
| `bdrive verify [folder]` | Prove this folder matches the content the journal records: **sha256 every file the project syncs** and compare. Reports `drifted` (on disk, but not what the journal records), `never-pushed` (committed here, never reached the hub), `missing-locally` (the journal has it, this folder does not), `not-yet-scanned` (on disk, no op yet), and with `--remote` also `missing-on-hub` (one existence check per distinct blob, asking **both** `blobs/<sha>` and `manifests/<sha>` — files over 4 MiB are pushed as chunks plus a manifest, so a check that asked only `blobs/` would call every large file missing). Unlike `bdrive status` this reads content, so a file whose bytes changed while its size and mtime stayed put is caught; there is deliberately no fast path, and it is slower than `status` on a big project. Pure read — no daemon, no lock, no ops, no journal writes, and without `--remote` no network. It never repairs anything. **Exit status is 0 when every category is empty, 1 otherwise**, so it composes as a pre-flight check. An unreachable hub degrades `--remote` to a warning, never a failure. The journals it replays are this device's local copies, so it proves "this folder matches what I last pulled" |
| `bdrive forget <path>...` | Stop syncing a path *and* remove it from the hub — adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
| `bdrive url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally |
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
+24 -1
View File
@@ -115,6 +115,22 @@ classDiagram
}
note for Drift "drift.go — the `local:` line in bdrive status: what is on disk that the state cache has not seen, using the scan's own size+mtime compare. Pure read like its siblings, and load-bearing that it stays one: status is what someone runs when sync is stuck, so it stores no blob, mints no op, rewrites no cache — it does not even mutate the cache map it is handed, which status prints `files:` from"
class Verify {
+Verify(ctx, folder, include, st, device, be) VerifyReport
-existsEither(ctx, be, blob, size) bool
}
class VerifyReport {
+Files int
+Bytes int64
+Elapsed Duration
+Drifted / NeverPushed / MissingLocally paths
+NotYetScanned / MissingOnHub paths
+NotFetched int
+RemoteErr error
+Problems() int
}
note for Verify "verify.go — bdrive verify, the third read-only sibling and the only one that HASHES: sha256 every synced file against journal.Replay(st.AllOps()), which is why it catches the file whose bytes changed while size and mtime stayed put — precisely what Drift cannot see. Pure read like its siblings: no Session, no flock, no ops, no journal write, no materialize, and no network unless a Backend is passed. missing-locally applies filter.Skip + neverSync, the same guard materialize uses, so a scope-narrowed project does not report every out-of-scope path as missing. The --remote leg is one Exists per DISTINCT blob probing BOTH blobs/<sha> and manifests/<sha> — size only orders the probe, because a large file legitimately lives under blobs/ (browser upload, pushChunked fallback, pre-delta-sync history) — and any Exists error sets RemoteErr and stops that leg, never the local verdict"
class Explain {
+Explain(folder, include, accepted) two lists
+NotSyncedFiles(entries) int
@@ -191,6 +207,11 @@ classDiagram
Session --> Filter : SkipUp on scan, Skip on materialize
Session --> walkFolder : scan
Explain --> walkFolder : same predicate
Verify --> SyncedFiles : hashes what syncs
Verify --> Filter : own fresh instance, for missing-locally
Verify --> Store : AllOps / DeviceOps / LoadSync / HasBlob
Verify --> Backend : Exists per blob, --remote only
Verify ..> VerifyReport : findings and exit status
Drift --> walkFolder : same predicate
Drift --> Filter : own fresh instance
SyncedFiles --> walkFolder : same predicate
@@ -223,13 +244,15 @@ classDiagram
class Commands {
init login logout
sync stop scope grep stale forget status log
sync stop scope grep stale verify forget status log
restore url share export import
web daemon hooks read-log
resume autostart
}
note for Commands "cmd/bdrive — thin cobra layer; init is the front door (one command: login + hooks + sync + link), stop pauses"
note for Commands "grep searches file CONTENTS in the working folder via syncer.SyncedFiles — LoadProject not ResolveMount (a read must not enroll the device), no session, no flock, and the volume store is opened only if it already exists, so a search creates nothing. Exit 1 on no match is a status, not an error (errNoMatch + SilenceErrors). stale copies that whole posture and swaps the predicate: it extracts path-shaped references from synced markdown, keeps only the ones resolving into the SyncedFiles set, and flags a doc whose reference was written later. It dates a path from the JOURNAL, not os.Stat — materialize stamps a peer's file with this device's mtime, so mtime comparison reports nothing on a freshly cloned machine — folding st.AllOps() to the max syncer.DisplayTime per path, which drops a forged future stamp instead of dating that path to year 1. Unlike grep it exits 0 either way: advisory output, not a gate"
note for Commands "verify is the same pure-read shell again — LoadProject not ResolveMount, stat-guarded store.Open, safeField on every path (missing-locally and missing-on-hub come out of a PEER's journal) — and it opens its Backend with remote.Open directly rather than openSession, because openSession goes through mustProject and would enroll the device. Exit 1 when any category is non-empty is a status, not an error (errVerifyProblems + SilenceErrors), so it composes as a pre-flight check; an unreachable hub prints a warning and the local verdict still decides"
note for Commands "Every peer-authored string status / log / whoami print goes through safeField first — a teammate's file name is attacker-controlled text landing in your terminal, and an escape sequence there rewrites the line above it. grep runs BOTH the path and the matched line through it — a matched line is a teammate's file content, the widest version of that surface. login now does PKCE on the loopback callback (no compat arm) and both its client and init's refuse to follow a redirect off the hub's origin with the device token attached"
class Templates {
+1
View File
@@ -55,6 +55,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
scopeCmd(),
grepCmd(),
staleCmd(),
verifyCmd(),
forgetCmd(),
syncCmd(),
readLogCmd(),
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"errors"
"fmt"
"io"
"time"
"github.com/spf13/cobra"
"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/syncer"
)
// errVerifyProblems is verify's "something is wrong" exit, not a failure of
// the command: the convention is status 1 with the findings already printed,
// so `bdrive verify || ...` composes in a script or a pre-flight check. Real
// errors (not a project, bad remote) still print — see verifyCmd.
var errVerifyProblems = errors.New("verify found problems")
// verifyMaxList caps how many paths are listed per category. The counts are
// always exact; only the listing is bounded, so a project that drifted whole
// does not scroll the finding off the screen.
const verifyMaxList = 20
func verifyCmd() *cobra.Command {
var checkRemote bool
c := &cobra.Command{
Use: "verify [folder]",
Short: "Prove this folder matches the content the journal records",
Long: `Hash every file this project syncs and compare it to the journal.
"Your folder is the same everywhere" is the claim; this is the receipt.
` + "`bdrive status`" + ` counts pending ops and unscanned changes but never reads a
byte of content so a file whose bytes changed while its size and mtime
stayed put is invisible to it. verify hashes, which is the whole point: there
is deliberately no size/mtime fast path, and on a large project it is
noticeably slower than status.
It reports five things:
drifted on disk, but not the content the journal records
never-pushed committed here, never reached the hub
missing-locally the journal has it, this folder does not
not-yet-scanned on disk, with no op anywhere yet
missing-on-hub synced here, absent from the hub (--remote only)
It is a pure read: no daemon, no lock, no ops, no journal writes, and without
--remote no network at all so it works offline and never blocks on a sync in
progress. It never repairs anything; run ` + "`bdrive sync`" + ` for that.
Exit status is 0 when every category is empty and 1 when any is not.
One caveat it prints for itself: the journals it replays are this device's
local copies, so without a pull it proves "this folder matches what I last
pulled" a teammate's newer op is invisible until the next cycle.`,
Example: ` bdrive verify # hash the folder, compare to the journal
bdrive verify --remote # also ask the hub whether it still holds the content
bdrive verify ~/wiki # a project other than the current folder`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
err := runVerify(cmd, args, checkRemote)
// SilenceErrors below is for errVerifyProblems alone, so anything
// else has to print itself — cobra no longer will.
if err != nil && !errors.Is(err, errVerifyProblems) {
fmt.Fprintln(cmd.ErrOrStderr(), "Error:", err)
}
return err
},
}
// Silenced so a findings exit is status 1 with the findings and nothing
// else — errVerifyProblems is a status, not a failure, and cobra would
// otherwise render it as an error and a usage block. Same mechanism
// `bdrive grep` uses for its no-match exit.
c.SilenceErrors = true
c.SilenceUsage = true
c.Flags().BoolVar(&checkRemote, "remote", false, "also ask the hub whether it still holds every blob (one check per blob)")
return c
}
func runVerify(cmd *cobra.Command, folderArg []string, checkRemote bool) error {
folder, err := absFolder(folderArg)
if err != nil {
return err
}
// LoadProject, not ResolveMount: ResolveMount self-heals the registry
// path, i.e. it enrolls this device. A read-only query must not have that
// side effect — the same rule grep and stale follow.
proj, found, err := config.LoadProject(folder)
if err != nil {
return err
}
if !found {
return fmt.Errorf("%s is not a beardrive project (run `bdrive init` there first)", folder)
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, " project: %s (%s)\n", safeField(proj.Volume, 120), proj.ID)
// Stat-guarded, because store.Open MkdirAlls and a read must not create a
// volume for a project that has never synced; unlocked, because store.Open
// takes no volume flock — a running daemon never blocks this.
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
return err
}
if !dirExists(vdir) {
fmt.Fprintln(out, " nothing synced yet — no local history to verify against")
return nil
}
st, err := store.Open(vdir)
if err != nil {
return err
}
var be remote.Backend
if checkRemote {
if proj.Remote == "" {
return errors.New("--remote needs a hub: this project is local only")
}
// remote.Open directly, not openSession: openSession goes through
// mustProject, which is the enrolling call this command must not make.
// It picks up the device token itself, so there is nothing to wire.
b, oerr := remote.Open(cmd.Context(), proj.Remote)
if oerr != nil {
// Unreachable hub is a warning, not a failure: the local verdict
// is still the answer, and this is the repo's "never break on the
// remote" posture.
fmt.Fprintf(out, " warning: hub unreachable, local check only (%s)\n", safeField(oerr.Error(), 200))
} else {
be = b
defer be.Close()
}
}
// Best-effort: without an identity the never-pushed count is simply
// empty, which is a worse answer than the others but not a reason to fail
// a read.
dev, _ := config.LoadDevice()
rep, err := syncer.Verify(cmd.Context(), folder, proj.Include, st, dev.ID, be)
if err != nil {
return err
}
return printVerify(out, rep)
}
func printVerify(out io.Writer, rep syncer.VerifyReport) error {
fmt.Fprintf(out, " checked: %s, %s hashed in %s\n",
plural(rep.Files, "file"), humanBytes(rep.Bytes), rep.Elapsed.Round(time.Millisecond))
if rep.RemoteErr != nil {
fmt.Fprintf(out, " warning: hub check incomplete, local result stands (%s)\n", safeField(rep.RemoteErr.Error(), 200))
}
missingNote := "the journal has it, this folder does not"
if rep.NotFetched > 0 {
missingNote = fmt.Sprintf("%d not fetched yet — run `bdrive sync`", rep.NotFetched)
}
cats := []struct {
name, note string
paths []string
}{
{"drifted", "on disk, but not the content the journal records", rep.Drifted},
{"never-pushed", "committed here, never reached the hub", rep.NeverPushed},
{"missing-locally", missingNote, rep.MissingLocally},
{"not-yet-scanned", "on disk, with no op anywhere yet", rep.NotYetScanned},
{"missing-on-hub", "synced here, absent from the hub", rep.MissingOnHub},
}
for _, c := range cats {
if len(c.paths) == 0 {
continue
}
fmt.Fprintf(out, "\n %s (%d) - %s\n", c.name, len(c.paths), c.note)
shown := c.paths
if len(shown) > verifyMaxList {
shown = shown[:verifyMaxList]
}
for _, p := range shown {
// Every path in missing-locally and missing-on-hub comes out of a
// PEER's journal — a string someone else chose. safeField, the
// same treatment `bdrive log` and `bdrive grep` give journal
// strings, or a lone CR repaints the row.
fmt.Fprintf(out, " %s\n", safeField(p, 160))
}
if len(c.paths) > len(shown) {
fmt.Fprintf(out, " ... and %d more\n", len(c.paths)-len(shown))
}
}
if n := rep.Problems(); n > 0 {
fmt.Fprintf(out, "\n %s. Run `bdrive sync` to reconcile, or `bdrive log <path>` for history.\n", plural(n, "problem"))
return errVerifyProblems
}
fmt.Fprintln(out, " OK - the folder matches the journal")
// Said out loud, not just documented: the journals replayed here are this
// device's local copies, so a clean verdict is about what was last pulled.
// Without this line the command overclaims exactly the thing it exists to
// prove.
fmt.Fprintln(out, " (this compares against what this device last pulled — run `bdrive sync` first for the hub's latest)")
return nil
}
+243
View File
@@ -0,0 +1,243 @@
package syncer
import (
"context"
"os"
"path/filepath"
"sort"
"time"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
)
// VerifyReport is what Verify found. Every slice holds mount-relative paths,
// sorted; an empty slice means that category is clean.
//
// A path can legitimately land in two categories at once — a file edited after
// its last scan and never pushed is both drifted and never-pushed — so the
// counts are independent and dedup happens only within a category.
type VerifyReport struct {
Files int // files hashed this run
Bytes int64 // bytes hashed this run
Elapsed time.Duration // how long the hashing took, so the cost is visible
// Drifted: on disk and in the journal state, but sha256(disk) != state.Blob.
Drifted []string
// NeverPushed: this device's own ops past SyncState.PushedOps — content
// the hub has never seen.
NeverPushed []string
// MissingLocally: a path the journal state holds and the local filter
// includes, that this folder does not have.
MissingLocally []string
// NotFetched is how many of MissingLocally are simply not downloaded yet
// (their blob is absent from the local store). That is the benign half of
// this category — materializeFile returns early on it and the next cycle
// fixes it — and without the distinction a freshly cloned device reads as
// broken.
NotFetched int
// NotYetScanned: a file on disk the filter syncs, with no op anywhere yet.
NotYetScanned []string
// MissingOnHub: content this device believes is synced that the hub does
// not hold. Only populated with a backend.
MissingOnHub []string
// RemoteErr is set when the remote leg could not finish. The local half of
// the report still stands — this is the repo's "never break on the remote"
// posture, the same one Result.Offline takes.
RemoteErr error
}
// Problems is how many findings the report holds, across every category. It is
// what decides `bdrive verify`'s exit status.
func (r VerifyReport) Problems() int {
return len(r.Drifted) + len(r.NeverPushed) + len(r.MissingLocally) +
len(r.NotYetScanned) + len(r.MissingOnHub)
}
// Verify compares the bytes in the working folder against the content the
// journal records for them, hashing every synced file.
//
// It is a pure read, the same contract as Drift and Explain: no Session, no
// volume lock, no ops, no journal write, no materialize — and with be == nil,
// no network either. `bdrive verify` is what someone runs to check a folder
// they already suspect; a version of it that repaired anything would destroy
// the evidence it was asked to describe.
//
// Unlike Drift, which compares size+mtime against the state cache, this hashes
// content — that is the whole point, and there is deliberately no fast path.
// A file whose bytes changed but whose size and mtime were restored is exactly
// what Drift cannot see.
//
// device is this device's ID, for the never-pushed count. be may be nil, which
// skips the hub leg entirely.
//
// Known limitation, and the command prints it: the journals AllOps replays are
// this device's LOCAL copies. Without a pull, this proves "the folder matches
// what I last pulled" — and with a backend, "and the hub still holds all of
// it". A teammate's newer op committed since the last cycle is invisible here.
func Verify(ctx context.Context, folder string, include []string, st *store.Store, device string, be remote.Backend) (VerifyReport, error) {
var rep VerifyReport
start := time.Now()
sync, err := st.LoadSync()
if err != nil {
return rep, err
}
// A fresh filter: addNestedMount mutates it during a walk, so this must
// never be shared with a live cycle. AcceptRules for the reason Explain
// documents — the scan applies Filter.SkipUp, and omitting it would
// disagree with the very next cycle.
filter, err := loadFilter(folder, include)
if err != nil {
return rep, err
}
filter.AcceptRules(sync.IgnoreAccepted)
ops, err := st.AllOps()
if err != nil {
return rep, err
}
// The identical object Cycle materializes from (syncer.go, the pull phase),
// so what this compares against is what the cycle would write.
state := journal.Replay(ops)
paths, err := SyncedFiles(folder, include, sync.IgnoreAccepted)
if err != nil {
return rep, err
}
seen := make(map[string]bool, len(paths))
for _, rel := range paths {
seen[rel] = true
want, known := state[rel]
if !known {
rep.NotYetScanned = append(rep.NotYetScanned, rel)
continue
}
abs := filepath.Join(folder, filepath.FromSlash(rel))
sum, err := hashFile(abs)
if err != nil {
continue // vanished or unreadable; skipped, never fatal, as everywhere in this walk
}
rep.Files++
if fi, err := os.Stat(abs); err == nil {
rep.Bytes += fi.Size()
}
if sum != want.Blob {
rep.Drifted = append(rep.Drifted, rel)
}
}
for rel, want := range state {
// The same guard materialize applies before it would write: a path the
// local filter excludes is LEGITIMATELY absent from disk, because the
// rules are applied symmetrically in scan and materialize. Without
// this, every project narrowed by `bdrive scope --only` would report
// every out-of-scope path as missing.
if seen[rel] || filter.Skip(rel) || neverSync(rel) {
continue
}
rep.MissingLocally = append(rep.MissingLocally, rel)
if !st.HasBlob(want.Blob) {
rep.NotFetched++
}
}
// The same arithmetic push and `bdrive status` use: our own ops past the
// push cursor are the ones the hub has never seen.
if myOps, err := st.DeviceOps(device); err == nil {
from := sync.PushedOps
if from > int64(len(myOps)) {
from = int64(len(myOps))
}
if from < 0 {
from = 0
}
dedup := map[string]bool{}
for _, op := range myOps[from:] {
if dedup[op.Path] {
continue
}
dedup[op.Path] = true
rep.NeverPushed = append(rep.NeverPushed, op.Path)
}
}
if be != nil {
verifyRemote(ctx, be, state, &rep)
}
for _, s := range [][]string{rep.Drifted, rep.NeverPushed, rep.MissingLocally, rep.NotYetScanned, rep.MissingOnHub} {
sort.Strings(s)
}
rep.Elapsed = time.Since(start)
return rep, nil
}
// verifyRemote asks the hub whether it still holds every distinct blob the
// journal state references — one existence check per blob, not per path.
func verifyRemote(ctx context.Context, be remote.Backend, state map[string]journal.FileState, rep *VerifyReport) {
byBlob := map[string][]string{}
size := map[string]int64{}
for rel, want := range state {
if want.Blob == "" {
continue
}
byBlob[want.Blob] = append(byBlob[want.Blob], rel)
if want.Size > size[want.Blob] {
size[want.Blob] = want.Size
}
}
blobs := make([]string, 0, len(byBlob))
for b := range byBlob {
blobs = append(blobs, b)
}
sort.Strings(blobs) // stable order, so a partial run after RemoteErr is reproducible
for _, b := range blobs {
ok, err := existsEither(ctx, be, b, size[b])
if err != nil {
// Stop the remote leg, keep the local report. Never a hard failure.
rep.RemoteErr = err
return
}
if !ok {
rep.MissingOnHub = append(rep.MissingOnHub, byBlob[b]...)
}
}
}
// existsEither asks for a blob under both key shapes it can live under.
//
// A file over chunkThreshold is pushed as content-defined chunks plus a
// manifest keyed by the FILE's own sha256 — manifests/<sha>, never
// blobs/<sha> (chunks.go). A check that only asked blobs/<sha> would report
// every large file as missing from the hub.
//
// The size only picks which key to try FIRST; it can never be a filter,
// because a large file legitimately lives under blobs/ in three ways: the
// browser upload path always writes blobs/<sha> at any size, pushChunked falls
// back to a whole-blob Put when the manifest key is refused, and anything
// pushed before delta sync existed is a whole blob regardless of size.
//
// Checking the manifest key alone is enough for a chunked file:
// "a manifest exists ⟹ its chunks exist" is enforced hub-side at ingest, not
// an honest-client convention, so there is no need to walk the chunk list.
func existsEither(ctx context.Context, be remote.Backend, blob string, size int64) (bool, error) {
keys := [2]string{"blobs/" + blob, "manifests/" + blob}
if size > chunkThreshold {
keys[0], keys[1] = keys[1], keys[0]
}
for _, k := range keys {
ok, err := be.Exists(ctx, k)
if err != nil {
return false, err
}
if ok {
return true, nil // short-circuit: the common case is one round trip
}
}
return false, nil
}
+301
View File
@@ -0,0 +1,301 @@
package syncer
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
)
// verifyLocal runs Verify against a device the way `bdrive verify` does with
// no --remote: the device's own store and ID, no backend.
func verifyLocal(t *testing.T, s *Session) VerifyReport {
t.Helper()
rep, err := Verify(context.Background(), s.Folder, nil, s.Store, s.Device.ID, nil)
if err != nil {
t.Fatal(err)
}
return rep
}
func verifyRemoteRun(t *testing.T, s *Session) VerifyReport {
t.Helper()
rep, err := Verify(context.Background(), s.Folder, nil, s.Store, s.Device.ID, s.Backend)
if err != nil {
t.Fatal(err)
}
if rep.RemoteErr != nil {
t.Fatalf("unexpected RemoteErr: %v", rep.RemoteErr)
}
return rep
}
func wantEmpty(t *testing.T, name string, got []string) {
t.Helper()
if len(got) != 0 {
t.Fatalf("%s = %v, want empty", name, got)
}
}
func wantOnly(t *testing.T, name string, got []string, want ...string) {
t.Helper()
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("%s = %v, want %v", name, got, want)
}
}
// TestVerifyCleanAfterConverge: two devices that have converged both report a
// fully empty verdict — nothing drifted, nothing pending, nothing missing.
func TestVerifyCleanAfterConverge(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, "doc.txt", "v1")
write(t, a.Folder, "sub/nested.md", "deep")
cycle(t, a)
cycle(t, b)
for _, d := range []*Session{a, b} {
rep := verifyLocal(t, d)
if rep.Problems() != 0 {
t.Fatalf("%s: Problems() = %d, want 0 (%+v)", d.Device.ID, rep.Problems(), rep)
}
if rep.Files != 2 {
t.Fatalf("%s: hashed %d file(s), want 2", d.Device.ID, rep.Files)
}
if rep.Bytes == 0 {
t.Fatalf("%s: Bytes should be > 0", d.Device.ID)
}
}
}
// TestVerifyCatchesRestoredMtime is the criterion Drift cannot meet: bytes
// changed, size and mtime put back. Drift compares size+mtime and sees
// nothing; Verify hashes and catches it.
func TestVerifyCatchesRestoredMtime(t *testing.T) {
a := newDevice(t, "deva", sharedRemote(t))
write(t, a.Folder, "doc.txt", "aaaa")
cycle(t, a)
abs := filepath.Join(a.Folder, "doc.txt")
fi, err := os.Stat(abs)
if err != nil {
t.Fatal(err)
}
// Same length, different bytes, original mtime restored.
if err := os.WriteFile(abs, []byte("bbbb"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(abs, fi.ModTime(), fi.ModTime()); err != nil {
t.Fatal(err)
}
rep := verifyLocal(t, a)
wantOnly(t, "Drifted", rep.Drifted, "doc.txt")
// And the whole point: the cheap check reports nothing.
added, modified, removed, err := Drift(a.Folder, nil, "", cacheOf(t, a))
if err != nil {
t.Fatal(err)
}
if added+modified+removed != 0 {
t.Fatalf("Drift saw %d/%d/%d changes; it is supposed to be blind to this", added, modified, removed)
}
}
// cacheOf is the materialization cache Drift compares against, for the mount
// the test session materializes into.
func cacheOf(t *testing.T, s *Session) map[string]store.CachedFile {
t.Helper()
c, err := s.Store.LoadCache(s.mountID())
if err != nil {
t.Fatal(err)
}
return c
}
// TestVerifyMissingLocally: a path the journal holds and this folder does not.
func TestVerifyMissingLocally(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, "keep.txt", "k")
write(t, a.Folder, "gone.txt", "g")
cycle(t, a)
cycle(t, b)
// Delete behind the daemon's back — no cycle, so no delete op.
if err := os.Remove(filepath.Join(b.Folder, "gone.txt")); err != nil {
t.Fatal(err)
}
rep := verifyLocal(t, b)
wantOnly(t, "MissingLocally", rep.MissingLocally, "gone.txt")
wantEmpty(t, "Drifted", rep.Drifted)
if rep.NotFetched != 0 {
t.Fatalf("NotFetched = %d, want 0 — the blob is right here", rep.NotFetched)
}
}
// TestVerifyIgnoredPathNotMissing is the --only false-alarm case: a path the
// LOCAL filter excludes is legitimately absent from disk, because the rules
// are applied symmetrically in scan and materialize.
func TestVerifyIgnoredPathNotMissing(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, "keep.txt", "k")
write(t, a.Folder, "secret/thing.txt", "s")
cycle(t, a)
cycle(t, b)
// B narrows its scope and drops the excluded path, the way materialize
// would on the next cycle.
if err := os.RemoveAll(filepath.Join(b.Folder, "secret")); err != nil {
t.Fatal(err)
}
rules := "secret/\n"
write(t, b.Folder, IgnoreFile, rules)
sync, err := b.Store.LoadSync()
if err != nil {
t.Fatal(err)
}
sync.IgnoreAccepted = rules
if err := b.Store.SaveSync(sync); err != nil {
t.Fatal(err)
}
rep := verifyLocal(t, b)
wantEmpty(t, "MissingLocally", rep.MissingLocally)
// Negative control: without the rule the very same folder DOES report it,
// so the assertion above is about the filter and not about the path
// having quietly fallen out of the journal state.
if err := os.Remove(filepath.Join(b.Folder, IgnoreFile)); err != nil {
t.Fatal(err)
}
sync.IgnoreAccepted = ""
if err := b.Store.SaveSync(sync); err != nil {
t.Fatal(err)
}
if rep := verifyLocal(t, b); len(rep.MissingLocally) != 1 || rep.MissingLocally[0] != "secret/thing.txt" {
t.Fatalf("without the rule MissingLocally = %v, want [secret/thing.txt]", rep.MissingLocally)
}
}
// TestVerifyNeverPushed: an offline cycle commits ops the hub never sees.
func TestVerifyNeverPushed(t *testing.T) {
a := newDevice(t, "deva", nil) // nil backend: nothing can be pushed
write(t, a.Folder, "notes/draft.md", "wip")
cycle(t, a)
rep := verifyLocal(t, a)
wantOnly(t, "NeverPushed", rep.NeverPushed, "notes/draft.md")
wantEmpty(t, "Drifted", rep.Drifted)
wantEmpty(t, "NotYetScanned", rep.NotYetScanned)
}
// TestVerifyNotYetScanned: a file on disk the filter syncs, with no op at all.
func TestVerifyNotYetScanned(t *testing.T) {
a := newDevice(t, "deva", sharedRemote(t))
write(t, a.Folder, "doc.txt", "v1")
cycle(t, a)
write(t, a.Folder, "fresh.txt", "brand new") // no cycle
rep := verifyLocal(t, a)
wantOnly(t, "NotYetScanned", rep.NotYetScanned, "fresh.txt")
wantEmpty(t, "Drifted", rep.Drifted)
wantEmpty(t, "MissingLocally", rep.MissingLocally)
}
// TestVerifyRemoteChunkedFile is the manifests/<sha> criterion: a file over
// chunkThreshold is pushed as chunks plus a manifest keyed by the file's own
// sha, never blobs/<sha>. A --remote check that asked only blobs/ would report
// every large file as missing from the hub.
func TestVerifyRemoteChunkedFile(t *testing.T) {
dir := t.TempDir()
a := newDevice(t, "deva", fileRemote(t, dir))
big := strings.Repeat("beardrive chunked payload — 64 bytes of filler here!!\n", (chunkThreshold/53)+200_000/53)
if len(big) <= chunkThreshold {
t.Fatalf("test payload is %d bytes, need > %d", len(big), chunkThreshold)
}
write(t, a.Folder, "video/demo.bin", big)
write(t, a.Folder, "small.txt", "tiny")
cycle(t, a)
// The test is only meaningful if the big file really took the chunked
// path: its content must be reachable ONLY under manifests/<sha>, so a
// check that asked blobs/<sha> alone would have to report it missing.
sum, err := hashFile(filepath.Join(a.Folder, "video/demo.bin"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "manifests", sum)); err != nil {
t.Fatalf("the >4MiB file was not chunked, so this test proves nothing: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "blobs", sum)); err == nil {
t.Fatal("the >4MiB file is also a whole blob, so this test proves nothing")
}
rep := verifyRemoteRun(t, a)
wantEmpty(t, "MissingOnHub", rep.MissingOnHub)
if rep.Problems() != 0 {
t.Fatalf("Problems() = %d, want 0 (%+v)", rep.Problems(), rep)
}
}
// TestVerifyRemoteMissingBlob: content this device believes is synced, gone
// from the hub's storage.
func TestVerifyRemoteMissingBlob(t *testing.T) {
dir := t.TempDir()
be := fileRemote(t, dir)
a := newDevice(t, "deva", be)
write(t, a.Folder, "doc.txt", "v1")
cycle(t, a)
if rep := verifyRemoteRun(t, a); rep.Problems() != 0 {
t.Fatalf("before removal: Problems() = %d, want 0 (%+v)", rep.Problems(), rep)
}
// Blow the blob out of the remote directory.
blobs := filepath.Join(dir, "blobs")
entries, err := os.ReadDir(blobs)
if err != nil {
t.Fatal(err)
}
removed := 0
for _, e := range entries {
if err := os.RemoveAll(filepath.Join(blobs, e.Name())); err != nil {
t.Fatal(err)
}
removed++
}
if removed == 0 {
t.Fatal("no blobs in the remote to remove")
}
rep := verifyRemoteRun(t, a)
wantOnly(t, "MissingOnHub", rep.MissingOnHub, "doc.txt")
// The local half is still clean — the bytes are on this disk.
wantEmpty(t, "Drifted", rep.Drifted)
if rep.Elapsed <= 0 {
t.Fatal("Elapsed should be measured")
}
}
// fileRemote is sharedRemote against a directory the test can reach into, so
// it can delete a blob out from under a converged device.
func fileRemote(t *testing.T, dir string) remote.Backend {
t.Helper()
be, err := remote.Open(context.Background(), "file://"+dir)
if err != nil {
t.Fatal(err)
}
return be
}
+83
View File
@@ -1113,3 +1113,86 @@ func TestCLIStatusReportsUnscannedWork(t *testing.T) {
t.Fatalf("drift did not clear after a sync:\n%s", out)
}
}
// TestCLIVerifyE2E drives `bdrive verify` against a real hub through the real
// binary: a clean project passes, an edit made behind the daemon's back is
// reported as drifted with a status-1 exit, --remote passes against the live
// hub, and --remote against a hub that has gone away still returns the local
// verdict rather than failing.
func TestCLIVerifyE2E(t *testing.T) {
e := newCLIEnv(t)
run := e.run
work := t.TempDir()
if err := os.WriteFile(filepath.Join(work, "index.md"), []byte("# Index\n"), 0o644); err != nil {
t.Fatal(err)
}
if out, err := run(work, "init", "--name", "verify-e2e", "--yes"); err != nil {
t.Fatalf("init: %v\n%s", err, out)
}
defer run(work, "stop", work)
if out, err := run(work, "sync"); err != nil {
t.Fatalf("sync: %v\n%s", err, out)
}
// Stop the daemon, or it scans the edit below out from under the test.
if out, err := run(work, "stop", work); err != nil {
t.Fatalf("stop: %v\n%s", err, out)
}
// --- Clean: exit 0, and it says so.
out, err := run(work, "verify")
if err != nil {
t.Fatalf("verify on a clean project should exit 0: %v\n%s", err, out)
}
if !strings.Contains(out, "OK - the folder matches the journal") {
t.Fatalf("clean verify did not report OK:\n%s", out)
}
// index.md plus the .bdriveignore init seeds — the point is that the cost
// is reported at all, not the exact count.
if !strings.Contains(out, "checked: 2 files, ") {
t.Fatalf("verify did not report what it hashed:\n%s", out)
}
// --- The hub still holds it: --remote is clean too.
out, err = run(work, "verify", "--remote")
if err != nil {
t.Fatalf("verify --remote on a synced project should exit 0: %v\n%s", err, out)
}
if strings.Contains(out, "missing-on-hub") {
t.Fatalf("verify --remote reported content missing from the hub:\n%s", out)
}
// --- Edited behind the daemon's back: drifted, exit 1.
if err := os.WriteFile(filepath.Join(work, "index.md"), []byte("# Index\n\nappended by hand\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err = run(work, "verify")
if err == nil {
t.Fatalf("verify should exit 1 when something drifted:\n%s", out)
}
if !strings.Contains(out, "drifted (1)") || !strings.Contains(out, "index.md") {
t.Fatalf("verify did not name the drifted file:\n%s", out)
}
// A status is not a stack trace: cobra's usage block must stay silenced.
if strings.Contains(out, "Usage:") {
t.Fatalf("verify printed a usage block for a findings exit:\n%s", out)
}
// --- Hub gone: the remote leg degrades to a warning, the local verdict
// still decides. Put the file back first so "local is clean" is the
// thing being asserted.
if err := os.WriteFile(filepath.Join(work, "index.md"), []byte("# Index\n"), 0o644); err != nil {
t.Fatal(err)
}
e.hub.Close()
out, err = run(work, "verify", "--remote")
if err != nil {
t.Fatalf("verify --remote against an unreachable hub must not hard-fail: %v\n%s", err, out)
}
if !strings.Contains(out, "warning:") {
t.Fatalf("verify --remote said nothing about the unreachable hub:\n%s", out)
}
if !strings.Contains(out, "OK - the folder matches the journal") {
t.Fatalf("verify --remote dropped the local verdict when the hub went away:\n%s", out)
}
}
@@ -19,6 +19,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| `bdrive scope --explain` | List every path in the folder, split into what syncs and what does not, with counts — the verifiable answer to "what leaves this machine". Pure read: no daemon, no lock, no network |
| `bdrive grep <pattern> [folder]` | Search the text **inside** the files a project syncs. `pattern` is a Go RE2 regexp, or a literal string with `-F`. `-i` ignores case, `-l` prints matching paths only, `-n` caps the lines printed (default 200, `0` = all). Pure read: no daemon, no lock, no network |
| `bdrive stale [folder]` | Find synced markdown that links to a file written **after** the doc itself — staleness by what moved, not by the calendar. `-l` prints outgrown paths only, `-n` caps the docs printed (default 50, `0` = all). Pure read: no daemon, no lock, no network. Exit status is 0 whether or not anything is stale |
| `bdrive verify [folder]` | Prove this folder matches the content the journal records — sha256 every synced file and compare. Reports `drifted`, `never-pushed`, `missing-locally`, `not-yet-scanned`, and with `--remote` also `missing-on-hub`. Pure read: no daemon, no lock, no ops, and without `--remote` no network. Exit status is 0 when every category is empty, 1 otherwise |
| `bdrive forget <path>...` | Stop syncing a path and remove it from the hub. Adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
| `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally |
| `bdrive share <file>` | Public URL for a synced file — links are per-file, so a folder is refused with a file inside it named instead. `--list`, `--revoke`, `--expires` (the hub's Share dialog can also set an expiry on an existing link). Refuses a file whose first 1 MiB holds credential-shaped strings — `--force` shares it anyway |
@@ -204,6 +205,67 @@ would invert here and fail on a clean project. Read heat, a badge on the hub's
file view, and injecting the flag into an agent's session context are not built
yet; this ships the signal.
### `bdrive verify` — prove this folder matches the hub
"Your folder is the same everywhere" is the claim. `bdrive verify` is the
receipt. It hashes every file the project syncs and compares each one against
the content the journal records for it.
This is what `bdrive status` cannot do. `status` counts pending ops and
unscanned changes, but it never reads a byte of content — so a file whose bytes
changed while its size and mtime stayed put is invisible to it. `verify`
hashes, which is the whole point: there is deliberately no size/mtime fast
path, and on a large project it is noticeably slower than `status`. It reports
what it hashed and how long it took for exactly that reason.
```sh
bdrive verify
# project: team-wiki (m-a3f9c1d2)
# checked: 184 files, 41.2 MB hashed in 912ms
# OK - the folder matches the journal
# (this compares against what this device last pulled — run `bdrive sync` first for the hub's latest)
bdrive verify --remote # also ask the hub whether it still holds the content
bdrive verify ~/wiki # a project other than the current folder
```
It reports five things:
| Category | Meaning |
| --- | --- |
| `drifted` | on disk, but not the content the journal records |
| `never-pushed` | committed here, never reached the hub |
| `missing-locally` | the journal has it, this folder does not |
| `not-yet-scanned` | on disk, with no op anywhere yet |
| `missing-on-hub` | synced here, absent from the hub — `--remote` only |
A path the local `.bdriveignore` excludes is **not** reported as missing. The
rules are applied symmetrically in scan and materialize, so its absence from
disk is correct — a project narrowed with `bdrive scope --only` stays quiet.
And when `missing-locally` is really just "not downloaded yet", the output says
so and points at `bdrive sync`.
`--remote` adds one existence check per **distinct blob**, and asks for both
`blobs/<sha>` and `manifests/<sha>`. Files over 4 MiB move as content-defined
chunks plus a manifest keyed by the file's own sha256, so a check that asked
only `blobs/` would call every large file missing from the hub. A manifest
existing implies its chunks do — the hub enforces that at ingest — so there is
no chunk-by-chunk walk. If the hub is unreachable, the remote half degrades to
a printed warning and the local verdict still stands.
**`verify` never repairs anything.** It is a pure read: no daemon, no lock, no
ops, no journal writes. Run `bdrive sync` to reconcile what it found, or
`bdrive log <path>` for a file's history.
**Exit status is 0 when every category is empty and 1 when any is not,** so it
composes as a pre-flight check in a script or a CI job.
One caveat the command prints for itself: the journals it replays are this
device's **local copies**. Without a pull it proves "this folder matches what I
last pulled" — and with `--remote`, "and the hub still holds all of it". A
teammate's newer op is invisible until the next cycle, so sync first if you
want the hub's latest.
### `bdrive forget` and `bdrive sync --prune` — cleaning up the hub
Adding a rule to `.bdriveignore` only stops *future* uploads. Anything that