diff --git a/README.md b/README.md index 34748ad..bb9cc5a 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ the project: ```jsonc // .bdrive/config.json { "id": "m-5a10b713", "volume": "notes", - "remote": "https://drive.example.com/p/p-7f3a2c91", "include": ["shared/"] } + "remote": "https://drive.example.com/p/p-7f3a2c91", "include": ["/shared/"] } ``` Opting out is non-destructive: when a pattern starts matching an diff --git a/cmd/bdrive/init.go b/cmd/bdrive/init.go index d8f30e5..84bef48 100644 --- a/cmd/bdrive/init.go +++ b/cmd/bdrive/init.go @@ -274,10 +274,12 @@ func chooseScope() ([]string, error) { return strings.Fields(strings.ReplaceAll(dirs, ",", " ")), nil } -// cleanShared normalizes --shared entries into include patterns ("wiki/"): -// slashes cleaned, duplicates dropped. Any entry that resolves to the mount -// root or escapes it is an error — a silently-dropped "." would widen the -// scope to the whole folder. +// cleanShared normalizes --shared entries into include patterns ("/wiki/"): +// slashes cleaned, duplicates dropped. The leading slash anchors the pattern +// to the mount root — without it a nested directory of the same name (say +// .claude/skills/x/wiki/) would match and sync too. Any entry that resolves +// to the mount root or escapes it is an error — a silently-dropped "." would +// widen the scope to the whole folder. func cleanShared(shared []string) ([]string, error) { var out []string seen := map[string]bool{} @@ -288,7 +290,7 @@ func cleanShared(shared []string) ([]string, error) { } if !seen[s] { seen[s] = true - out = append(out, s+"/") + out = append(out, "/"+s+"/") } } return out, nil diff --git a/cmd/bdrive/init_test.go b/cmd/bdrive/init_test.go index fe9ec66..a8d50c1 100644 --- a/cmd/bdrive/init_test.go +++ b/cmd/bdrive/init_test.go @@ -6,25 +6,28 @@ import ( ) func TestScopeRemove(t *testing.T) { - include := []string{"wiki/", "docs/", "*.md"} for _, tc := range []struct { - args []string - want []string - err bool + include []string + args []string + want []string + err bool }{ - {args: []string{"docs"}, want: []string{"wiki/", "*.md"}}, - {args: []string{"docs/"}, want: []string{"wiki/", "*.md"}}, // normalized match - {args: []string{"*.md"}, want: []string{"wiki/", "docs/"}}, // literal pattern match - {args: []string{"wiki", "docs"}, want: []string{"*.md"}}, - {args: []string{"notes"}, err: true}, // not in scope + {include: []string{"/wiki/", "/docs/", "*.md"}, args: []string{"docs"}, want: []string{"/wiki/", "*.md"}}, + {include: []string{"/wiki/", "/docs/", "*.md"}, args: []string{"docs/"}, want: []string{"/wiki/", "*.md"}}, // normalized match + {include: []string{"/wiki/", "/docs/", "*.md"}, args: []string{"*.md"}, want: []string{"/wiki/", "/docs/"}}, // literal pattern match + {include: []string{"/wiki/", "/docs/", "*.md"}, args: []string{"wiki", "docs"}, want: []string{"*.md"}}, + {include: []string{"/wiki/", "/docs/", "*.md"}, args: []string{"notes"}, err: true}, // not in scope + // A config written before include entries were anchored: the + // unanchored form must still be removable. + {include: []string{"wiki/", "docs/"}, args: []string{"wiki"}, want: []string{"docs/"}}, } { - got, err := scopeRemove(include, tc.args) + got, err := scopeRemove(tc.include, tc.args) if tc.err != (err != nil) { - t.Errorf("scopeRemove(%q) err = %v, want err %v", tc.args, err, tc.err) + t.Errorf("scopeRemove(%q, %q) err = %v, want err %v", tc.include, tc.args, err, tc.err) continue } if !tc.err && !reflect.DeepEqual(got, tc.want) { - t.Errorf("scopeRemove(%q) = %q, want %q", tc.args, got, tc.want) + t.Errorf("scopeRemove(%q, %q) = %q, want %q", tc.include, tc.args, got, tc.want) } } } @@ -36,10 +39,10 @@ func TestCleanShared(t *testing.T) { err bool }{ {in: nil, want: nil}, - {in: []string{"wiki"}, want: []string{"wiki/"}}, - {in: []string{"wiki", "docs"}, want: []string{"wiki/", "docs/"}}, - {in: []string{" wiki ", "./docs/", "wiki"}, want: []string{"wiki/", "docs/"}}, // trimmed, cleaned, deduped - {in: []string{"a/b"}, want: []string{"a/b/"}}, + {in: []string{"wiki"}, want: []string{"/wiki/"}}, // anchored: only the root-level wiki/ + {in: []string{"wiki", "docs"}, want: []string{"/wiki/", "/docs/"}}, + {in: []string{" wiki ", "./docs/", "wiki"}, want: []string{"/wiki/", "/docs/"}}, // trimmed, cleaned, deduped + {in: []string{"a/b"}, want: []string{"/a/b/"}}, {in: []string{""}, err: true}, {in: []string{"wiki", ""}, err: true}, // "wiki,,docs" typo must not half-apply {in: []string{"."}, err: true}, // would silently mean whole-folder sync diff --git a/cmd/bdrive/scope.go b/cmd/bdrive/scope.go index edafe86..f3bf7c8 100644 --- a/cmd/bdrive/scope.go +++ b/cmd/bdrive/scope.go @@ -128,14 +128,17 @@ func scopeRmCmd() *cobra.Command { } // scopeRemove drops the named dirs from the include list, matching each -// argument both literally and in normalized "dir/" form (hand-edited -// configs may hold arbitrary patterns). Unknown entries are an error. +// argument literally, in normalized "/dir/" form, and in the pre-anchoring +// "dir/" form that configs written before the anchoring fix still hold +// (hand-edited configs may hold arbitrary patterns). Unknown entries are an +// error. func scopeRemove(include, args []string) ([]string, error) { remove := map[string]bool{} for _, a := range args { keys := map[string]bool{strings.TrimSpace(a): true} if norm, err := cleanShared([]string{a}); err == nil { keys[norm[0]] = true + keys[strings.TrimPrefix(norm[0], "/")] = true } found := false for _, i := range include { @@ -164,6 +167,6 @@ func printScope(proj config.Project) { } fmt.Println("syncing only:") for _, i := range proj.Include { - fmt.Println(" ./" + strings.TrimSuffix(i, "/")) + fmt.Println(" ./" + strings.Trim(i, "/")) } } diff --git a/internal/config/project.go b/internal/config/project.go index 8407b98..679e581 100644 --- a/internal/config/project.go +++ b/internal/config/project.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // ProjectDir is the per-folder settings directory at the mount root. It @@ -63,9 +64,27 @@ func LoadProject(folder string) (Project, bool, error) { if err := json.Unmarshal(data, &p); err != nil { return p, false, fmt.Errorf("parse %s: %w", projectConfigPath(folder), err) } + p.Include = normalizeInclude(p.Include) return p, true, nil } +// normalizeInclude anchors bare single-segment include entries to the mount +// root, so a config written before the fix ("wiki/") stops matching nested +// directories of the same name without needing a re-init. Only single-segment +// entries need it: compile() already anchors anything containing a slash. +// Entries with glob syntax are left alone — a hand-written pattern is a +// deliberate pattern. +func normalizeInclude(include []string) []string { + for n, i := range include { + s := strings.TrimSuffix(i, "/") + if s == "" || strings.ContainsAny(s, "/*?[!") { + continue + } + include[n] = "/" + i + } + return include +} + // SaveProject writes /.bdrive/config.json, assigning a mount ID on // first save. func SaveProject(folder string, p Project) (Project, error) { diff --git a/internal/config/project_test.go b/internal/config/project_test.go new file mode 100644 index 0000000..7219d12 --- /dev/null +++ b/internal/config/project_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestNormalizeInclude(t *testing.T) { + for _, tc := range []struct { + in []string + want []string + }{ + {in: nil, want: nil}, + {in: []string{"wiki/"}, want: []string{"/wiki/"}}, // legacy config, anchored on read + {in: []string{"wiki"}, want: []string{"/wiki"}}, + {in: []string{"/wiki/"}, want: []string{"/wiki/"}}, // already anchored + {in: []string{"a/b/"}, want: []string{"a/b/"}}, // compile() anchors these already + {in: []string{"*.md"}, want: []string{"*.md"}}, // deliberate pattern, left alone + {in: []string{"!keep"}, want: []string{"!keep"}}, + {in: []string{"wiki/", "*.md"}, want: []string{"/wiki/", "*.md"}}, + } { + in := append([]string(nil), tc.in...) + if got := normalizeInclude(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Errorf("normalizeInclude(%q) = %q, want %q", in, got, tc.want) + } + } +} diff --git a/internal/syncer/filter_sync_test.go b/internal/syncer/filter_sync_test.go index 9cb3575..44b81e4 100644 --- a/internal/syncer/filter_sync_test.go +++ b/internal/syncer/filter_sync_test.go @@ -76,3 +76,29 @@ func TestIncludeListLimitsSync(t *testing.T) { } } } + +// The shared-subfolder scope is anchored at the mount root: a nested +// directory that happens to share the name must never sync, or private +// material leaks out of a project scoped to one folder. +func TestIncludeListIsRootAnchored(t *testing.T) { + for _, include := range []string{"/shared/", "shared/"} { // new form, and pre-fix configs + t.Run(include, func(t *testing.T) { + be := sharedRemote(t) + a := newDevice(t, "deva", be) + b := newDevice(t, "devb", be) + + write(t, a.Folder, ".bdrive/config.json", `{"include": ["`+include+`"]}`) + write(t, a.Folder, "shared/a.txt", "included") + write(t, a.Folder, ".claude/skills/x/shared/leak.mjs", "private") + cycle(t, a) + cycle(t, b) + + if got := read(t, b.Folder, "shared/a.txt"); got != "included" { + t.Fatalf("shared/a.txt = %q", got) + } + if _, err := os.Stat(filepath.Join(b.Folder, ".claude/skills/x/shared/leak.mjs")); !os.IsNotExist(err) { + t.Fatal("a nested dir named shared/ must not sync") + } + }) + } +} diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index 64d908f..d88721f 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -48,7 +48,7 @@ Two files at the mount root control a folder's sync behavior: "id": "m-5a10b713", "volume": "agent-workspace", "remote": "https://drive.example.com/p/p-7f3a2c91", - "include": ["shared/"] // optional: sync ONLY these (init --shared; edit with bdrive scope add/rm) + "include": ["/shared/"] // optional: sync ONLY these (init --shared; edit with bdrive scope add/rm) } ``` diff --git a/web/docs/src/content/docs/guides/scoping.md b/web/docs/src/content/docs/guides/scoping.md index 7a72984..35255b7 100644 --- a/web/docs/src/content/docs/guides/scoping.md +++ b/web/docs/src/content/docs/guides/scoping.md @@ -29,9 +29,13 @@ The result lands in `.bdrive/config.json` as an include list: ```jsonc { "id": "m-5a10b713", "volume": "notes", - "remote": "https://drive.example.com/p/p-7f3a2c91", "include": ["wiki/"] } + "remote": "https://drive.example.com/p/p-7f3a2c91", "include": ["/wiki/"] } ``` +The leading slash anchors each entry to the mount root: `/wiki/` means the +`wiki` folder at the top of this project and nothing else, so a nested +directory that happens to share the name never syncs. + ## Change the scope later `bdrive scope` shows the include list; `scope add` / `scope rm` edit it — no diff --git a/web/docs/src/content/docs/reference/project-files.md b/web/docs/src/content/docs/reference/project-files.md index 9cb5458..d852cdc 100644 --- a/web/docs/src/content/docs/reference/project-files.md +++ b/web/docs/src/content/docs/reference/project-files.md @@ -14,7 +14,7 @@ plus project, remote, and include settings. ```jsonc // .bdrive/config.json { "id": "m-5a10b713", "volume": "notes", - "remote": "https://drive.example.com/p/p-7f3a2c91", "include": ["shared/"] } + "remote": "https://drive.example.com/p/p-7f3a2c91", "include": ["/shared/"] } ``` Written by `bdrive init` and safe to hand-edit — a running daemon picks changes