feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) (#130)

There is no rename in beardrive: the scanner emits a put at the new path and
a delete at the old, same device, same blob, one cycle. Everything keyed on a
path therefore broke the moment a file moved — the viewer 404'd, history lost
the file's own past versions, restore refused them, and a share link either
404'd or silently served whatever unrelated file later took its address.

internal/webapp/moves.go derives the pairing from the ops the replay already
walks, cached with the snapshot. Deliberately not a rename op: journal.Less
and Replay are what every device converges to, and every already-shipped
journal would still need the heuristic to read its own history.

The two rules point in opposite directions on purpose. A viewer URL is an
address, so a LIVE path always wins and only an empty one redirects. A share
token is a promise about one file, so it follows the file even when a new one
takes the old address — and 404s forever once the file is deleted.

Nothing here writes an op or touches sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-11 02:52:20 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent aa33ba55cc
commit e19fec0534
19 changed files with 1258 additions and 144 deletions
+2
View File
@@ -31,7 +31,9 @@ classDiagram
class Browser {
folder listing, file view
per-view routes
+moved: /resolve?path= on a tree miss only
}
note for Browser "A missing path is decided from /tree alone — the file is never fetched — so the X-Bdrive-Canonical-Path header /file answers with would never reach the browser, and a moved FOLDER has no content fetch to hang a header on. The not-found branch asks GET /resolve?path= instead, then replaceState-navigates to the destination and prints one Moved from … line above it (BEA-81)"
class router {
+VIEW_ROUTES dashboard history install settings
+30 -1
View File
@@ -37,7 +37,7 @@ classDiagram
class volume {
-source Source
-refresh time.Duration
-snap *snapshot
-snap *snapshot (files + moves)
+snapshot(ctx)
+invalidate()
}
@@ -68,6 +68,30 @@ classDiagram
}
note for sourcedOp "An op's Device field is whatever the writer typed; From is the journal object it actually came out of, which the /store door gates. Attribution reads From — a peer cannot sign someone else's name on a change by editing its own journal"
note for RemoteSource "OpenBlob is the single blob-read door: the sha must match blobRe, and verify re-hashes the bytes whenever the backend is a PutSigner — in direct-upload mode the server never saw the content, so the store is the only thing that could have swapped it. It stops re-hashing only once the object is PROVABLY immutable: both presign doors refuse a key that exists, so every URL for a blob was minted before its first PUT and dies at mint+PresignTTL; past that age the hub is the only writer left. That is what remote.Object.Modified is for"
class MoveSource {
<<interface>>
+FilesWithMoves(ctx) files, moveIndex
}
class moveIndex {
<<map path→[]pathEvent>>
+buildMoveIndex(sorted ops)
+resolveForward(idx, files, p) viewer
+resolveShare(idx, files, p, since) /s/
+chainSegments(idx, p) []segment
+resolveFolder(idx, files, dir) all-or-nothing
}
class pathEvent {
+At the delete that ended it
+To "" = deleted, not moved
+ToAt destination's create
}
class segment {
+Path
+From, To window it WAS the file
}
note for moveIndex "There is no rename op — a move is put(new) + delete(old), same device, same blob, one cycle — so the index is DERIVED inside the replay Files already runs and cached with the snapshot. Pairing needs same device, |Δt| ≤ 30s, B's first-ever put, and one-to-one both ways; anything ambiguous stays a plain deletion. Nothing here writes an op: journal.Less and Replay are untouched"
note for segment "Time-bounded on purpose: a bare set of paths would make history?path=docs/a.md show the ops of the NEW a.md that took the old address"
class Uploader {
<<interface>>
+Upload(ctx, path, r, size, who, note)
@@ -346,6 +370,11 @@ classDiagram
Source <|.. DirSource
Source <|.. RemoteSource
MoveSource <|.. RemoteSource : optional, like Uploader — DirSource has no journals, so no moves
MoveSource ..> moveIndex
volume o-- moveIndex : cached with the snapshot
moveIndex *-- pathEvent
moveIndex ..> segment : chainSegments
Uploader <|-- DirectUploader
DirectUploader <|.. RemoteSource
RemoteSource o-- Backend : Prefixed(Root, projectID)
+65
View File
@@ -765,3 +765,68 @@ func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// A rename is not an op — the scanner emits a put at the new path and a
// delete at the old, in one cycle, carrying the same blob. The hub infers
// moves from exactly that shape (internal/webapp/moves.go), which only stays
// true while sync keeps producing it. Nothing in the move index touches
// journal.Less or Replay; this is what pins that.
func TestRenameConvergesAsPutPlusDelete(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, "plan.md", "the plan")
cycle(t, a)
cycle(t, b)
if got := read(t, b.Folder, "plan.md"); got != "the plan" {
t.Fatalf("b before the rename = %q", got)
}
// The rename, exactly as a person or an editor does it.
if err := os.MkdirAll(filepath.Join(a.Folder, "notes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(filepath.Join(a.Folder, "plan.md"), filepath.Join(a.Folder, "notes", "plan.md")); err != nil {
t.Fatal(err)
}
res := cycle(t, a)
if res.LocalOps != 2 {
t.Fatalf("LocalOps = %d, want 2 (the put and the delete)", res.LocalOps)
}
cycle(t, b)
if got := read(t, b.Folder, "notes/plan.md"); got != "the plan" {
t.Fatalf("b after the rename = %q, want the plan", got)
}
if _, err := os.Stat(filepath.Join(b.Folder, "plan.md")); !os.IsNotExist(err) {
t.Fatalf("the old path survived on b: %v", err)
}
// The two halves the hub pairs on: one device, same blob, same cycle.
ops, err := journal.ReadFile(a.Store.JournalPath(a.Device.ID))
if err != nil {
t.Fatal(err)
}
var put, del *journal.Op
for i := range ops {
switch {
case ops[i].Kind == journal.KindPut && ops[i].Path == "notes/plan.md":
put = &ops[i]
case ops[i].Kind == journal.KindDelete && ops[i].Path == "plan.md":
del = &ops[i]
}
}
if put == nil || del == nil {
t.Fatalf("rename did not journal a put+delete pair: %+v", ops)
}
if put.Device != del.Device {
t.Fatalf("halves on different devices: %q vs %q", put.Device, del.Device)
}
if want := ops[0].Blob; put.Blob != want {
t.Fatalf("the moved file's blob changed: %q, want %q", put.Blob, want)
}
if d := del.Time.Sub(put.Time); d > 30*time.Second || d < -30*time.Second {
t.Fatalf("the halves landed %v apart — wider than the hub's pairing window", d)
}
}
+13
View File
@@ -252,6 +252,19 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
// A second version of the same binary, so the history diff has a
// predecessor to refuse to diff (the "binary — no diff" path).
put("assets/logo.png", png+"\x00trailing", 3*time.Hour)
// A file that MOVED: the same blob put at the new path and the old path
// deleted, one device, one cycle — the shape the scanner emits for a
// rename. The old URL has to keep working (BEA-81).
put("old-guide.md", "# Old guide\n\nThis file has been moved.\n", 30*time.Hour)
put("archive/moved-guide.md", "# Old guide\n\nThis file has been moved.\n", 5*time.Hour)
lam++
seq++
ops = append(ops, journal.Op{
Seq: seq, Lamport: lam, Time: now.Add(-5 * time.Hour).Add(time.Second),
Device: "seed", DeviceName: "seed-agent", Author: "alice@x.io",
User: "alice@x.io", UserName: "Alice",
Kind: journal.KindDelete, Path: "old-guide.md",
})
// One removed file, so the history feed has a delete row: deletes have no
// content, so their rows stay unclickable while every other row is now an
// address for its own version.
@@ -808,6 +808,27 @@ test("an old version of an extensionless file previews the same way", async ({ p
await expect(page.locator("#content .empty")).toContainText("That version isn't available.");
});
// BEA-81: an old URL for a file that has since been renamed or dragged into
// a folder still lands on the file, rewrites itself, and says what happened.
test("a moved file's old URL redirects and says so", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/old-guide.md`);
await page.waitForURL(`/${pid}/archive/moved-guide.md`);
await expect(page.locator("#content")).toContainText("This file has been moved");
await expect(page.locator(".vbanner")).toContainText("Moved from old-guide.md");
// replace, not push: Back must not bounce off the dead URL forever.
await expect(page.locator(".notfound")).toHaveCount(0);
});
test("a path that never existed still gets the not-found card", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/nothing-here.md`);
await expect(page.locator(".notfound")).toBeVisible();
await expect(page.locator(".vbanner")).toHaveCount(0);
});
// BEA-74: .csv/.tsv render as a table, and anything the parser can't make a
// table of stays the plain-text view it is today.
+45 -2
View File
@@ -5,10 +5,10 @@ import {
useState,
type ReactNode,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { atLeast } from "../api/types";
import { postJSON } from "../api/http";
import { getJSON, postJSON } from "../api/http";
import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { useShares } from "../hooks/useHub";
@@ -78,6 +78,29 @@ export default function Browser(props: {
const isMissing = !!path && loaded && !isDir && !isFile;
const listingShowing = isDir && !route.view;
/* ---- an address whose file moved ----
Files get renamed and dragged into folders, and the old URL is already
in someone's notes. The server can pair the delete with the put that
carried the same blob, so ask it but only once the tree says the path
is gone, so the happy path costs nothing. It is a separate call rather
than the X-Bdrive-Canonical-Path header /file answers with, because we
never fetch a missing file at all, and a moved FOLDER has no content
fetch to hang a header on. */
const { data: moved } = useQuery({
queryKey: ["resolve", apiBase, path],
queryFn: () =>
getJSON<{ to: string; kind: string }>(apiBase + "resolve?path=" + encodeURIComponent(path)),
enabled: isMissing,
retry: false, // a 404 here is the normal answer, not a flake
staleTime: 60_000,
});
const [movedFrom, setMovedFrom] = useState<{ from: string; to: string } | null>(null);
useEffect(() => {
if (!isMissing || !moved?.to) return;
setMovedFrom({ from: path, to: moved.to });
navigate(urlForPath(moved.to, project?.id), { replace: true });
}, [isMissing, moved, path, project?.id]);
/* ---- tree expansion ---- */
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const firstLoad = useRef(true);
@@ -464,6 +487,26 @@ export default function Browser(props: {
view = <div className="empty">Select a file to read it.</div>;
}
// Arriving here by redirect: say so, or the URL silently changed under a
// reader who typed the other one. Above whatever the destination renders,
// so a moved folder gets it too.
if (movedFrom && movedFrom.to === path) {
view = (
<>
<div className="vbanner" role="status">
<span className="vb-icon">
<Icon name="link" />
</span>
<div className="vb-text">
<b>Moved from {movedFrom.from}</b>
<span>The URL has been updated.</span>
</div>
</div>
{view}
</>
);
}
const crumb = panel ? (
panel.crumb
) : path ? (
+14 -1
View File
@@ -216,11 +216,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
op journal.Op
}
visible := s.deviceVisibleIn(projectID(r))
// A file that moved keeps its past — under its old path. ?path= resolves
// through the move chain so the feed for docs/a.md includes the versions
// written while it was a.md. Each hop is time-bounded (see segment), so
// an unrelated NEW a.md created after the move does not leak in. `all`
// is already sorted by journal.Less, so this costs no extra I/O.
var chain []segment
if path != "" {
ops := make([]journal.Op, len(all))
for i, sop := range all {
ops[i] = sop.Op
}
chain = chainSegments(buildMoveIndex(ops), path)
}
matched := make([]timed, 0, len(all))
for i, sop := range all {
op := sop.Op
switch {
case path != "" && op.Path != path:
case path != "" && !inSegments(chain, op.Path, op.Time):
continue
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
continue
+357
View File
@@ -0,0 +1,357 @@
package webapp
import (
"net/http"
"sort"
"strings"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
// Moves. There is no rename in beardrive: the scanner emits a put for the
// path it has never seen and a delete for the cache key it no longer sees,
// both in the same cycle, from the same device, carrying the same blob
// (internal/syncer/syncer.go). journal.Op has put and delete and nothing
// else, so a move is only ever inferred — never recorded.
//
// Everything keyed on a path therefore breaks the moment a file moves: the
// viewer 404s, history loses the file's own past versions, restore refuses
// them, and a share link either 404s or — worse — silently serves whatever
// unrelated file later lands on the address it was minted for.
//
// This file derives the pairing from the ops the replay already walks. It
// deliberately does NOT add a rename op: journal.Less and Replay are what
// every device converges to, and every already-shipped journal would still
// need the heuristic to read its own history. Derivation is reversible; a
// new op kind is not.
//
// Everything here is read-side. Nothing in this file writes an op.
// moveWindow is how far apart the two halves of a move may land. Wider than
// one daemon cycle (--scan-interval 3s, so a move split across two cycles
// still pairs), narrower than a person's editing session. It is the one
// number worth revisiting under real journals.
const moveWindow = 30 * time.Second
// maxChain bounds every walk in this file. Op.Time and Op.Path are peer JSON,
// so a journal can describe a cycle (A→B→A); a visited set alone stops the
// loop, and this stops a pathological chain from making one request walk a
// journal-sized graph.
const maxChain = 64
// pathEvent is one moment a path stopped being the file it was.
type pathEvent struct {
At time.Time // the delete that ended it
To string // where the content went; "" = deleted, not moved
ToAt time.Time // when the destination was created (move only)
}
// hop is the instant the file changed address. The two halves of a move
// arrive in either order (an in-cycle move is put-then-delete; one split
// across cycles is delete-then-put), so the boundary between the old path's
// window and the new one's is the earlier of the two — otherwise the
// destination's own creating op falls outside its own history.
func (e pathEvent) hop() time.Time {
if e.To != "" && !e.ToAt.IsZero() && e.ToAt.Before(e.At) {
return e.ToAt
}
return e.At
}
// moveIndex is, per path, the events that ended it — in time order.
//
// One time-stamped list rather than two flat maps (movedTo + deletedAt): a
// path that is deleted, recreated and then moved has an entry under the same
// key in both maps and nothing left to say which came first, so a share
// minted before the delete would follow the move and serve the file that
// REPLACED its own.
type moveIndex map[string][]pathEvent
// buildMoveIndex pairs deletes with creates. ops must already be sorted by
// journal.Less — every caller has them that way (the replay, the history
// feed) so the index costs no extra I/O and no extra pass over storage.
//
// A delete of A pairs with the first-ever put of B when all hold: same
// Op.Device (one device's scan produces both halves), |Δt| ≤ moveWindow,
// B's blob is the blob A held immediately before its delete (content
// identity is the only link the journal gives us), and the pairing is
// one-to-one in both directions. Anything ambiguous — duplicated content,
// empty files — stays unpaired and becomes a plain deletion. Silence beats
// a wrong destination.
func buildMoveIndex(ops []journal.Op) moveIndex {
type half struct {
path, blob, dev string
at time.Time
}
var creates, deletes []half
live := map[string]string{} // path -> the blob it currently holds
ever := map[string]bool{} // path has ever been put
for _, op := range ops {
switch op.Kind {
case journal.KindPut:
// Files ignores a put whose Blob is not a bare sha256 (it is a
// storage key suffix, so anything else is a path the writer
// chose). The index has to agree, or it would pair against a
// version the viewer never shows.
if !blobRe.MatchString(op.Blob) {
continue
}
if !ever[op.Path] {
ever[op.Path] = true
creates = append(creates, half{op.Path, op.Blob, op.Device, op.Time})
}
live[op.Path] = op.Blob
case journal.KindDelete:
blob, ok := live[op.Path]
if !ok {
continue // deleting nothing
}
delete(live, op.Path)
deletes = append(deletes, half{op.Path, blob, op.Device, op.Time})
}
}
key := func(h half) string { return h.dev + "\x00" + h.blob }
byKey := map[string][]int{}
for i, c := range creates {
byKey[key(c)] = append(byKey[key(c)], i)
}
pairs := func(a, b half) bool {
return a.path != b.path && abs(a.at.Sub(b.at)) <= moveWindow
}
// ponytail: O(n²) inside one (device, blob) bucket. A bucket is the
// identical copies of ONE file, not the volume — bucket by time too if
// a duplicate-heavy volume ever shows up in a profile.
idx := moveIndex{}
for _, d := range deletes {
ev := pathEvent{At: d.at}
var only int = -1
for _, i := range byKey[key(d)] {
if !pairs(creates[i], d) {
continue
}
if only >= 0 {
only = -1 // two candidates: ambiguous, decline
break
}
only = i
}
if only >= 0 {
// ...and one-to-one the other way: two deletes claiming one
// create is the same ambiguity seen from the other side.
c, n := creates[only], 0
for _, d2 := range deletes {
if key(d2) == key(c) && pairs(c, d2) {
n++
}
}
if n == 1 {
ev.To, ev.ToAt = c.path, c.at
}
}
idx[d.path] = append(idx[d.path], ev)
}
for p := range idx {
evs := idx[p]
sort.SliceStable(evs, func(i, j int) bool { return evs[i].At.Before(evs[j].At) })
}
return idx
}
func abs(d time.Duration) time.Duration {
if d < 0 {
return -d
}
return d
}
// resolveForward answers "this address is empty — where did the file go?".
// It follows the LAST event of each path (the most recent move wins) until
// it lands somewhere live. Used by the viewer, where a live path always
// wins: the caller only reaches here on a snapshot miss.
func resolveForward(idx moveIndex, files map[string]FileInfo, p string) (string, bool) {
seen := map[string]bool{p: true}
for i := 0; i < maxChain; i++ {
evs := idx[p]
if len(evs) == 0 {
return "", false
}
to := evs[len(evs)-1].To
if to == "" || seen[to] {
return "", false // deleted, or a cycle a journal described
}
seen[to] = true
if _, ok := files[to]; ok {
return to, true
}
p = to
}
return "", false
}
// resolveShare answers the opposite question: "where is the file this token
// was minted for?" — following the file even when a new one has taken its
// old address. A viewer URL is an address; a share token is a promise about
// one file, and must never resolve to a file it wasn't minted for.
//
// since is the share's creation time: only what happened AFTER the link was
// minted can move it. A path that was deleted rather than moved is gone,
// even if something now occupies the address.
func resolveShare(idx moveIndex, files map[string]FileInfo, p string, since time.Time) (string, bool) {
seen := map[string]bool{p: true}
for i := 0; i < maxChain; i++ {
var next *pathEvent
for j := range idx[p] {
if idx[p][j].At.After(since) {
next = &idx[p][j]
break
}
}
if next == nil {
// Nothing happened to this path since: it is still the file.
if _, ok := files[p]; ok {
return p, true
}
return "", false
}
if next.To == "" || seen[next.To] {
return "", false
}
seen[next.To] = true
p, since = next.To, next.At
}
return "", false
}
// segment is one path plus the window during which it WAS the file being
// asked about. Time-bounded on purpose: a bare set of paths would make
// history?path=docs/a.md show the ops of the new a.md that took the old
// address. A zero From/To is open at that end.
type segment struct {
Path string
From, To time.Time
}
// chainSegments walks backwards: p's own window plus each ancestor's,
// ending at the hop that carried the file out of it. Used by history and
// restore, so a moved file keeps reaching its own past versions.
func chainSegments(idx moveIndex, p string) []segment {
type inbound struct {
from string
hop time.Time
}
into := map[string][]inbound{}
for src, evs := range idx {
for _, e := range evs {
if e.To != "" {
into[e.To] = append(into[e.To], inbound{src, e.hop()})
}
}
}
var segs []segment
seen := map[string]bool{p: true}
cur, upper := p, time.Time{}
for i := 0; i < maxChain; i++ {
anc := into[cur]
// No ancestor, or more than one claiming to be it: stop, leaving
// this segment open at the start rather than guessing.
if len(anc) != 1 || seen[anc[0].from] {
return append(segs, segment{Path: cur, To: upper})
}
segs = append(segs, segment{Path: cur, From: anc[0].hop, To: upper})
seen[anc[0].from] = true
cur, upper = anc[0].from, anc[0].hop
}
return segs
}
// inSegments reports whether an op at path p, time at, belongs to the chain.
func inSegments(segs []segment, p string, at time.Time) bool {
for _, s := range segs {
switch {
case s.Path != p:
case !s.From.IsZero() && at.Before(s.From):
case !s.To.IsZero() && !at.Before(s.To):
default:
return true
}
}
return false
}
// resolveFolder derives a folder redirect from the file mappings — there are
// no folder ops, so a folder move is N file moves. All-or-nothing: every
// file that was under dir/ must land on the same newdir/<same suffix>, with
// nothing under dir/ still live and no non-move delete in the way. A partial
// match gets no folder redirect; the individual files still redirect on
// their own.
func resolveFolder(idx moveIndex, files map[string]FileInfo, dir string) (string, bool) {
dir = strings.Trim(dir, "/")
if dir == "" {
return "", false
}
prefix := dir + "/"
for p := range files {
if strings.HasPrefix(p, prefix) {
return "", false // the folder still exists; nothing to redirect
}
}
dest := ""
for src := range idx {
if !strings.HasPrefix(src, prefix) {
continue
}
to, ok := resolveForward(idx, files, src)
if !ok {
return "", false // deleted, or ambiguous: no honest destination
}
suffix := strings.TrimPrefix(src, prefix)
if !strings.HasSuffix(to, "/"+suffix) {
return "", false
}
nd := strings.TrimSuffix(to, "/"+suffix)
if nd == "" || (dest != "" && nd != dest) {
return "", false
}
dest = nd
}
return dest, dest != ""
}
// handleResolve serves GET .../resolve?path= — "this address is empty, where
// did it go?". The SPA calls it only on a miss (it decides a path is missing
// from /tree alone and never fetches the file, so the canonical header on
// /file would never reach the browser) and it is the only shape that covers
// a moved folder, which has no content fetch to hang a header on.
func (s *Server) handleResolve(v *volume, w http.ResponseWriter, r *http.Request) {
p := r.URL.Query().Get("path")
if p == "" {
http.Error(w, "missing ?path=", http.StatusBadRequest)
return
}
snap, err := v.snapshot(r.Context())
if err != nil {
storageErr(w, http.StatusBadGateway, "content temporarily unavailable", err)
return
}
if _, live := snap.files[p]; !live {
if to, ok := resolveForward(snap.moves, snap.files, p); ok {
writeJSON(w, map[string]string{"to": to, "kind": "file"})
return
}
if to, ok := resolveFolder(snap.moves, snap.files, p); ok {
writeJSON(w, map[string]string{"to": to, "kind": "folder"})
return
}
}
http.Error(w, "no such file: "+p, http.StatusNotFound)
}
// setCanonical tells the caller the path moved. The value is the path that
// answered; a client that cares rewrites its URL to it.
func setCanonical(w http.ResponseWriter, r *http.Request, p string) {
if p != r.URL.Query().Get("path") {
w.Header().Set("X-Bdrive-Canonical-Path", p)
}
}
+378
View File
@@ -0,0 +1,378 @@
package webapp
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
// t0 is the fixed clock these tests place ops on. Every pairing rule in
// moves.go is time-windowed, so "now" would make them unreadable.
var t0 = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
// delAt is the timed twin of fakeRemote.del (putAt already lives in
// history_test.go).
func (f *fakeRemote) delAt(dev, path string, at time.Time) {
f.t.Helper()
f.append(dev, journal.Op{Kind: journal.KindDelete, Path: path, Time: at})
}
// move is the shape the scanner actually emits: one device, one cycle, the
// same blob put at the new path and deleted at the old.
func (f *fakeRemote) move(dev, from, to, content string, at time.Time) {
f.t.Helper()
f.putAt(dev, to, content, at)
f.delAt(dev, from, at.Add(time.Second))
}
/* ---- the index ---- */
// buildIndex is the unit-level path: ops in, index out.
func buildIndex(ops ...journal.Op) moveIndex {
journal.Sort(ops)
return buildMoveIndex(ops)
}
func op(kind, path, blob string, at time.Time, dev string, lamport int64) journal.Op {
return journal.Op{
Kind: kind, Path: path, Blob: blob, Time: at, Device: dev,
Lamport: lamport, Seq: lamport, Size: 4, Mode: 0o644,
}
}
func sha(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func TestMoveIndexPairsInEitherJournalOrder(t *testing.T) {
b := sha("body")
for _, tc := range []struct {
name string
ops []journal.Op
}{
{"put-then-delete", []journal.Op{
op(journal.KindPut, "a.md", b, t0, "deva", 1),
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute), "deva", 2),
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute+time.Second), "deva", 3),
}},
{"delete-then-put", []journal.Op{
op(journal.KindPut, "a.md", b, t0, "deva", 1),
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+4*time.Second), "deva", 3),
}},
} {
t.Run(tc.name, func(t *testing.T) {
idx := buildIndex(tc.ops...)
evs := idx["a.md"]
if len(evs) != 1 || evs[0].To != "docs/a.md" {
t.Fatalf("a.md events = %+v, want one move to docs/a.md", evs)
}
})
}
}
func TestMoveIndexDeclinesOutsideWindow(t *testing.T) {
b := sha("body")
idx := buildIndex(
op(journal.KindPut, "a.md", b, t0, "deva", 1),
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Hour), "deva", 3),
)
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
t.Fatalf("a.md events = %+v, want one plain deletion", evs)
}
}
func TestMoveIndexDeclinesAcrossDevices(t *testing.T) {
b := sha("body")
idx := buildIndex(
op(journal.KindPut, "a.md", b, t0, "deva", 1),
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+time.Second), "devb", 3),
)
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
t.Fatalf("a.md events = %+v, want one plain deletion", evs)
}
}
func TestMoveIndexAmbiguousIdenticalContent(t *testing.T) {
// Two files with the same bytes, one deleted, one created: content
// identity cannot say which is which, so nothing pairs.
b := sha("body")
idx := buildIndex(
op(journal.KindPut, "a.md", b, t0, "deva", 1),
op(journal.KindPut, "b.md", b, t0, "deva", 2),
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 3),
op(journal.KindDelete, "b.md", "", t0.Add(time.Minute), "deva", 4),
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+time.Second), "deva", 5),
)
for _, p := range []string{"a.md", "b.md"} {
if evs := idx[p]; len(evs) != 1 || evs[0].To != "" {
t.Fatalf("%s events = %+v, want a plain deletion (ambiguous)", p, evs)
}
}
}
func TestMoveIndexIgnoresOverwritingPut(t *testing.T) {
// docs/a.md already existed, so the put that happens to carry a.md's
// blob is an edit, not a move destination.
b := sha("body")
idx := buildIndex(
op(journal.KindPut, "a.md", b, t0, "deva", 1),
op(journal.KindPut, "docs/a.md", sha("other"), t0, "deva", 2),
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 3),
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute), "deva", 4),
)
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
t.Fatalf("a.md events = %+v, want a plain deletion", evs)
}
}
/* ---- resolvers ---- */
func TestResolveShareDeleteBeforeMove(t *testing.T) {
// a.md is deleted, an unrelated a.md is created at the same address,
// and THAT one moves away. A share minted before the delete is a promise
// about the first file — which is gone.
b, c := sha("first"), sha("second")
idx := buildIndex(
op(journal.KindPut, "a.md", b, t0, "deva", 1),
op(journal.KindDelete, "a.md", "", t0.Add(time.Hour), "deva", 2),
op(journal.KindPut, "a.md", c, t0.Add(2*time.Hour), "deva", 3),
op(journal.KindPut, "docs/a.md", c, t0.Add(3*time.Hour), "deva", 4),
op(journal.KindDelete, "a.md", "", t0.Add(3*time.Hour+time.Second), "deva", 5),
)
files := map[string]FileInfo{"docs/a.md": {Blob: c}}
if to, ok := resolveShare(idx, files, "a.md", t0); ok {
t.Fatalf("share minted before the delete resolved to %q, want gone", to)
}
to, ok := resolveShare(idx, files, "a.md", t0.Add(2*time.Hour+time.Minute))
if !ok || to != "docs/a.md" {
t.Fatalf("share minted after the recreate = %q %v, want docs/a.md", to, ok)
}
}
func TestResolveForwardCycle(t *testing.T) {
// A journal is peer JSON and can describe A→B→A. Answer "not found"
// rather than walk forever.
idx := moveIndex{
"a.md": {{At: t0, To: "b.md", ToAt: t0}},
"b.md": {{At: t0.Add(time.Minute), To: "a.md", ToAt: t0.Add(time.Minute)}},
}
if to, ok := resolveForward(idx, map[string]FileInfo{}, "a.md"); ok {
t.Fatalf("cycle resolved to %q, want not found", to)
}
if to, ok := resolveShare(idx, map[string]FileInfo{}, "a.md", time.Time{}); ok {
t.Fatalf("cycle resolved (share) to %q, want not found", to)
}
}
func TestChainSegmentsExcludeSuccessorAtOldPath(t *testing.T) {
b := sha("body")
idx := buildIndex(
op(journal.KindPut, "a.md", b, t0, "deva", 1),
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Hour), "deva", 2),
op(journal.KindDelete, "a.md", "", t0.Add(time.Hour+time.Second), "deva", 3),
op(journal.KindPut, "a.md", sha("unrelated"), t0.Add(2*time.Hour), "deva", 4),
)
segs := chainSegments(idx, "docs/a.md")
if !inSegments(segs, "a.md", t0) {
t.Error("the original a.md version is not in docs/a.md's chain")
}
if inSegments(segs, "a.md", t0.Add(2*time.Hour)) {
t.Error("the unrelated later a.md leaked into docs/a.md's chain")
}
if !inSegments(segs, "docs/a.md", t0.Add(time.Hour)) {
t.Error("docs/a.md's own creating op is not in its chain")
}
}
/* ---- viewer ---- */
func TestMovedFileRedirects(t *testing.T) {
f := newFakeRemote(t)
f.putAt("deva", "a.md", "# Body", t0)
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
h := f.server().Handler()
rec := get(t, h, "/api/file?path=a.md")
if rec.Code != 200 || rec.Body.String() != "# Body" {
t.Fatalf("moved file: %d %q", rec.Code, rec.Body)
}
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "docs/a.md" {
t.Fatalf("canonical header = %q, want docs/a.md", got)
}
// The destination itself carries no header — nothing moved.
rec = get(t, h, "/api/file?path=docs/a.md")
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "" {
t.Fatalf("canonical header on the live path = %q, want none", got)
}
// A path that never existed still 404s.
if rec := get(t, h, "/api/file?path=nope.md"); rec.Code != 404 {
t.Fatalf("unknown path: %d, want 404", rec.Code)
}
}
func TestLivePathWinsOverRedirect(t *testing.T) {
f := newFakeRemote(t)
f.putAt("deva", "a.md", "# Body", t0)
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
f.putAt("deva", "a.md", "# Brand new", t0.Add(2*time.Hour))
h := f.server().Handler()
rec := get(t, h, "/api/file?path=a.md")
if rec.Code != 200 || rec.Body.String() != "# Brand new" {
t.Fatalf("live path: %d %q, want the new file", rec.Code, rec.Body)
}
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "" {
t.Fatalf("canonical header = %q, want none (nothing moved out of a live path)", got)
}
}
func TestMoveChainResolvesInOneHop(t *testing.T) {
f := newFakeRemote(t)
f.putAt("deva", "a.md", "body", t0)
f.move("deva", "a.md", "b.md", "body", t0.Add(time.Hour))
f.move("deva", "b.md", "c.md", "body", t0.Add(2*time.Hour))
h := f.server().Handler()
rec := get(t, h, "/api/file?path=a.md")
if rec.Code != 200 {
t.Fatalf("a.md: %d %s", rec.Code, rec.Body)
}
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "c.md" {
t.Fatalf("canonical header = %q, want c.md", got)
}
}
func TestRenderFollowsMove(t *testing.T) {
f := newFakeRemote(t)
f.putAt("deva", "a.md", "# Title", t0)
f.move("deva", "a.md", "docs/a.md", "# Title", t0.Add(time.Hour))
h := f.server().Handler()
rec := get(t, h, "/api/render?path=a.md")
if rec.Code != 200 {
t.Fatalf("render: %d %s", rec.Code, rec.Body)
}
var doc struct {
Path string `json:"path"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if doc.Path != "docs/a.md" {
t.Fatalf("rendered path = %q, want the canonical docs/a.md", doc.Path)
}
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "docs/a.md" {
t.Fatalf("canonical header = %q", got)
}
}
/* ---- /resolve ---- */
func TestResolveEndpoint(t *testing.T) {
f := newFakeRemote(t)
f.putAt("deva", "notes/one.md", "one", t0)
f.putAt("deva", "notes/two.md", "two", t0)
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
f.move("deva", "notes/two.md", "wiki/two.md", "two", t0.Add(time.Hour))
h := f.server().Handler()
var got struct{ To, Kind string }
rec := get(t, h, "/api/resolve?path=notes")
if rec.Code != 200 {
t.Fatalf("folder resolve: %d %s", rec.Code, rec.Body)
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.To != "wiki" || got.Kind != "folder" {
t.Fatalf("folder resolve = %+v, want wiki/folder", got)
}
rec = get(t, h, "/api/resolve?path=notes/one.md")
if rec.Code != 200 {
t.Fatalf("file resolve: %d %s", rec.Code, rec.Body)
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.To != "wiki/one.md" || got.Kind != "file" {
t.Fatalf("file resolve = %+v", got)
}
// A live path has not gone anywhere.
if rec := get(t, h, "/api/resolve?path=wiki/one.md"); rec.Code != 404 {
t.Fatalf("live path resolve: %d, want 404", rec.Code)
}
}
func TestResolveFolderIsAllOrNothing(t *testing.T) {
t.Run("half moved", func(t *testing.T) {
f := newFakeRemote(t)
f.putAt("deva", "notes/one.md", "one", t0)
f.putAt("deva", "notes/two.md", "two", t0)
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
t.Fatalf("partial move: %d, want 404 (notes/ still exists)", rec.Code)
}
})
t.Run("one deleted", func(t *testing.T) {
f := newFakeRemote(t)
f.putAt("deva", "notes/one.md", "one", t0)
f.putAt("deva", "notes/two.md", "two", t0)
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
f.delAt("deva", "notes/two.md", t0.Add(2*time.Hour))
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
t.Fatalf("deleted member: %d, want 404 (no honest destination)", rec.Code)
}
})
t.Run("split destinations", func(t *testing.T) {
f := newFakeRemote(t)
f.putAt("deva", "notes/one.md", "one", t0)
f.putAt("deva", "notes/two.md", "two", t0)
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
f.move("deva", "notes/two.md", "docs/two.md", "two", t0.Add(time.Hour))
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
t.Fatalf("split destinations: %d, want 404", rec.Code)
}
})
}
/* ---- read heat lands on the canonical path ---- */
func TestRedirectedReadCreditsCanonicalPath(t *testing.T) {
srv, p, root := newHub(t, true, nil)
var err error
if srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0); err != nil {
t.Fatal(err)
}
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.putAt("deva", "a.md", "# Body", t0)
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
h := srv.Handler()
if rec := do(t, h, "GET", "/api/p/"+p.ID+"/file?path=a.md", nil); rec.Code != 200 {
t.Fatalf("read: %d %s", rec.Code, rec.Body)
}
rec := do(t, h, "GET", "/api/p/"+p.ID+"/heat?days=30", nil)
var heat struct {
Entries map[string]HeatEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &heat); err != nil {
t.Fatalf("heat: %v (%s)", err, rec.Body)
}
if heat.Entries["docs/a.md"].Human == 0 {
t.Errorf("heat credited %v, want the read on docs/a.md", heat.Entries)
}
if heat.Entries["a.md"].Human != 0 {
t.Errorf("heat credited the OLD path a.md: %v", heat.Entries)
}
}
+10 -3
View File
@@ -51,11 +51,18 @@ func (s *Server) handleRestore(v *volume, w http.ResponseWriter, r *http.Request
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// The sha must be a version OF THIS PATH: without this, restore would
// paste any blob in the store onto any path.
// The sha must be a version OF THIS FILE: without this, restore would
// paste any blob in the store onto any path. "This file" includes the
// paths it lived at before it moved — otherwise a moved file can never
// be restored to anything older than its move. Ancestors only: the put
// is written at the current path, so a descendant's versions are not
// reachable from here anyway. buildMoveIndex needs journal order.
journal.Sort(all)
chain := chainSegments(buildMoveIndex(all), p)
var found *journal.Op
for i := range all {
if op := &all[i]; op.Kind == journal.KindPut && op.Path == p && op.Blob == req.SHA {
op := &all[i]
if op.Kind == journal.KindPut && op.Blob == req.SHA && inSegments(chain, op.Path, op.Time) {
found = op
break
}
+68
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
@@ -276,3 +277,70 @@ func TestRestoreNotOnSingleVolume(t *testing.T) {
t.Fatalf("single-volume restore: %d, want no such route", rec.Code)
}
}
// A moved file keeps its past. Without the chain, restore refuses every
// version written before the move — the file's own history becomes
// unreachable the moment someone drags it into a folder.
func TestRestoreReachesVersionsFromBeforeAMove(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
at := time.Now().Add(-time.Hour)
f.putAt("dev1", "plan.md", "v1", at)
f.putAt("dev1", "plan.md", "v2 longer", at.Add(time.Minute))
// the move: same device, same blob, one cycle
f.putAt("dev1", "notes/plan.md", "v2 longer", at.Add(2*time.Minute))
f.delAt("dev1", "plan.md", at.Add(2*time.Minute+time.Second))
// an unrelated file later takes the old address
f.putAt("dev1", "plan.md", "not the same file", at.Add(time.Hour))
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
rec := do(t, h, "POST", base+"restore",
map[string]string{"path": "notes/plan.md", "sha": shaOf("v1")})
if rec.Code != 200 {
t.Fatalf("restore across a move: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", base+"file?path=notes/plan.md", nil); rec.Body.String() != "v1" {
t.Fatalf("file after restore = %q, want v1", rec.Body)
}
// The successor at the old address is a different file, not an ancestor.
rec = do(t, h, "POST", base+"restore",
map[string]string{"path": "notes/plan.md", "sha": shaOf("not the same file")})
if rec.Code != http.StatusNotFound {
t.Fatalf("restore of the successor's blob: %d %s, want 404", rec.Code, rec.Body)
}
}
// The same chain, on the history feed.
func TestHistoryFollowsAMove(t *testing.T) {
srv, p, root := newHub(t, true, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
at := time.Now().Add(-time.Hour)
f.putAt("dev1", "plan.md", "v1", at)
f.putAt("dev1", "plan.md", "v2", at.Add(time.Minute))
f.putAt("dev1", "notes/plan.md", "v2", at.Add(2*time.Minute))
f.delAt("dev1", "plan.md", at.Add(2*time.Minute+time.Second))
f.putAt("dev1", "plan.md", "unrelated", at.Add(time.Hour))
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
entries := historyOf(t, h, base, "notes/plan.md")
var blobs []string
for _, e := range entries {
blobs = append(blobs, e.Blob)
}
if len(entries) != 3 {
t.Fatalf("history = %d entries %v, want 3 (v1, v2, the move)", len(entries), blobs)
}
seen := map[string]bool{}
for _, b := range blobs {
seen[b] = true
}
if !seen[shaOf("v1")] {
t.Errorf("the pre-move v1 is missing from the feed: %v", blobs)
}
if seen[shaOf("unrelated")] {
t.Errorf("the unrelated file at the old address leaked in: %v", blobs)
}
}
+46 -4
View File
@@ -203,6 +203,18 @@ type volume struct {
type snapshot struct {
files map[string]FileInfo
// moves is the derived rename index (see moves.go), cached with the
// listing it was replayed alongside. Nil for a source that has no
// journals to derive it from — every resolver then answers "not found",
// so the DirSource exclusion falls out instead of needing a rule.
moves moveIndex
}
// MoveSource is a Source that can also report where its files came from.
// Optional, like Uploader: implementing it keeps the replay ONE pass, so the
// move index costs no extra journal read.
type MoveSource interface {
FilesWithMoves(context.Context) (map[string]FileInfo, moveIndex, error)
}
func (v *volume) snapshot(ctx context.Context) (*snapshot, error) {
@@ -211,14 +223,23 @@ func (v *volume) snapshot(ctx context.Context) (*snapshot, error) {
if v.snap != nil && time.Since(v.at) < v.refresh {
return v.snap, nil
}
files, err := v.source.Files(ctx)
var (
files map[string]FileInfo
moves moveIndex
err error
)
if ms, ok := v.source.(MoveSource); ok {
files, moves, err = ms.FilesWithMoves(ctx)
} else {
files, err = v.source.Files(ctx)
}
if err != nil {
if v.snap != nil {
return v.snap, nil // serve stale rather than fail
}
return nil, err
}
v.snap, v.at = &snapshot{files: files}, time.Now()
v.snap, v.at = &snapshot{files: files, moves: moves}, time.Now()
return v.snap, nil
}
@@ -468,9 +489,16 @@ func (r *RemoteSource) loadSourcedOps(ctx context.Context) ([]sourcedOp, error)
}
func (r *RemoteSource) Files(ctx context.Context) (map[string]FileInfo, error) {
files, _, err := r.FilesWithMoves(ctx)
return files, err
}
// FilesWithMoves is the replay, plus the rename index derived from the same
// sorted ops — one pass, so the index rides in the cached snapshot.
func (r *RemoteSource) FilesWithMoves(ctx context.Context) (map[string]FileInfo, moveIndex, error) {
all, err := r.loadOps(ctx)
if err != nil {
return nil, err
return nil, nil, err
}
journal.Sort(all)
files := make(map[string]FileInfo)
@@ -495,7 +523,7 @@ func (r *RemoteSource) Files(ctx context.Context) (map[string]FileInfo, error) {
delete(files, op.Path)
}
}
return files, nil
return files, buildMoveIndex(all), nil
}
func (r *RemoteSource) Open(ctx context.Context, _ string, fi FileInfo) (io.ReadCloser, error) {
@@ -567,6 +595,7 @@ func (s *Server) Handler() http.Handler {
"/api/p/{project}/": proj,
} {
mux.HandleFunc("GET "+prefix+"tree", resolve(PermRead, s.handleTree))
mux.HandleFunc("GET "+prefix+"resolve", resolve(PermRead, s.handleResolve))
mux.HandleFunc("GET "+prefix+"file", resolve(PermRead, s.handleFile))
mux.HandleFunc("GET "+prefix+"download", resolve(PermRead, s.handleDownload))
mux.HandleFunc("GET "+prefix+"render", resolve(PermRead, s.handleRender))
@@ -1008,8 +1037,19 @@ func lookup(v *volume, r *http.Request) (string, FileInfo, int, error) {
}
fi, ok := snap.files[p]
if !ok {
// The address is empty — but the file may have moved out of it. A
// LIVE path always wins, which falls out of the ordering: the
// snapshot hit above returns first, so nothing redirects while
// something still answers at the old address.
to, moved := resolveForward(snap.moves, snap.files, p)
if !moved {
return "", FileInfo{}, http.StatusNotFound, fmt.Errorf("no such file: %s", p)
}
// The canonical path is what gets returned, so the read is recorded
// against it (heat doesn't split across old and new) and the render
// payload names it.
p, fi = to, snap.files[to]
}
return p, fi, 0, nil
}
@@ -1019,6 +1059,7 @@ func (s *Server) serveBlob(v *volume, w http.ResponseWriter, r *http.Request, at
http.Error(w, err.Error(), code)
return
}
setCanonical(w, r, p)
// Count the read before the ETag check: a 304 render is still a person
// reading the file, and skipping it would undercount the hottest pages.
s.recordRead(r, p)
@@ -1086,6 +1127,7 @@ func (s *Server) handleRender(v *volume, w http.ResponseWriter, r *http.Request)
http.Error(w, err.Error(), code)
return
}
setCanonical(w, r, p)
s.recordRead(r, p)
rc, err := v.source.Open(r.Context(), p, fi)
if err != nil {
+4
View File
@@ -66,7 +66,11 @@ func (f *fakeRemote) append(dev string, op journal.Op) {
f.lam++
f.seq[dev]++
op.Seq, op.Lamport = f.seq[dev], f.lam
// A caller that set Time keeps it: move pairing and share resolution are
// both time-windowed, so those tests have to place ops in time.
if op.Time.IsZero() {
op.Time = time.Now().UTC()
}
op.Device, op.DeviceName, op.Author = dev, dev, dev+"@test"
if err := journal.Append(filepath.Join(f.dir, "journal", dev+".jsonl"), []journal.Op{op}); err != nil {
f.t.Fatal(err)
+13 -8
View File
@@ -458,14 +458,19 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
http.Error(w, "content temporarily unavailable", http.StatusBadGateway)
return
}
fi, ok := snap.files[sh.Path]
// A share token is a promise about ONE file, so it follows that file
// when it moves — the opposite of a viewer URL, which is an address and
// always serves whatever lives there now. That also closes a leak: a
// share used to serve whatever unrelated file later occupied its path.
sp, ok := resolveShare(snap.moves, snap.files, sh.Path, sh.Created)
if !ok {
http.Error(w, "the shared file no longer exists", http.StatusNotFound)
return
}
fi := snap.files[sp]
// A share hit is external consumption. Actor is token+IP: one audience
// member reloading is debounced to a visit, distinct visitors still count.
s.Reads.Record(sh.Project, sh.Path, ReadKindShare, sh.Token+"/"+s.clientIP(r))
s.Reads.Record(sh.Project, sp, ReadKindShare, sh.Token+"/"+s.clientIP(r))
// Share links are the only unauthenticated door to stored bytes, so they
// are the only egress a plan actually caps. The per-IP limiter above
@@ -485,7 +490,7 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
cw := &countingWriter{w: w}
defer func() { s.quota().RecordEgress(org, cw.n) }()
rc, err := v.source.Open(r.Context(), sh.Path, fi)
rc, err := v.source.Open(r.Context(), sp, fi)
if err != nil {
http.Error(w, "content temporarily unavailable", http.StatusBadGateway)
return
@@ -493,13 +498,13 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
defer rc.Close()
if r.URL.Query().Get("download") == "1" {
w.Header().Set("Content-Type", contentType(sh.Path))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", sanitizeFilename(path.Base(sh.Path))))
w.Header().Set("Content-Type", contentType(sp))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", sanitizeFilename(path.Base(sp))))
io.Copy(cw, rc)
return
}
switch strings.ToLower(path.Ext(sh.Path)) {
switch strings.ToLower(path.Ext(sp)) {
case ".md", ".markdown":
src, err := io.ReadAll(rc)
if err != nil {
@@ -512,12 +517,12 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(cw, sharedMarkdownShell, html.EscapeString(path.Base(sh.Path)), updatedStamp(fi.Time), body)
fmt.Fprintf(cw, sharedMarkdownShell, html.EscapeString(path.Base(sp)), updatedStamp(fi.Time), body)
case ".html", ".htm":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.Copy(cw, rc)
default:
w.Header().Set("Content-Type", contentType(sh.Path))
w.Header().Set("Content-Type", contentType(sp))
setContentLength(w, rc) // measured, never the journal's Size field
io.Copy(cw, rc)
}
+55
View File
@@ -667,3 +667,58 @@ func authAs(t *testing.T, srv *Server, req *http.Request) {
}
req.Header.Set("Authorization", "Bearer "+tok)
}
// A share token is a promise about ONE file. These three pin the direction
// it goes when a path changes hands — the opposite of the viewer's, which is
// an address and always serves whatever lives there now.
func TestShareFollowsMovedFile(t *testing.T) {
srv, p, _, f, h := shareHub(t)
token, _ := authedShare(t, srv, h, p.ID, "wiki/notes.md")
at := time.Now().Add(time.Minute)
f.putAt("dev1", "docs/notes.md", "# Notes\n\nhello **team**", at)
f.delAt("dev1", "wiki/notes.md", at.Add(time.Second))
rec := do(t, h, "GET", "/s/"+token, nil)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "hello") {
t.Fatalf("share after move: %d %s", rec.Code, rec.Body)
}
// ...and keeps following it once an UNRELATED file takes the old address.
f.putAt("dev1", "wiki/notes.md", "# Someone else's file", at.Add(time.Hour))
rec = do(t, h, "GET", "/s/"+token, nil)
if rec.Code != 200 {
t.Fatalf("share with a new file at the old path: %d %s", rec.Code, rec.Body)
}
if body := rec.Body.String(); strings.Contains(body, "Someone else") {
t.Fatalf("share served the file that took its old address:\n%s", body)
}
}
func TestShareOfDeletedFileNeverServesItsSuccessor(t *testing.T) {
srv, p, _, f, h := shareHub(t)
token, _ := authedShare(t, srv, h, p.ID, "wiki/notes.md")
at := time.Now().Add(time.Minute)
f.delAt("dev1", "wiki/notes.md", at)
if rec := do(t, h, "GET", "/s/"+token, nil); rec.Code != 404 {
t.Fatalf("share of a deleted file: %d, want 404", rec.Code)
}
// The leak this closes: something new lands on the address later, and
// the public link used to start serving it with no revoke and no signal.
f.putAt("dev1", "wiki/notes.md", "# Payroll", at.Add(time.Hour))
rec := do(t, h, "GET", "/s/"+token, nil)
if rec.Code != 404 {
t.Fatalf("share resurrected by an unrelated file: %d %s", rec.Code, rec.Body)
}
}
func TestShareUnaffectedByAnUnmovedFile(t *testing.T) {
srv, p, _, f, h := shareHub(t)
_ = f
token, _ := authedShare(t, srv, h, p.ID, "wiki/notes.md")
if rec := do(t, h, "GET", "/s/"+token, nil); rec.Code != 200 {
t.Fatalf("unmoved share: %d %s", rec.Code, rec.Body)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-BiaGRL-i.js"></script>
<script type="module" crossorigin src="/assets/index-u2Ih5Asg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BdCy9HmN.css">
</head>
<body>
@@ -28,6 +28,12 @@ bdrive url --sync wiki/report.html
With no argument, `bdrive url` gives the project home.
If someone later renames the file or drags it into a folder, the old URL keeps
working: the hub pairs the rename's two halves in the journal and redirects to
the file's new home, saying "Moved from …" above the content. A **live** path
always wins, though — if a brand-new file has since taken the old address, that
new file is what the URL serves, with no redirect.
:::tip[Agents do this automatically]
The sync hook `bdrive init` registers injects the project's
gated-link formula into the agent's context, so a connected agent appends
@@ -50,6 +56,12 @@ https://drive.example.com/s/eacc1df3ee6a6ebbdacc535c2796dc30
Links serve the file's **latest** synced content, which is the right behavior
for living reports and wiki pages, and live until they expire or you revoke them.
A share link points at one **file**, not at an address — the opposite of an
internal link. Move or rename the file and the link follows it, even if a new
file later takes the old path. Delete the file and the link 404s, and stays
404 forever: nothing that later appears at that path is ever served through a
link minted for something else.
```sh
bdrive share --list # every link you've minted
bdrive share --revoke <token-or-url> # kill one