Files
beardrive/internal/syncer/syncer.go
T
922886b949 fix(sync): a refused push stays refused, and says why (BEA-403) (#146)
A device whose journal push the hub 403s reported healthy sync between
every pair of remote passes, and never showed the hub's reason for the
refusal — so a user whose device was not registered to their account
re-ran `bdrive login` (which the message told them to), re-checked their
project permissions (write), and had nowhere left to look.

Two causes, both local to the client:

- Cycle recomputed st.Access from scratch at the end of every pass,
  including the daemon's cheap local-only ticks that never reach the hub.
  Three of those run between remote passes, so the daemon alternated
  "read-only on this project" / "access restored; syncing normally" every
  few seconds and `bdrive status` reported OK moments after a refused
  push. Now each leg records its own verdict — pull clears no-access, push
  records read-only or clears it — and a cycle that asked nothing leaves
  the last answer standing.
- The hub's own sentence was summarized into "read-only (pull only)",
  which describes the STATUS CODE. It is the only thing that tells a
  device-registration refusal from a project the user really is a reader
  on. It now rides in SyncState.AccessReason and Result.Reason(), printed
  by `bdrive sync`, `bdrive status` and the daemon log, and dropped unless
  it passes journal.SafeText — hub text reaching a terminal.

The hub's refusal also now names the upgrade: the binding is made by the
login request naming its device, which a CLI older than the gate does not
do, so "run `bdrive login`" alone sent that user in a circle. Hub and CLI
deploy separately, so the skew is the expected state right after the gate
ships.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:33:26 -07:00

1545 lines
60 KiB
Go

// Package syncer drives a volume's sync cycle:
//
// scan → commit local ops → pull peer journals → preserve conflicts →
// materialize merged state → push blobs + own journal
//
// Scanning always happens before pulling, so local edits are committed to the
// journal (and their content captured in the blob store) before any remote
// state can overwrite the working folder. Concurrent edits resolve
// deterministically last-writer-wins; the losing local version is preserved
// as a "<name>.bdrive-conflict-<device>-<time>" file that syncs like any other.
package syncer
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log"
"math"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
"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"
)
// pushConcurrency bounds how many blobs upload at once. The initial import of
// many files is latency-bound on serial round-trips, so uploading in parallel
// is the main speedup.
const pushConcurrency = 16
// Progress reports upload progress during a cycle's push phase, so the CLI can
// draw a bar. Total/TotalBytes are set once when the push starts; Done/Bytes
// climb as blobs finish. Nil OnProgress means no reporting (the daemon).
type Progress struct {
Done, Total int
Bytes, ToBytes int64
}
// Session ties a working folder to its volume store and (optionally) remote.
type Session struct {
Folder string
MountID string // the stable project mount id from .bdrive/config.json
Store *store.Store
Device config.Device
// Account is the signed-in user (from `bdrive login`); ops carry it so
// history shows who changed what. Zero on offline/no-auth setups —
// Device.Author remains the fallback identity.
Account config.Settings
// Note, when set, is stamped into every op this session commits — session
// context like "claude-code session <id>". Empty means fall back to the
// store's persisted session note (store.LoadNote), which lets a one-shot
// `bdrive sync --note` leave context that the daemon's later scans also
// stamp. Conflict-copy ops keep their own explanatory note.
Note string
// SessionID is the agent session every op this cycle commits is stamped
// with (journal.Op.Session). Set only by `bdrive sync --hook`, and
// deliberately NOT persisted the way Note is (store.SaveNote): the note
// is context that outlives the hook turn, the session id is an identity
// that must not be attached to changes the daemon commits on its own
// later. So a daemon scan after the hook turn carries the note and no
// session — the asymmetry is intended.
SessionID string
// Prune makes this cycle reconcile the hub against the shared ignore
// rules: every path the remote still holds that .bdriveignore (or a
// builtin never-sync rule) now excludes is journaled as a delete, so it
// leaves the hub while staying on disk on every device. Off by default —
// plain `bdrive sync` and the daemon never set it, because pruning must
// be a deliberate act, never a side effect of editing .bdriveignore.
Prune bool
Backend remote.Backend // nil = work offline
// OnProgress, when set, is called during push with upload progress. It may
// be invoked concurrently from upload workers, so it must be safe to call
// from multiple goroutines.
OnProgress func(Progress)
}
func (s *Session) mountID() string {
if s.MountID != "" {
return s.MountID
}
// Fallback for sessions built without a project (tests): key the state
// cache by the folder path.
sum := sha256.Sum256([]byte(s.Folder))
return hex.EncodeToString(sum[:])[:12]
}
// Result summarizes one sync cycle.
//
// Offline, ReadOnly, and NoAccess are three different answers and must not be
// conflated: offline means the hub could not be reached and everything should
// be retried; ReadOnly means it refused our push (we keep pulling, local ops
// stay journaled and unpushed); NoAccess means it refused our pull too, so the
// cycle does nothing at all and leaves the working folder alone. Regaining
// access self-heals on a later cycle with no manual step.
type Result struct {
LocalOps int // local changes committed to the journal
PulledOps int // ops received from other devices
Conflicts int // conflict copies created
Pruned int // paths removed from the hub by --prune (kept on disk)
Materialized int // files written/removed in the working folder
Pushed bool // own journal/blobs uploaded
// Offline reports that the remote leg of this cycle had a problem worth
// telling the user about — usually "unreachable", but also "the hub served
// bytes that are not their content address", which is the only signal that
// case ever produces. It is a REPORT, not a gate: a content-level problem
// with one object still lets this device push its own work (Pushed may be
// true alongside it), because otherwise one peer's journal line decides
// whether anyone else's edits ever leave their machine.
Offline bool
OfflineErr error
ReadOnly bool // the hub refused our push: pull-only from here
NoAccess bool // the hub refused our pull: sync paused, nothing touched
AccessErr error
}
// accessReason renders a hub refusal as the sentence a person can act on. The
// hub answers a device-registration 403 with what to DO about it, and every
// caller used to collapse that into "read-only (pull only)" — the one refusal
// that is not about project permissions at all, reported as if it were, so the
// user re-checked their access, saw `write`, and had nowhere left to look.
//
// The wrapper chain the CLI itself added ("forbidden: server: 403 Forbidden: ")
// is dropped; it tells a reader nothing the line does not already say.
//
// The remainder is the HUB's text landing in a log file, a terminal and an
// agent's context, so it passes journal.SafeText — the rule this repo already
// applies to every other peer-written string it renders — or it does not travel
// at all. Bounded for the same reason: this is persisted in sync.json and
// printed on one status line.
func accessReason(err error) string {
if err == nil {
return ""
}
msg := err.Error()
if _, rest, ok := strings.Cut(msg, "Forbidden: "); ok && rest != "" {
msg = rest
}
if len([]rune(msg)) > 300 || !journal.SafeText(msg) {
return ""
}
return msg
}
// Reason is the hub's own words for a ReadOnly or NoAccess answer, empty when
// the hub gave none. It is what the CLI prints under the summary line.
func (r *Result) Reason() string { return accessReason(r.AccessErr) }
func (r *Result) Activity() bool {
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Pruned > 0 || r.Materialized > 0
}
// The builtin exclusions (.bdrive — the mount's local identity, syncing it
// would let one device silently repoint another — and .git) are defined once
// in config, because the hub enforces the same set on the paths clients
// upload. Local aliases so the walk and the path checks below read plainly.
func ignoredDir(name string) bool { return config.ReservedDir(name) }
// maxLamport caps what this device's clock will absorb from a peer. Cycle
// raises st.Lamport to any value it pulls and scan increments it per local op,
// so one op carrying math.MaxInt64 wraps the clock negative and every op this
// device ever writes again sorts before everything it has already seen — a
// silent, permanent write lock installed by one line of JSON. A value this
// large is not a clock reading, so it is ignored rather than absorbed. Pulled
// ops are never rewritten: replay must agree between a device and its remote
// copy.
const maxLamport = int64(1) << 62
// absorbLamport advances the local clock to a peer's reading, ignoring absurd
// ones. tickLamport is the local increment, which stops at the ceiling rather
// than wrapping.
func absorbLamport(cur, peer int64) int64 {
if peer > cur && peer <= maxLamport {
return peer
}
return cur
}
// tickLamport is the local increment. The cap is on what this device ABSORBS,
// not on what it writes: a device that legitimately absorbed the ceiling must
// still be able to write an op that sorts after it, or the clock is frozen
// there forever and every later local edit falls through to Time — which the
// peer that sent the ceiling also chose. That is the same silent write lock
// the cap exists to prevent, reachable with the one value the cap accepts.
// Ticking past the ceiling is safe: it only ever climbs by one per local op,
// so only the wrap itself is refused.
func tickLamport(cur int64) int64 {
if cur == math.MaxInt64 {
return cur
}
return cur + 1
}
// Cycle runs one full scan/sync/materialize pass under the volume lock.
func (s *Session) Cycle(ctx context.Context) (*Result, error) {
unlock, err := s.Store.Lock()
if err != nil {
return nil, fmt.Errorf("lock volume: %w", err)
}
defer unlock()
res := &Result{}
cache, err := s.Store.LoadCache(s.mountID())
if err != nil {
return nil, fmt.Errorf("load state: %w", err)
}
st, err := s.Store.LoadSync()
if err != nil {
return nil, fmt.Errorf("load sync state: %w", err)
}
myOps, err := s.Store.DeviceOps(s.Device.ID)
if err != nil {
return nil, fmt.Errorf("read own journal: %w", err)
}
proj, _, err := config.LoadProject(s.Folder)
if err != nil {
return nil, err
}
filter, err := loadFilter(s.Folder, proj.Include)
if err != nil {
return nil, fmt.Errorf("load %s: %w", IgnoreFile, err)
}
// Accept the rules on disk as this device's own whenever they are not the
// copy a peer's version last wrote here — i.e. whenever somebody at this
// machine authored them (`bdrive init --only`, `bdrive scope`, an editor).
// Runs before the scan and before the pull, so the floor the scan applies
// is always the last rules this device agreed to. See Filter.SkipUp.
if cur, err := os.ReadFile(filepath.Join(s.Folder, IgnoreFile)); err == nil || os.IsNotExist(err) {
text := string(cur)
// Both fields are omitempty, so a SyncState written before they existed
// carries neither — and the accept test below then reads whatever is on
// disk as locally authored, including a peer's widening that landed one
// cycle earlier (scan runs before pull, so it always does). A device that
// has already synced is an upgrade, not a new joiner: seed the pair from
// what this mount is demonstrably syncing already (vouchedFloor) instead
// of taking the file's word for it.
if st.IgnoreAccepted == "" && st.IgnorePulled == "" && text != "" &&
(st.Lamport > 0 || st.PushedOps > 0) {
synced := make([]string, 0, len(cache))
for rel := range cache {
synced = append(synced, rel)
}
st.IgnoreAccepted, st.IgnorePulled = vouchedFloor(text, synced), text
} else if text != st.IgnorePulled {
st.IgnoreAccepted = text
}
}
filter.AcceptRules(st.IgnoreAccepted)
// 1. Scan the working folder and journal any local changes.
localOps, err := s.scan(cache, &st, int64(len(myOps)), filter)
if err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
if len(localOps) > 0 {
if err := s.Store.AppendOps(s.Device.ID, localOps); err != nil {
return nil, fmt.Errorf("append journal: %w", err)
}
myOps = append(myOps, localOps...)
res.LocalOps = len(localOps)
}
// 2. Pull journals + blobs from other devices.
//
// blocked is "the remote leg cannot continue this cycle" and is separate
// from res.Offline, which is what the user is TOLD. They used to be one
// flag, so any pull error withheld this device's own push — and one of
// those errors is "a blob's bytes are not its content address", which one
// peer produces at will by understating Op.Size on one journal line.
blocked := false
var pulled, gone []journal.Op
if s.Backend != nil {
pulled, gone, err = s.pull(ctx)
switch {
case err == nil:
if st.Access == store.AccessNone {
// The pull that was refused now succeeds, so read access is back.
// Whether writes are back is the push leg's to answer below.
st.Access, st.AccessReason = store.AccessOK, ""
}
case errors.Is(err, remote.ErrForbidden):
// Access to this project was revoked. Stop here: materializing a
// replay we can no longer refresh would look like the hub
// reverting the user's files. Nothing is pushed, nothing is
// deleted, and the next cycle re-checks.
res.NoAccess, res.AccessErr = true, err
st.Access, st.AccessReason = store.AccessNone, accessReason(err)
return res, s.finish(cache, st)
case errors.Is(err, errBlobContent):
// Reported — it is the only signal a device ever gets that its hub
// is serving bytes that are not what they are addressed as — but
// NOT blocking: one object's content says nothing about whether the
// hub is reachable, and treating it as such withheld this device's
// own journal and blobs for one integer in one peer's journal line.
// The path itself stays unwritten until real content shows up.
res.Offline = true
res.OfflineErr = err
default:
res.Offline = true
res.OfflineErr = err
blocked = true
}
res.PulledOps = len(pulled)
for _, op := range pulled {
st.Lamport = absorbLamport(st.Lamport, op.Lamport)
}
}
// 3. Preserve losing local edits as conflict copies.
if len(pulled) > 0 {
conflictOps, err := s.conflictCopies(myOps, st.PushedOps, pulled, &st)
if err != nil {
return nil, err
}
if len(conflictOps) > 0 {
if err := s.Store.AppendOps(s.Device.ID, conflictOps); err != nil {
return nil, fmt.Errorf("append conflict ops: %w", err)
}
myOps = append(myOps, conflictOps...)
res.Conflicts = len(conflictOps)
}
}
// 3b. Re-assert what a peer withdrew. An op this device already applied is
// a file on this disk, and a file only leaves a disk because somebody
// deleted it — so when a peer republishes its journal without an op we
// already put on disk, we restate that op in OUR journal, keeping its
// original clock so a genuinely later change still wins replay. The
// alternative (refusing the peer's update) leaves this device holding a
// state a first-time device can never reach, which is the divergence the
// byte-offset resume exists to prevent.
//
// It runs AFTER conflictCopies, not before it. A re-asserted op carries the
// withdrawn op's original — and therefore usually losing — lamport, so as a
// member of the unpushed set it was a losing local edit by construction, and
// step 3 did exactly what it exists to do: preserved it as a conflict copy.
// Net effect, with no local edit on this side at all: a peer made this
// device create, sign and push a file at a path that never existed in the
// project, holding content the peer chose.
if len(gone) > 0 {
readopt := make([]journal.Op, 0, len(gone))
for _, op := range gone {
if !s.stillHold(op, cache) {
continue // we cannot stand behind what this folder does not hold
}
if filter.Skip(op.Path) || neverSync(op.Path) || unsafeRel(op.Path) {
// The filter is applied symmetrically in scan and materialize
// precisely so a path this device refuses to write is also one
// it never publishes. This is a third publishing site and owes
// the same rule.
continue
}
op.Device, op.DeviceName, op.Author = s.Device.ID, s.Device.Name, s.Device.Author
op.Seq = int64(len(myOps)) + int64(len(readopt)) + 1
op.Note = reassertNote
readopt = append(readopt, op)
}
if len(readopt) > 0 {
if err := s.Store.AppendOps(s.Device.ID, readopt); err != nil {
return nil, fmt.Errorf("append re-asserted ops: %w", err)
}
myOps = append(myOps, readopt...)
res.LocalOps += len(readopt)
}
}
// 4. Materialize the merged state into the working folder.
all, err := s.Store.AllOps()
if err != nil {
return nil, fmt.Errorf("read journals: %w", err)
}
target := journal.Replay(all)
// The ignore rules sync like any other file, so a peer can receive the new
// .bdriveignore and the delete ops it justifies in the same batch. The
// filter was loaded at the top of the cycle, before the pull, so write the
// rules first and reload from them — otherwise materialize's delete loop
// runs against stale rules, its filter guard never fires, and it unlinks
// files that merely left sync scope.
if want, ok := target[IgnoreFile]; !ok {
// A peer DELETED the shared rules — the maximal widening there is, and
// the one shape the bookkeeping below cannot see, since it is written in
// terms of a file that still exists. materialize's delete loop unlinks
// the local copy either way; recording the deletion as PULLED is what
// stops the next cycle reading the now-absent file as locally authored
// and dropping the accepted floor with it.
if len(pulled) > 0 {
st.IgnorePulled = ""
}
} else if len(pulled) > 0 {
wrote, err := s.materializeFile(IgnoreFile, want, cache)
if err != nil {
log.Printf("beardrive: could not write %s this cycle: %v", IgnoreFile, err)
}
if wrote {
res.Materialized++
// The reload rebuilds the rules from the new file — and only the
// rules. Filter.nested is what walkFolder discovered during the
// scan at the top of this cycle: it marks subfolders that sync
// through their OWN project, with their own member list, so it is
// a project boundary rather than an ignore rule. A fresh filter's
// empty nested list would let this project's ops write into that
// one, where its daemon picks them up and pushes them on.
nested := filter.nested
if filter, err = loadFilter(s.Folder, proj.Include); err != nil {
return nil, fmt.Errorf("load %s: %w", IgnoreFile, err)
}
filter.nested = nested
// Remember the pulled text as pulled, NOT as accepted: these rules
// came from a peer, so they may narrow what this device uploads but
// never widen it until somebody here authors a change. The accepted
// floor carries over unchanged.
if cur, err := os.ReadFile(filepath.Join(s.Folder, IgnoreFile)); err == nil {
st.IgnorePulled = string(cur)
}
filter.AcceptRules(st.IgnoreAccepted)
}
// If the blob isn't fetched yet materializeFile skips it and the old
// rules stand: the usual retry-next-cycle posture, and the guard in
// materialize still protects the files either way.
}
// 4b. Prune: remove from the hub what the shared rules now exclude.
if s.Prune {
pruneOps, err := s.pruneOps(target, &st, int64(len(myOps)))
if err != nil {
return nil, err
}
if len(pruneOps) > 0 {
if err := s.Store.AppendOps(s.Device.ID, pruneOps); err != nil {
return nil, fmt.Errorf("append prune ops: %w", err)
}
myOps = append(myOps, pruneOps...)
res.Pruned = len(pruneOps)
}
}
n, err := s.materialize(target, cache, filter)
if err != nil {
return nil, fmt.Errorf("materialize: %w", err)
}
res.Materialized += n
// 5. Push our blobs and journal.
if s.Backend != nil && !blocked && int64(len(myOps)) > st.PushedOps {
switch err := s.push(ctx, myOps, &st); {
case err == nil:
res.Pushed = true
st.Access, st.AccessReason = store.AccessOK, ""
case errors.Is(err, remote.ErrForbidden):
// Read-only on this project: pull and materialize already ran, so
// pull-only is the steady state. Our own ops stay in the local
// journal — never pushed, never dropped. The push is still
// attempted once per remote interval (no hot loop, and a re-grant
// self-heals).
res.ReadOnly, res.AccessErr = true, err
st.Access, st.AccessReason = store.AccessReadOnly, accessReason(err)
default:
res.Offline = true
res.OfflineErr = err
blocked = true
}
}
// 6. Drain the agent read spool to the hub (read heatmap telemetry).
// Strictly best-effort: a failed report keeps the batch queued for the
// next cycle and never fails — or even marks offline — this one.
if rr, ok := s.Backend.(remote.ReadReporter); ok && !blocked {
if evs, err := s.Store.PendingReads(); err == nil && len(evs) > 0 {
reads := make([]remote.ReadEvent, len(evs))
for i, e := range evs {
reads[i] = remote.ReadEvent{Path: e.Path, Session: e.Session, Time: e.Time}
}
if rr.ReportReads(ctx, reads) == nil {
s.Store.ClearPendingReads()
}
}
}
// st.Access is NOT recomputed here. Only the leg that actually asked the hub
// knows how it answered, so each one records its own verdict above and a
// cycle that never asked leaves the last one standing. Resetting it to OK on
// every cycle meant the daemon's cheap local-only ticks — three of them
// between remote passes — each declared "access restored; syncing normally",
// so a device the hub was refusing alternated between the two log lines
// forever and `bdrive status` reported healthy sync moments after the push
// it had just refused.
if err := s.finish(cache, st); err != nil {
return nil, err
}
return res, nil
}
// finish persists the two pieces of state a cycle mutates. Saving the cache
// matters even on a cut-short cycle: the scan already journaled local edits,
// and dropping the cache would make the next scan journal them all again.
func (s *Session) finish(cache map[string]store.CachedFile, st store.SyncState) error {
if err := s.Store.SaveCache(s.mountID(), cache); err != nil {
return err
}
return s.Store.SaveSync(st)
}
// scan diffs the working folder against the state cache and returns ops for
// every local change, storing new content in the blob store. Filtered paths
// are neither journaled nor deleted: a path that becomes ignored is dropped
// from the cache without a delete op, so opting out locally never removes
// the file from other devices.
func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, seqBase int64, filter *Filter) ([]journal.Op, error) {
seen := make(map[string]bool, len(cache))
var ops []journal.Op
note := s.Note
if note == "" {
note = s.Store.LoadNote()
}
nextOp := func(kind, rel string) journal.Op {
st.Lamport = tickLamport(st.Lamport)
seqBase++
return journal.Op{
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
User: s.Account.Email, UserName: s.Account.Name,
Kind: kind, Path: rel, Note: note, Session: s.SessionID,
}
}
err := walkFolder(s.Folder, filter, func(p, rel string, d fs.DirEntry, v verdict) error {
if v != vSync {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
seen[rel] = true
size, mt := info.Size(), info.ModTime().UnixNano()
mode := uint32(info.Mode().Perm())
c, ok := cache[rel]
if ok && c.Size == size && c.MTimeNS == mt {
return nil // unchanged (cheap path)
}
sum, n, err := s.Store.PutBlobFile(p)
if err != nil {
return nil // file vanished or unreadable; next cycle
}
if ok && c.Blob == sum {
// content unchanged, just touched
c.Size, c.MTimeNS, c.Mode = n, mt, mode
cache[rel] = c
return nil
}
op := nextOp(journal.KindPut, rel)
op.Blob, op.Size, op.Mode = sum, n, mode
op.Mtime = info.ModTime().UTC()
ops = append(ops, op)
cache[rel] = store.CachedFile{Blob: sum, Size: n, Mode: mode, MTimeNS: mt}
return nil
})
if err != nil {
return nil, err
}
for rel := range cache {
if seen[rel] {
continue
}
// The walk only ever produces clean in-mount paths, but this pass
// mints an op from every cache key the walk did NOT see — and the
// cache is a JSON file in $BDRIVE_HOME, not something the walk wrote.
// Same rule the peer-op side applies, so this device never signs and
// pushes a path it would refuse from anyone else.
if unsafeRel(rel) || neverSync(rel) {
delete(cache, rel)
continue
}
if filter.Skip(rel) {
delete(cache, rel) // newly filtered, not deleted: stop tracking silently
continue
}
ops = append(ops, nextOp(journal.KindDelete, rel))
delete(cache, rel)
}
return ops, nil
}
// redefinesApplied reports whether have re-uses the (device, seq) of an op in
// applied for a DIFFERENT op. That slot is an op's identity within a journal —
// the one thing a peer cannot restate without contradicting itself — and
// contradicting it is how an already-applied op gets withdrawn while the op
// count, which is all the guard in pull can otherwise check, stays put.
//
// Sameness is exactly what journal.Less compares (the fields Replay reads), so
// two ops this calls equal fold to the same state; everything else on an Op is
// display only.
//
// A slot that is simply GONE is not a redefinition: a torn or corrupt line is
// the honest version of that, and pull's op-count guard is what covers it.
func redefinesApplied(have, applied []journal.Op) bool {
if len(applied) == 0 {
return false
}
ids := make(map[string]bool, len(have))
slots := make(map[string]bool, len(have))
for _, op := range have {
ids[opID(op)] = true
slots[opSlot(op)] = true
}
for _, op := range applied {
if slots[opSlot(op)] && !ids[opID(op)] {
return true
}
}
return false
}
func opSlot(op journal.Op) string { return fmt.Sprintf("%s\x00%d", op.Device, op.Seq) }
func opID(op journal.Op) string {
return fmt.Sprintf("%s\x00%d\x00%s\x00%s\x00%s\x00%s\x00%d\x00%d",
opSlot(op), op.Lamport, op.Time.UTC().Format(time.RFC3339Nano),
op.Kind, op.Path, op.Blob, op.Size, op.Mode)
}
// sizeGrowth is the headroom a bound gets over the size an object was
// declared at. Both bodies a device reads off the wire are bounded by what it
// was TOLD they were: the party serving the bytes must not also choose how
// many of them the daemon buffers (a journal body went straight into
// io.ReadAll, and a blob into an unbounded io.Copy in the volume's temp dir,
// with the hash check that would notice running after the copy — on a 3-second
// retry loop, forever). The slack is what makes the bound safe for the honest
// case: a journal legitimately grows between the LIST and the GET, and the
// next cycle picks up whatever did not fit.
const sizeGrowth = 1 << 20
func sizeBound(size int64) int64 {
if size <= 0 || size > math.MaxInt64-sizeGrowth {
return sizeGrowth
}
return size + sizeGrowth
}
// maxPullBytes is the absolute ceiling under sizeBound. sizeBound alone met its
// stated property only while the party serving the bytes was a PEER, whose
// numbers a separate hub had at least stored; when the peer IS the hub there is
// no second party at all, and one listing entry or one journal line sized the
// device's allocation and its disk. A journal is JSONL text (32 MiB is ~500k
// ops) and a blob this size is already far past what a synced project of notes
// and documents holds.
//
// It is a read CEILING, never an up-front refusal on the declared size: op.Size
// is a peer's integer with no relation to the object it names, so refusing on it
// let one peer line stop honest content from ever landing — the round-4 wedge
// class. An over-declared honest blob still reads to its real length and
// verifies; only bytes past the ceiling are refused.
//
// ponytail: an absolute constant, mirroring `bdrive import`'s maxImportBlob
// (256 << 20, raisable with --max-blob). A file larger than this does not
// materialize on receiving devices — if that becomes a real workload, this
// wants the same kind of knob, not a bigger constant.
const maxPullBytes = 32 << 20
func pullBound(size int64) int64 { return min(sizeBound(size), maxPullBytes) }
// maxPeerJournals caps how many peer journal files one project's listing may
// mint on this disk. See pull.
const maxPeerJournals = 512
// reassertNote marks an op this device restated on a peer's behalf (step 3b).
// It is the only thing that distinguishes such an op from a local edit once it
// is in this device's journal, and conflictCopies depends on that distinction.
const reassertNote = "re-asserted: the device that published it withdrew it"
// errBlobContent marks "a blob's bytes are not its content address" — a
// statement about ONE object, never about whether the hub is reachable.
// Conflating the two let one peer integer (an understated Op.Size truncates an
// honest read and fails the sha) put the cycle in Offline, which is the flag
// step 5 checks before pushing anything: the victim's own journal and blobs
// stayed on the victim's disk, for one journal PUT per cycle.
var errBlobContent = errors.New("blob content mismatch")
// safeDevice reports whether a device id off a remote listing may become a
// path in this volume's journal dir. journal.SafePath is the repo's rule for a
// path a stranger named; a device id is one segment of one, so it also may not
// contain a separator and is bounded by NAME_MAX.
func safeDevice(dev string) bool {
return dev != "" && len(dev) <= 200 && !strings.ContainsAny(dev, `/\`) && journal.SafePath(dev)
}
// sameJournalFile reports whether two journal paths name one file. os.SameFile
// is the only answer that survives a filesystem's own normalization (case on
// APFS/NTFS, unicode NFD on APFS) — string comparison is the check that let a
// hub address this device's own journal under another spelling.
func sameJournalFile(a, b string) bool {
fa, err := os.Stat(a)
if err != nil {
return false
}
fb, err := os.Stat(b)
if err != nil {
return false
}
return os.SameFile(fa, fb)
}
// stillHold reports whether this folder's own materialization currently stands
// behind op — the premise step 2b rests on ("an op this device already applied
// is a file on this disk"). The mount's state cache is the record of what THIS
// device wrote into THIS folder, so it is the only evidence that the op was
// applied here; the replayed state is not, since the withdrawal is exactly what
// changed it.
//
// A withdrawn DELETE is never re-asserted. The cache records what is here, never
// what left, so "this device applied that delete" and "this path never existed
// in the project" are the same observation — and re-asserting on it let a peer
// mint deletes of paths that never existed (round 7's inert-op primitive),
// withdraw them, and grow every teammate's append-only journal by that many ops
// for one journal PUT each. Withdrawing a delete instead resurrects the file,
// which only the op's own author can trigger and which they can reach anyway by
// re-putting the content.
func (s *Session) stillHold(op journal.Op, cache map[string]store.CachedFile) bool {
if op.Kind != journal.KindPut || op.Blob == "" {
return false
}
c, ok := cache[op.Path]
return ok && c.Blob == op.Blob && s.Store.HasBlob(op.Blob)
}
// withdrawn reports the ops in applied whose (device, seq) slot is no longer
// present in have at all — an op this device already put on disk that the
// peer's republished journal simply does not carry any more.
//
// It is not a redefinition (redefinesApplied) and not a shrink (the op count
// can grow at the same time), so neither guard sees it; a peer replaces one
// applied line with bytes Parse drops, appends two, and a file leaves every
// teammate's folder with no delete op and nothing in History. Refusing the
// update instead is not available: a device that synced before the rewrite
// would then hold a state a device syncing for the first time can never reach
// (TestSec_Pull_APeerCannotChooseWhichOpsEachDeviceSees), and the peer would
// be choosing the split again. So the receiver RE-ASSERTS what it applied, as
// its own op — the one journal it is allowed to write — which both keeps the
// file and republishes the fact to every other device.
func withdrawn(have, applied []journal.Op) []journal.Op {
if len(applied) == 0 {
return nil
}
slots := make(map[string]bool, len(have))
for _, op := range have {
slots[opSlot(op)] = true
}
var out []journal.Op
for _, op := range applied {
if !slots[opSlot(op)] {
out = append(out, op)
}
}
return out
}
// pull fetches journals that grew on the remote and any blobs we are missing
// for the new ops. It returns the ops we had not seen before, and any op a
// peer withdrew from a journal we had already applied (see withdrawn).
func (s *Session) pull(ctx context.Context) ([]journal.Op, []journal.Op, error) {
objs, err := s.Backend.List(ctx, "journal/")
if err != nil {
return nil, nil, err
}
// The listing is the remote's answer, and the remote may be a hostile hub:
// every key in it becomes a path in this volume's journal dir, and every
// journal file there is re-read by AllOps on every later cycle. Round 7
// capped the listing BODY; nothing capped the object COUNT, so one in-bounds
// listing minted ~200k local files. New journals are admitted up to a
// ceiling; existing ones always keep updating, so a real project's peers are
// never starved by a flood.
//
// ponytail: 512 peer journals per project. A project with more devices than
// that needs a paginated/authenticated peer list, not a bigger number.
newJournals := maxPeerJournals
if ents, err := os.ReadDir(filepath.Join(s.Store.Dir(), "journal")); err == nil {
newJournals -= len(ents)
}
own := s.Store.JournalPath(s.Device.ID)
var newOps, gone []journal.Op
for _, o := range objs {
name := strings.TrimPrefix(o.Key, "journal/")
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, "/") {
continue
}
dev := strings.TrimSuffix(name, ".jsonl")
if !safeDevice(dev) {
// The hub picks these names and store.JournalPath validates
// nothing. A name the OS cannot open (a NUL, a control byte, 300
// bytes) is not a peer; it used to be a path this loop then failed
// on, hiding every peer listed behind it.
continue
}
lp := s.Store.JournalPath(dev)
// Never this device's own journal — the one object it is the sole
// writer of. An exact string compare is not the test: on a
// case-insensitive filesystem (APFS and NTFS by default) "DEVA.jsonl"
// is a different key and the SAME FILE, so the hub got to replace this
// device's log and push it back as its own. The skip is on the FILE,
// which is what the invariant is about.
if strings.EqualFold(dev, s.Device.ID) || sameJournalFile(lp, own) {
continue
}
var localSize int64
isNew := true
if fi, err := os.Stat(lp); err == nil {
localSize, isNew = fi.Size(), false
}
if o.Size <= localSize && localSize > 0 {
continue
}
if isNew {
if newJournals <= 0 {
continue
}
newJournals--
}
rc, err := s.Backend.Get(ctx, o.Key)
if err != nil {
return newOps, gone, err
}
data, err := io.ReadAll(io.LimitReader(rc, pullBound(o.Size)))
rc.Close()
if err != nil {
return newOps, gone, err
}
// Resume from a BYTE offset, never from an op count. A peer owns its
// journal object and can rewrite it, and Parse drops a line it cannot
// decode — so "skip the first len(prev) ops" let the peer choose how
// far each device's cursor jumped: replace one already-counted line
// with junk and every appended op shifts down by one, so a device that
// synced earlier silently never sees it while a first-time device
// does. Two devices, one journal, permanently different states.
//
// What we hold locally is the exact bytes we last accepted. If the
// object still extends them, the new ops are what the extension parses
// to; if it does not, the peer rewrote its log and every op in it is
// treated as new. Re-applying ops is idempotent (Replay is a fold), so
// the rewritten case is only ever slow, never divergent.
local, err := os.ReadFile(lp)
if err != nil && !os.IsNotExist(err) {
// One unreadable local copy is one peer's problem, not every
// peer's: returning here abandoned every journal this loop had not
// reached yet, in an order the hub chooses, and reported it to the
// user as "offline".
log.Printf("beardrive: skipping journal %s this cycle: %v", dev, err)
continue
}
if bytes.Equal(local, data) {
continue
}
// Resume only at a COMPLETE LINE. "Append-only" is the peer's claim
// about its own object: publish a stage cut mid-line and both the size
// gate and HasPrefix still pass, but the offset lands inside an op's
// JSON, Parse drops the fragment, and the local copy is then
// overwritten with the full object — so the next resume starts past an
// op this device never applied, while every other device applies it.
// The peer picks the split by choosing where to cut.
accepted := local
if i := bytes.LastIndexByte(accepted, '\n'); i >= 0 {
accepted = accepted[:i+1]
} else {
accepted = nil
}
// An op we already applied must not be REDEFINED: if the object still
// carries the (device, seq) slot we applied, it has to still be the
// same op. This is an IDENTITY check, and it is what the op-count
// guard below cannot do — a peer replaces an applied op's line with a
// decodable but inert one (a delete of a path that never existed), the
// count is preserved, and a file leaves every teammate's folder with no
// delete op, nothing in the journal and nothing in History.
//
// It runs BEFORE the resume switch because it is needed on both arms:
// `accepted` is trimmed to the last newline, but Parse does not require
// a trailing newline, so an op published unterminated is APPLIED while
// sitting outside the prefix HasPrefix protects — and a rewrite of that
// op then looks like an ordinary append.
applied, _ := journal.Parse(local)
all, aerr := journal.Parse(data)
if aerr != nil || redefinesApplied(all, applied) {
continue
}
tail := data
switch {
case len(accepted) > 0 && bytes.HasPrefix(data, accepted):
tail = data[len(accepted):]
case len(accepted) > 0:
// The peer rewrote its log instead of appending to it. Re-reading
// it whole is fine for ordering (Replay is a fold) but it must not
// UNDO: replacing an already-applied op's line with a longer
// undecodable one grows the object and drops the op.
prev, perr := journal.Parse(accepted)
if perr != nil || len(all) < len(prev) {
continue
}
}
fresh, err := journal.Parse(tail)
if err != nil {
continue // corrupt remote journal; ignore rather than break sync
}
if err := store.WriteFileAtomic(lp, data, 0o644); err != nil {
return newOps, gone, err
}
gone = append(gone, withdrawn(all, applied)...)
newOps = append(newOps, fresh...)
}
// Fetch content for new ops. Blobs are uploaded before journals on push,
// so anything referenced should exist — but Op.Blob is a string a peer
// chose, so "missing" is a case this loop has to survive rather than a
// contradiction. A blob that cannot be fetched is left unfetched:
// materializeFile skips a path whose content is not in the store yet and
// the next cycle retries, which is this package's posture for everything
// transient. Abandoning the loop instead meant one op naming a blob that
// was never pushed stopped every complete op behind it from ever landing.
var bad error
for _, op := range newOps {
if op.Kind != journal.KindPut || op.Blob == "" || s.Store.HasBlob(op.Blob) {
continue
}
rc, err := s.Backend.Get(ctx, "blobs/"+op.Blob)
if err != nil {
continue
}
sum, _, err := s.Store.PutBlobReader(io.LimitReader(rc, pullBound(op.Size)))
rc.Close()
if err != nil {
return newOps, gone, err
}
if sum != op.Blob {
// Report it, but do NOT abandon the batch. Content addressing
// already protects the disk (PutBlobReader files bytes under their
// COMPUTED hash, so HasBlob(op.Blob) stays false and materializeFile
// keeps skipping the path); the check earns its place as the only
// SIGNAL that a hub is serving bytes that are not what they are
// addressed as, since otherwise the case is indistinguishable from
// "not uploaded yet".
//
// Returning here made that signal a weapon. op.Size bounds the read,
// and op.Size is a field the PEER wrote with no relation to the
// object it names — so declaring 1 byte for a real 3 MiB blob
// truncated an honest read, failed the sha, and dropped every blob
// queued BEHIND it in the same batch. The journal is on local disk
// by then, so the next cycle yields no new ops and the loop is never
// re-entered: one integer in one journal line decided which of a
// peer's files each teammate never received. The mismatch is now
// remembered and returned once the rest of the batch has been
// fetched, so it still lands in res.Offline/OfflineErr.
if bad == nil {
bad = fmt.Errorf("%w: blob %s corrupt on remote (got %s)", errBlobContent, shortSha(op.Blob), shortSha(sum))
}
continue
}
}
return newOps, gone, bad
}
// shortSha trims a blob string for a message. Op.Blob is arbitrary JSON off a
// peer's journal, not necessarily 64 hex characters, so slicing it directly
// panicked the daemon on every device that pulled the line.
func shortSha(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
// conflictCopies detects paths edited concurrently — we hold a not-yet-pushed
// op and just pulled a competing op for the same path. Last-writer-wins
// resolves the path itself deterministically; here the device that observed
// the concurrency preserves the losing version (ours or the pulled one) as a
// conflict-copy file so no content is silently dropped.
// It runs BEFORE the re-assertion step on purpose: a re-asserted op is not this
// device's edit — it carries the withdrawn op's original (and therefore usually
// losing) lamport — so having it in myOps here made it a losing local unpushed
// op by construction, and a peer with no collision at all could make a victim
// author, sign and push a conflict copy at a path that never existed, holding
// content the peer chose.
func (s *Session) conflictCopies(myOps []journal.Op, pushed int64, pulled []journal.Op, st *store.SyncState) ([]journal.Op, error) {
if pushed > int64(len(myOps)) {
pushed = int64(len(myOps))
}
unpushed := map[string]journal.Op{}
for _, op := range myOps[pushed:] {
if op.Note == reassertNote {
// A re-asserted op is not an edit this device made — it restates a
// peer's op that the peer withdrew, carrying that op's original
// (and therefore usually losing) clock. Round 9 kept it out of the
// unpushed set by ORDERING (re-assert after this step), which holds
// for exactly one cycle: `pushed` only advances on a successful
// push, and a read-only member is the documented steady state where
// local ops stay journaled and unpushed forever. So it came back
// into this set on every later cycle, and two withdrawals made the
// victim author and journal peer-chosen content at a new path.
// Marked, not ordered.
delete(unpushed, op.Path)
continue
}
unpushed[op.Path] = op // latest local op per path
}
pulledLatest := map[string]journal.Op{}
for _, op := range pulled {
if _, ok := unpushed[op.Path]; !ok {
continue
}
if prev, ok := pulledLatest[op.Path]; !ok || journal.Less(prev, op) {
pulledLatest[op.Path] = op
}
}
if len(pulledLatest) == 0 {
return nil, nil
}
all, err := s.Store.AllOps()
if err != nil {
return nil, err
}
state := journal.Replay(all)
seqBase := int64(len(myOps))
var out []journal.Op
for p, theirs := range pulledLatest {
mine := unpushed[p]
cur, exists := state[p]
mineWon := (mine.Kind == journal.KindPut && exists && cur.Blob == mine.Blob) ||
(mine.Kind == journal.KindDelete && !exists)
loser := mine
if mineWon {
loser = theirs
}
if loser.Kind != journal.KindPut || loser.Blob == "" {
continue // a lost delete needs no preservation
}
if exists && cur.Blob == loser.Blob {
continue // identical content; nothing actually lost
}
if !s.Store.HasBlob(loser.Blob) {
continue // content unavailable (partial pull); skip rather than fail
}
st.Lamport = tickLamport(st.Lamport)
seqBase++
out = append(out, journal.Op{
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
User: s.Account.Email, UserName: s.Account.Name,
Kind: journal.KindPut, Path: conflictName(p, loser.DeviceName, loser.Time),
Blob: loser.Blob, Size: loser.Size, Mode: loser.Mode,
Note: "conflict copy of " + p,
})
}
return out, nil
}
// conflictName builds the copy's name. Both variable parts are bounded: the
// loser's DeviceName is an unvalidated string off a peer's journal, and the
// result has to be a name the filesystem accepts (NAME_MAX is 255 everywhere
// beardrive runs). An unwritable name is worse than an ugly one — the op is
// already in this device's own journal by the time the write is attempted, so
// it would replay and fail on every cycle from then on, triggered by one
// ordinary concurrent edit.
func conflictName(p, deviceName string, t time.Time) string {
suffix := ".bdrive-conflict-" + clip(sanitize(deviceName), 32) + "-" + t.UTC().Format("20060102T150405Z")
dir, base := path.Split(p)
return dir + clip(base, 255-len(suffix)) + suffix
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
func sanitize(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
default:
return '-'
}
}, s)
}
// materialize applies the merged state to the working folder, never
// clobbering files that changed since the scan earlier in this cycle.
// Filtered paths are not written: other devices' files that match the local
// ignore/include rules simply don't appear here.
// A path that cannot be written is skipped, not fatal. Op.Path is a peer's
// string and the working folder is a real filesystem: a NUL byte, a 400-byte
// segment, a put for "docs/child.md" after a put for "docs", or a directory in
// the way are all ordinary refusals from the kernel. Failing the cycle on one
// of them wedged the device permanently — the op stays in the pulled journal,
// so every later cycle replayed it and died at the same line, and Cycle
// returns before finish() so the state cache was never saved either.
func (s *Session) materialize(target map[string]journal.FileState, cache map[string]store.CachedFile, filter *Filter) (int, error) {
changed, skipped := 0, 0
var firstErr error
skip := func(err error) {
skipped++
if firstErr == nil {
firstErr = err
}
}
defer func() {
if skipped > 0 {
log.Printf("beardrive: %d path(s) could not be written this cycle (first: %v)", skipped, firstErr)
}
}()
for rel, want := range target {
// neverSync as well as the ignore filter: the builtin exclusions are
// what keep .bdrive/ and .git/ off this device's disk, and an op
// naming one arrives from a peer's journal — where the scan-side
// check never ran. Writing it would let one device repoint another's
// mount (or drop a git hook that runs on the next commit).
if filter.Skip(rel) || neverSync(rel) {
continue
}
wrote, err := s.materializeFile(rel, want, cache)
if err != nil {
skip(err)
continue
}
if wrote {
changed++
}
}
for rel, c := range cache {
if _, ok := target[rel]; ok {
continue
}
// The write loop above got unsafeRel, neverSync and UnderRoot in round
// 4; this loop got none of them, and it ends in os.Remove.
if unsafeRel(rel) || neverSync(rel) {
delete(cache, rel)
continue
}
if filter.Skip(rel) {
// The path left sync scope rather than being deleted — someone
// ignored it, or `--prune` removed it from the hub. Stop tracking
// it; the file itself is ours to keep. Without this guard a prune
// (or any delete op for a now-filtered path) unlinks every peer's
// local copy, which is the data loss the feature exists to avoid.
delete(cache, rel)
continue
}
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
if !store.UnderRoot(s.Folder, abs) {
delete(cache, rel)
continue
}
if fi, err := os.Stat(abs); err == nil {
if fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS {
continue // dirty; do not delete fresh local edits
}
if err := os.Remove(abs); err != nil {
skip(err)
continue
}
pruneEmptyDirs(s.Folder, filepath.Dir(abs))
// Tell the next agent turn what vanished under it. Best-effort:
// a spool failure must never fail a cycle. Only the branch that
// actually unlinked a file logs — the paths below it were already
// gone locally, so nothing changed under the agent.
_ = s.Store.LogInbound(rel, true)
}
delete(cache, rel)
changed++
}
return changed, nil
}
// materializeFile writes one path of the merged state into the working
// folder, reporting whether it wrote. It never clobbers a file that changed
// since the scan earlier in this cycle. Split out of materialize so the cycle
// can land .bdriveignore on its own, before the rules are needed.
func (s *Session) materializeFile(rel string, want journal.FileState, cache map[string]store.CachedFile) (bool, error) {
want.Mode = safeMode(want.Mode) // before the cache compare, or every cycle rewrites
c, ok := cache[rel]
if ok && c.Blob == want.Blob && c.Mode == want.Mode {
return false, nil
}
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
if fi, err := os.Stat(abs); err == nil {
if ok && (fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS) {
return false, nil // dirty: changed mid-cycle, next scan commits it
}
if !ok {
// Untracked file already at this path: adopt if identical,
// otherwise leave it for the next scan to journal.
sum, err := hashFile(abs)
if err != nil || sum != want.Blob {
return false, nil
}
}
}
if !s.Store.HasBlob(want.Blob) {
return false, nil // content not fetched yet; retry next cycle
}
if err := s.writeFile(abs, want); err != nil {
return false, fmt.Errorf("write %s: %w", rel, err)
}
fi, err := os.Stat(abs)
if err != nil {
return false, err
}
cache[rel] = store.CachedFile{Blob: want.Blob, Size: fi.Size(), Mode: want.Mode, MTimeNS: fi.ModTime().UnixNano()}
// Spool it for the next agent turn ("changed since your last turn"). Only
// peer content reaches here — a local edit is journaled by the scan and
// lands in the cache, so the compare above short-circuits before any
// write. Best-effort: a spool failure must never fail a cycle.
//
// Known trade: the first cycle on a fresh mount materializes the whole
// project, so that one turn's list is "everything" — bounded by the
// hook's render cap, and cheaper than special-casing an empty cache.
_ = s.Store.LogInbound(rel, false)
return true, nil
}
// pruneOps journals a delete for every path the hub still holds that the
// shared rules now exclude, and drops it from target so this cycle does not
// write it back. Peers keep their copies: the delete arrives alongside the
// rules that explain it, and materialize's filter guard turns it into
// "stop tracking" rather than "unlink".
//
// It reconciles against the replayed remote state, not the local cache. A
// path filtered out in some earlier cycle was dropped from the cache back
// then and is invisible locally today — which is exactly the leak --prune
// exists to clean up.
//
// The rules are deliberately ignore-only. .bdriveignore syncs, so every
// device agrees on it; the include list lives in this device's own
// .bdrive/config.json and does not sync. Never reuse the cycle's main filter
// here: a device with a legacy include-list scope would delete files a
// whole-folder teammate legitimately syncs.
func (s *Session) pruneOps(target map[string]journal.FileState, st *store.SyncState, seqBase int64) ([]journal.Op, error) {
shared, err := loadFilter(s.Folder, nil) // nil include: shared rules only
if err != nil {
return nil, fmt.Errorf("load %s: %w", IgnoreFile, err)
}
// The `!` refusal has to be made against the rules the prune is about to
// APPLY. The CLI's pruneSafe reads .bdriveignore before the cycle; the
// pull then materializes whatever version a peer pushed, and this reads it
// again — two reads of two different files, so a teammate running `bdrive
// scope`/`--only` (which writes exactly these rules) turned a cleared
// prune into a hub-wide delete of everything outside their scope. No
// malice required. The CLI check stays, as a nicer early error.
if shared.Negated() {
return nil, nil
}
var paths []string
for rel := range target {
if shared.Skip(rel) || neverSync(rel) {
paths = append(paths, rel)
}
}
sort.Strings(paths) // map order is random; keep the journal reproducible
ops := make([]journal.Op, 0, len(paths))
for _, rel := range paths {
st.Lamport = tickLamport(st.Lamport)
seqBase++
ops = append(ops, journal.Op{
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
User: s.Account.Email, UserName: s.Account.Name,
Kind: journal.KindDelete, Path: rel,
Note: "pruned: excluded by " + IgnoreFile,
})
delete(target, rel)
}
return ops, nil
}
// neverSync reports whether a path is one the scan walk never uploads at all
// — the builtin exclusions, which prune treats exactly like ignore rules — or
// one no journal may name at all (see unsafeRel). Every path that reaches the
// working folder routes through here.
//
// One spelling, config.ReservedPath, rather than a per-segment loop of its
// parts: the builtin exclusions grew a whole-PATH rule (an agent's
// project-level hook config, config.AgentHookConfig) that "a reserved dir plus
// a reserved base name" cannot express, and a second copy of the rule is a copy
// that will miss the next one.
func neverSync(rel string) bool {
return unsafeRel(rel) || config.ReservedPath(rel)
}
// unsafeRel reports whether an op's Path escapes the mount root. scan only
// ever produces clean relative paths, but Op.Path is arbitrary JSON off a
// peer's journal and materialize resolves it with filepath.Join(s.Folder, …),
// which walks above the root without complaint — one pushed line would reach
// ~/.ssh/authorized_keys on every teammate's machine.
//
// The rule itself is journal.SafePath, shared with the hub's two ingest doors:
// this spelling used to be its own copy and was the one MISSING the
// control-character clause, so the /store/* journal door accepted paths
// /upload/commit answers 400 to.
func unsafeRel(rel string) bool { return !journal.SafePath(rel) }
// safeMode is the only mode materialize will apply. scan records
// info.Mode().Perm(), but Op.Mode is a raw uint32 off the wire and
// fs.ModeSetuid/ModeSetgid live in that same word — os.Chmod would turn a
// peer's op into a setuid binary in every teammate's folder. Group/other write
// goes too: a synced file is never a drop box for other users on the machine.
func safeMode(m uint32) uint32 { return m & 0o777 &^ 0o022 }
func (s *Session) writeFile(abs string, want journal.FileState) error {
// unsafeRel judged the path's SPELLING; this is the same boundary on disk.
// MkdirAll, CreateTemp and Rename all follow symlinks, so a directory
// inside the mount that is a symlink makes "docs/x.md" a perfectly clean
// relative path landing outside the mount — and walkFolder refuses to
// descend into one, so such a directory is a one-way door: it takes peer
// writes and never reports them. Checked before MkdirAll, so a refused op
// does not even build the parent chain on the far side.
if !store.UnderRoot(s.Folder, abs) {
return fmt.Errorf("resolves outside the mount root")
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
src, err := s.Store.OpenBlob(want.Blob)
if err != nil {
return err
}
defer src.Close()
tmp, err := os.CreateTemp(filepath.Dir(abs), ".bdrive-tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
mode := os.FileMode(want.Mode)
if mode == 0 {
mode = 0o644
}
if err := os.Chmod(tmp.Name(), mode); err != nil {
return err
}
return os.Rename(tmp.Name(), abs)
}
// push uploads blobs referenced by unpushed ops, then the journal itself.
// Blob-before-journal ordering means peers never see an op whose content is
// missing.
func (s *Session) push(ctx context.Context, myOps []journal.Op, st *store.SyncState) error {
if st.PushedOps > int64(len(myOps)) {
st.PushedOps = int64(len(myOps))
}
// Collect the unique, not-yet-pushed blobs to upload (deduped by content
// hash). The backend's Put is idempotent and already skips content that's
// present remotely (the hub reports it during signing), so we don't pay a
// separate existence round-trip per blob.
seen := map[string]bool{}
type blobJob struct {
blob string
size int64
}
var jobs []blobJob
var totalBytes int64
for _, op := range myOps[st.PushedOps:] {
if op.Kind != journal.KindPut || op.Blob == "" || seen[op.Blob] {
continue
}
seen[op.Blob] = true
jobs = append(jobs, blobJob{op.Blob, op.Size})
totalBytes += op.Size
}
var done, bytesDone int64
report := func() {
if s.OnProgress != nil {
s.OnProgress(Progress{
Done: int(atomic.LoadInt64(&done)), Total: len(jobs),
Bytes: atomic.LoadInt64(&bytesDone), ToBytes: totalBytes,
})
}
}
report() // announce the total up front (0 / N)
// Upload blobs in parallel — the initial import is bound on serial
// round-trips, not bandwidth, so concurrency is the win.
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(pushConcurrency)
for _, j := range jobs {
g.Go(func() error {
f, err := s.Store.OpenBlob(j.blob)
if err != nil {
return err
}
fi, err := f.Stat()
if err != nil {
f.Close()
return err
}
err = s.Backend.Put(gctx, "blobs/"+j.blob, f, fi.Size())
f.Close()
if err != nil {
return err
}
atomic.AddInt64(&done, 1)
atomic.AddInt64(&bytesDone, fi.Size())
report()
return nil
})
}
if err := g.Wait(); err != nil {
return err
}
jp := s.Store.JournalPath(s.Device.ID)
f, err := os.Open(jp)
if err != nil {
return err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return err
}
if err := s.Backend.Put(ctx, "journal/"+s.Device.ID+".jsonl", f, fi.Size()); err != nil {
return err
}
st.PushedOps = int64(len(myOps))
return nil
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func pruneEmptyDirs(root, dir string) {
root = filepath.Clean(root)
for {
dir = filepath.Clean(dir)
if dir == root || !strings.HasPrefix(dir, root+string(filepath.Separator)) {
return
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) > 0 {
return
}
if err := os.Remove(dir); err != nil {
return
}
dir = filepath.Dir(dir)
}
}
// DisplayTime is the timestamp to show a human for an op: when the file was
// written if we know it, otherwise when the op was committed. Ops written
// before Op.Mtime existed, and deletes (no file left to stat), fall back.
func DisplayTime(op journal.Op) time.Time {
// Clamped to the moment the op was journaled. Mtime comes off the
// filesystem, so a peer chooses it: an op stamped in the year 9999 sits
// above every real entry in `bdrive log` forever, and a handful of them
// pushes the genuine history off a screen that prints 50 rows. Lagging
// Time is legitimate (an old file, journaled today); leading it is not a
// write time, it is a sort key someone picked.
//
// Op.Time is no more verified than Op.Mtime — same JSON line, same peer —
// so bounding one by the other only moves the value a field to the left.
// The one clock a peer does not own is this machine's: a stamp later than
// now is not a write time at all, and an op we cannot date does not get to
// outrank the changes we can. It sorts last rather than first, which is
// the direction that cannot be aimed.
now := time.Now()
if !op.Mtime.IsZero() && !op.Mtime.After(op.Time) && !op.Mtime.After(now) {
return op.Mtime
}
if op.Time.After(now) {
return time.Time{}
}
return op.Time
}
// SortForDisplay orders ops newest-first by DisplayTime — the timestamp the
// user actually sees, so the list reads as a timeline. Ties fall back to
// reversed journal.Less to stay deterministic. This is deliberately NOT the
// replay order: LogEntries keeps returning causal order because
// bdrive restore walks it to find a file's previous version.
func SortForDisplay(ops []journal.Op) {
sort.SliceStable(ops, func(i, j int) bool {
ti, tj := DisplayTime(ops[i]), DisplayTime(ops[j])
if !ti.Equal(tj) {
return ti.After(tj)
}
return journal.Less(ops[j], ops[i])
})
}
// LogEntries returns the volume history, newest first.
func LogEntries(st *store.Store, pathFilter string, limit int) ([]journal.Op, error) {
all, err := st.AllOps()
if err != nil {
return nil, err
}
journal.Sort(all)
// reverse
for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 {
all[i], all[j] = all[j], all[i]
}
if pathFilter != "" {
filtered := all[:0]
for _, op := range all {
if op.Path == pathFilter || strings.HasPrefix(op.Path, pathFilter+"/") || path.Dir(op.Path) == pathFilter {
filtered = append(filtered, op)
}
}
all = filtered
}
if limit > 0 && len(all) > limit {
all = all[:limit]
}
return all, nil
}