Skip to content

Commit 3605efd

Browse files
committed
merge: 🛡️ devbox stale-ours no longer destroys an edit in the pull window
2 parents 6ea5c22 + f1a6ac2 commit 3605efd

3 files changed

Lines changed: 141 additions & 0 deletions

File tree

internal/manifest/manifest.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,35 @@ func Build(root string, ig *ignore.Matcher, guard *secret.Guard) (Manifest, []st
102102
return Manifest{Entries: entries}, blocked, nil
103103
}
104104

105+
// BuildFile re-stats and re-hashes a single file at root/relPath, the way Build
106+
// would if it walked to just that path. Used to re-check one path against a
107+
// previously snapshotted Entry without re-walking (and re-hashing) the whole
108+
// tree. ok is false if the path is no longer a regular file — moved, deleted,
109+
// or replaced by a directory — since callers that hold a snapshot from an
110+
// earlier Build have no lock on the tree in between; that's not an error, just
111+
// "nothing here to compare".
112+
func BuildFile(root, relPath string) (Entry, bool, error) {
113+
p := filepath.Join(root, filepath.FromSlash(relPath))
114+
info, err := os.Stat(p)
115+
if err != nil || !info.Mode().IsRegular() {
116+
return Entry{}, false, nil
117+
}
118+
f, err := os.Open(p)
119+
if err != nil {
120+
return Entry{}, false, nil
121+
}
122+
cs, err := chunk.SplitReader(f)
123+
f.Close()
124+
if err != nil {
125+
return Entry{}, false, err
126+
}
127+
var hashes []string
128+
for _, c := range cs {
129+
hashes = append(hashes, c.Hash)
130+
}
131+
return Entry{Path: relPath, Mode: uint32(info.Mode().Perm()), Size: info.Size(), Chunks: hashes}, true, nil
132+
}
133+
105134
// Marshal returns the manifest's canonical JSON bytes. Build sorts Entries, so
106135
// this is deterministic and serves as the content-addressed manifest blob.
107136
func (m Manifest) Marshal() ([]byte, error) {

internal/syncer/pull.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ func Pull(c *transport.Client, root, share, subpath, base, host string, now int6
8181
continue // hub didn't touch p; local state (changed or not) stands
8282
}
8383

84+
// `ours` was snapshotted once by manifest.Build before this loop started
85+
// (pull.go:60); a local edit landing after that snapshot but before we
86+
// get here would otherwise look unchanged and get silently clobbered
87+
// below. Re-check the on-disk file against its snapshotted entry right
88+
// before we might overwrite it — only when hub is about to write (tOK)
89+
// and ours already had an entry to compare against (oOK); a pure hub-only
90+
// add (!oOK) has nothing on disk yet to race with. Folding a detected
91+
// edit into oursCh routes it through the existing conflict-preserving
92+
// branch below instead of adding a second write path. This narrows the
93+
// clobber window; it doesn't close it (a later edit can still land
94+
// between this check and the writeEntry rename at pull.go:424-451).
95+
if !oursCh && tOK && oOK {
96+
if cur, ok, serr := manifest.BuildFile(root, p); serr == nil && ok && !manifest.SameContent(cur, oe) {
97+
oursCh = true
98+
}
99+
}
100+
84101
switch {
85102
case !oursCh:
86103
// Hub-only change: apply it verbatim.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package syncer
2+
3+
import (
4+
"net/http/httptest"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/shoemoney/devbox/internal/hooks"
11+
"github.com/shoemoney/devbox/internal/hub"
12+
"github.com/shoemoney/devbox/internal/hub/blobstore"
13+
"github.com/shoemoney/devbox/internal/hub/meta"
14+
"github.com/shoemoney/devbox/internal/secret"
15+
)
16+
17+
// Repro for audit finding: `ours` is snapshotted once (manifest.Build, pull.go:60)
18+
// before any writes; a local edit landing after Build but before writeEntry still
19+
// looks "unchanged vs base", so the hub version is applied verbatim with no
20+
// conflict copy. We make the timing deterministic with a pre-pull hook, which
21+
// runs exactly in that window (after Build, before the apply loop).
22+
func TestAuditReproStaleOursOverwritesFreshEdit(t *testing.T) {
23+
db, _ := meta.Open(":memory:")
24+
defer db.Close()
25+
store, _ := blobstore.NewDisk(t.TempDir())
26+
srv := httptest.NewServer(hub.NewServer(db, store).Handler())
27+
defer srv.Close()
28+
guard, _ := secret.New(nil)
29+
ig, _ := LoadIgnore(t.TempDir())
30+
31+
A := joinDevice(t, db, srv.URL, "alice")
32+
B := joinDevice(t, db, srv.URL, "bob")
33+
if err := A.Publish("s"); err != nil {
34+
t.Fatal(err)
35+
}
36+
37+
// A publishes f.txt = v1 (this becomes A's base).
38+
rootA := t.TempDir()
39+
writeFile(t, rootA, "f.txt", "v1\n")
40+
snapA, _, err := Sync(A, rootA, "s", "", "", "alice", 1, ig, guard, nil)
41+
if err != nil {
42+
t.Fatalf("A sync: %v", err)
43+
}
44+
45+
// B pulls, edits f.txt = v2-hub, pushes: hub-only change relative to A's base.
46+
rootB := t.TempDir()
47+
baseB, _, err := Sync(B, rootB, "s", "", "", "bob", 2, ig, guard, nil)
48+
if err != nil {
49+
t.Fatalf("B sync: %v", err)
50+
}
51+
writeFile(t, rootB, "f.txt", "v2-hub\n")
52+
if _, _, err := Sync(B, rootB, "s", "", baseB, "bob", 3, ig, guard, nil); err != nil {
53+
t.Fatalf("B sync2: %v", err)
54+
}
55+
56+
// A's pre-pull hook simulates the user saving f.txt AFTER manifest.Build has
57+
// snapshotted `ours` but BEFORE writeEntry applies the hub version.
58+
hookDir := hooks.Dir(rootA)
59+
if err := os.MkdirAll(hookDir, 0o755); err != nil {
60+
t.Fatal(err)
61+
}
62+
hookScript := "#!/usr/bin/env bash\nprintf 'USER-EDIT-IN-WINDOW\\n' > \"$DEVBOX_MOUNT/f.txt\"\n"
63+
if err := os.WriteFile(filepath.Join(hookDir, "pre-pull"), []byte(hookScript), 0o755); err != nil {
64+
t.Fatal(err)
65+
}
66+
67+
hk := hooks.New(rootA, "s", "alice", srv.URL)
68+
pr, err := Pull(A, rootA, "s", "", snapA, "alice", 4, ig, guard, hk)
69+
if err != nil {
70+
t.Fatalf("A pull: %v", err)
71+
}
72+
t.Logf("pull: written=%v deleted=%v conflicts=%v skipped=%v", pr.Written, pr.Deleted, pr.Conflicts, pr.Skipped)
73+
74+
got := readFile(t, rootA, "f.txt")
75+
entries, _ := os.ReadDir(rootA)
76+
var conflictFiles []string
77+
for _, e := range entries {
78+
if strings.Contains(e.Name(), ".conflict-") {
79+
conflictFiles = append(conflictFiles, e.Name())
80+
}
81+
}
82+
t.Logf("f.txt=%q conflictFiles=%v", got, conflictFiles)
83+
84+
// The user's bytes must survive somewhere: either f.txt still holds them,
85+
// or a conflict copy does. If neither, the edit was destroyed.
86+
if got == "USER-EDIT-IN-WINDOW\n" {
87+
return // edit survived in place
88+
}
89+
for _, cf := range conflictFiles {
90+
if readFile(t, rootA, cf) == "USER-EDIT-IN-WINDOW\n" {
91+
return // preserved as conflict copy
92+
}
93+
}
94+
t.Fatalf("DATA LOSS CONFIRMED: local edit made during pull window destroyed — f.txt=%q, conflicts=%v (bytes exist in no file and no snapshot)", got, conflictFiles)
95+
}

0 commit comments

Comments
 (0)