mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
339 lines
10 KiB
Go
339 lines
10 KiB
Go
package main
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"errors"
|
|||
|
|
"os"
|
|||
|
|
"path/filepath"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|||
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// grepMount builds an isolated BDRIVE_HOME with one enrolled project folder.
|
|||
|
|
// Nothing here contacts a hub: grep is a local read.
|
|||
|
|
func grepMount(t *testing.T) string {
|
|||
|
|
t.Helper()
|
|||
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|||
|
|
folder := t.TempDir()
|
|||
|
|
folder, _ = filepath.EvalSymlinks(folder)
|
|||
|
|
if _, err := config.SaveProject(folder, config.Project{
|
|||
|
|
Volume: "wiki",
|
|||
|
|
Remote: "https://hub.example.com/p/p-12345678",
|
|||
|
|
}); err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
if _, _, err := config.EnrollMount(folder); err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
return folder
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func grepWrite(t *testing.T, folder, rel, body string) {
|
|||
|
|
t.Helper()
|
|||
|
|
abs := filepath.Join(folder, filepath.FromSlash(rel))
|
|||
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// grepRun drives the real cobra command and returns its combined output plus
|
|||
|
|
// the error it exited with (errNoMatch is grep's status-1 no-match).
|
|||
|
|
func grepRun(t *testing.T, args ...string) (string, error) {
|
|||
|
|
t.Helper()
|
|||
|
|
c := grepCmd()
|
|||
|
|
var out bytes.Buffer
|
|||
|
|
c.SetOut(&out)
|
|||
|
|
c.SetErr(&out)
|
|||
|
|
c.SetArgs(args)
|
|||
|
|
err := c.Execute()
|
|||
|
|
return out.String(), err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestGrepPrintsMatchingLines(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
grepWrite(t, folder, "wiki/runbook.md", "intro\nthe retention fold collapses day buckets\ntail\n")
|
|||
|
|
grepWrite(t, folder, "specs/reads.md", "retention folding happens at boot\n")
|
|||
|
|
grepWrite(t, folder, "unrelated.md", "nothing to see\n")
|
|||
|
|
|
|||
|
|
out, err := grepRun(t, "retention.*fold", folder)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("grep: %v\n%s", err, out)
|
|||
|
|
}
|
|||
|
|
for _, want := range []string{
|
|||
|
|
"wiki/runbook.md:2: the retention fold collapses day buckets",
|
|||
|
|
"specs/reads.md:1: retention folding happens at boot",
|
|||
|
|
"2 files, 2 matching lines",
|
|||
|
|
} {
|
|||
|
|
if !strings.Contains(out, want) {
|
|||
|
|
t.Errorf("missing %q in:\n%s", want, out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if strings.Contains(out, "unrelated.md") {
|
|||
|
|
t.Errorf("non-matching file printed:\n%s", out)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -l prints bare paths, one per line, and no summary.
|
|||
|
|
out, err = grepRun(t, "-l", "retention", folder)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("grep -l: %v\n%s", err, out)
|
|||
|
|
}
|
|||
|
|
got := strings.Fields(out)
|
|||
|
|
if len(got) != 2 || got[0] != "specs/reads.md" || got[1] != "wiki/runbook.md" {
|
|||
|
|
t.Errorf("-l should print two bare paths, got %q", out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestGrepFlags(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
grepWrite(t, folder, "notes.md", "TODO later\ntodo now\nliteral a[b]c here\n")
|
|||
|
|
|
|||
|
|
// Default is case-sensitive; -i widens it.
|
|||
|
|
out, _ := grepRun(t, "TODO", folder)
|
|||
|
|
if strings.Contains(out, "todo now") {
|
|||
|
|
t.Errorf("case-sensitive match leaked:\n%s", out)
|
|||
|
|
}
|
|||
|
|
out, err := grepRun(t, "-i", "TODO", folder)
|
|||
|
|
if err != nil || !strings.Contains(out, "todo now") {
|
|||
|
|
t.Errorf("-i should match both cases: %v\n%s", err, out)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -F takes the pattern literally: as a regexp a[b]c matches "abc", which
|
|||
|
|
// is not in the file — only the literal text is.
|
|||
|
|
if _, err := grepRun(t, "a[b]c", folder); !errors.Is(err, errNoMatch) {
|
|||
|
|
t.Errorf("regexp a[b]c should not match, got %v", err)
|
|||
|
|
}
|
|||
|
|
out, err = grepRun(t, "-F", "a[b]c", folder)
|
|||
|
|
if err != nil || !strings.Contains(out, "literal a[b]c here") {
|
|||
|
|
t.Errorf("-F should match literally: %v\n%s", err, out)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A bad pattern is a real error, printed, not a silent no-match.
|
|||
|
|
out, err = grepRun(t, "a(", folder)
|
|||
|
|
if err == nil || errors.Is(err, errNoMatch) {
|
|||
|
|
t.Errorf("bad pattern should error, got %v", err)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(out, "bad pattern") {
|
|||
|
|
t.Errorf("bad pattern should say so:\n%s", out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestGrepLimit(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
var body strings.Builder
|
|||
|
|
for i := 0; i < 500; i++ {
|
|||
|
|
body.WriteString("hit\n")
|
|||
|
|
}
|
|||
|
|
grepWrite(t, folder, "many.md", body.String())
|
|||
|
|
|
|||
|
|
// Default caps at 200 and says so.
|
|||
|
|
out, err := grepRun(t, "hit", folder)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
if n := strings.Count(out, "many.md:"); n != 200 {
|
|||
|
|
t.Errorf("default limit should print 200 lines, got %d", n)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(out, "output limited to 200 lines") {
|
|||
|
|
t.Errorf("truncation should be announced:\n%s", out[len(out)-200:])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -n 0 means all.
|
|||
|
|
out, err = grepRun(t, "-n", "0", "hit", folder)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
if n := strings.Count(out, "many.md:"); n != 500 {
|
|||
|
|
t.Errorf("-n 0 should print every line, got %d", n)
|
|||
|
|
}
|
|||
|
|
if strings.Contains(out, "output limited") {
|
|||
|
|
t.Errorf("-n 0 must not announce truncation:\n%s", out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Results match what the project actually syncs: the same .bdriveignore rule
|
|||
|
|
// that keeps a file off the hub keeps it out of search.
|
|||
|
|
func TestGrepRespectsTheSyncFilter(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
grepWrite(t, folder, "keep.md", "needle\n")
|
|||
|
|
grepWrite(t, folder, "drafts/skip.md", "needle\n")
|
|||
|
|
|
|||
|
|
out, err := grepRun(t, "needle", folder)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(out, "drafts/skip.md") {
|
|||
|
|
t.Fatalf("drafts/skip.md should match before the rule:\n%s", out)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
grepWrite(t, folder, ".bdriveignore", "drafts/\n")
|
|||
|
|
out, err = grepRun(t, "needle", folder)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
if strings.Contains(out, "drafts/skip.md") {
|
|||
|
|
t.Errorf("an ignored path must not be searchable:\n%s", out)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(out, "keep.md") {
|
|||
|
|
t.Errorf("keep.md should still match:\n%s", out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// .bdrive/ holds the project's own settings and .bdrive-tmp-* are half-written
|
|||
|
|
// state files. Neither is project content and neither may ever surface.
|
|||
|
|
func TestGrepNeverSearchesBdriveState(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
grepWrite(t, folder, "real.md", "p-12345678\n")
|
|||
|
|
grepWrite(t, folder, ".bdrive-tmp-half", "p-12345678\n")
|
|||
|
|
|
|||
|
|
out, err := grepRun(t, "p-12345678", folder)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
// .bdrive/config.json contains the remote URL, so it would match.
|
|||
|
|
if strings.Contains(out, ".bdrive/") || strings.Contains(out, ".bdrive-tmp-") {
|
|||
|
|
t.Errorf("bdrive state leaked into results:\n%s", out)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(out, "real.md") {
|
|||
|
|
t.Errorf("real.md should match:\n%s", out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestGrepSkipsBinaryFiles(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
grepWrite(t, folder, "text.md", "needle here\n")
|
|||
|
|
// A NUL inside the first 8 KB is the binary rule.
|
|||
|
|
grepWrite(t, folder, "blob.bin", "needle here\x00 and more\n")
|
|||
|
|
// A NUL past the sniff window is not: the file reads as text.
|
|||
|
|
grepWrite(t, folder, "late.bin", strings.Repeat("x\n", 6000)+"needle here\n\x00")
|
|||
|
|
|
|||
|
|
out, err := grepRun(t, "needle", folder)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
if strings.Contains(out, "blob.bin") {
|
|||
|
|
t.Errorf("binary file searched:\n%s", out)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(out, "text.md") || !strings.Contains(out, "late.bin") {
|
|||
|
|
t.Errorf("text files should match:\n%s", out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Every matched line is content a teammate wrote and synced — a strictly wider
|
|||
|
|
// version of the surface safeField exists for. A planted file must not be able
|
|||
|
|
// to repaint or reverse the operator's terminal.
|
|||
|
|
func TestGrepOutputCannotRewriteTheTerminal(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
hostile := "needle \x1b[2J\x1b[3J\x1b[H\rgnitset\x9b31m\x7f end"
|
|||
|
|
grepWrite(t, folder, "planted.md", hostile+"\n")
|
|||
|
|
// The path is attacker-controlled too: a teammate chooses the file name.
|
|||
|
|
grepWrite(t, folder, "name\x1b[31m.md", "needle\n")
|
|||
|
|
|
|||
|
|
for _, args := range [][]string{{"needle", folder}, {"-l", "needle", folder}} {
|
|||
|
|
out, err := grepRun(t, args...)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("grep %v: %v", args, err)
|
|||
|
|
}
|
|||
|
|
for _, bad := range []string{"\x1b", "\r", "", "", "\x9b", "\x7f"} {
|
|||
|
|
if strings.Contains(out, bad) {
|
|||
|
|
t.Errorf("grep %v leaked %q into the terminal:\n%q", args, bad, out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// One match is one line: no forged rows.
|
|||
|
|
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
|||
|
|
for _, r := range line {
|
|||
|
|
if r < 0x20 && r != '\t' || r == 0x7f {
|
|||
|
|
t.Errorf("control rune %q survived in %q", r, line)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A read-only query must not enroll this device: LoadProject, never
|
|||
|
|
// ResolveMount. Outside a project it says so, exits non-zero, and the registry
|
|||
|
|
// is untouched.
|
|||
|
|
func TestGrepOutsideAProjectWritesNothing(t *testing.T) {
|
|||
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|||
|
|
folder := t.TempDir()
|
|||
|
|
grepWrite(t, folder, "loose.md", "needle\n")
|
|||
|
|
|
|||
|
|
mounts := filepath.Join(os.Getenv("BDRIVE_HOME"), "mounts.json")
|
|||
|
|
before, beforeErr := os.ReadFile(mounts)
|
|||
|
|
|
|||
|
|
out, err := grepRun(t, "needle", folder)
|
|||
|
|
if err == nil || errors.Is(err, errNoMatch) {
|
|||
|
|
t.Fatalf("should fail outside a project, got %v", err)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(out, "not a beardrive project") {
|
|||
|
|
t.Errorf("message should name the problem:\n%s", out)
|
|||
|
|
}
|
|||
|
|
after, afterErr := os.ReadFile(mounts)
|
|||
|
|
if (beforeErr == nil) != (afterErr == nil) || !bytes.Equal(before, after) {
|
|||
|
|
t.Errorf("the registry was written by a read-only query")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Exit codes are grep's, so `bdrive grep x || …` composes: 0 on match, 1 on
|
|||
|
|
// no match with nothing printed at all.
|
|||
|
|
func TestGrepExitCodes(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
grepWrite(t, folder, "a.md", "hay\n")
|
|||
|
|
|
|||
|
|
if out, err := grepRun(t, "hay", folder); err != nil {
|
|||
|
|
t.Errorf("match should exit 0: %v\n%s", err, out)
|
|||
|
|
}
|
|||
|
|
out, err := grepRun(t, "needle", folder)
|
|||
|
|
if !errors.Is(err, errNoMatch) {
|
|||
|
|
t.Errorf("no match should return errNoMatch, got %v", err)
|
|||
|
|
}
|
|||
|
|
if out != "" {
|
|||
|
|
t.Errorf("no match must print nothing, got %q", out)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// grep opens the volume store read-only for one field and never takes the
|
|||
|
|
// volume flock, so a daemon mid-cycle cannot make a search hang.
|
|||
|
|
func TestGrepDoesNotBlockOnTheVolumeLock(t *testing.T) {
|
|||
|
|
folder := grepMount(t)
|
|||
|
|
grepWrite(t, folder, "a.md", "needle\n")
|
|||
|
|
|
|||
|
|
proj, found, err := config.LoadProject(folder)
|
|||
|
|
if err != nil || !found {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
vdir, err := config.VolumeDir(proj.ID)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
st, err := store.Open(vdir)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
unlock, err := st.Lock() // stand in for a cycle in progress
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
defer unlock()
|
|||
|
|
|
|||
|
|
done := make(chan string, 1)
|
|||
|
|
go func() {
|
|||
|
|
out, _ := grepRun(t, "needle", folder)
|
|||
|
|
done <- out
|
|||
|
|
}()
|
|||
|
|
select {
|
|||
|
|
case out := <-done:
|
|||
|
|
if !strings.Contains(out, "a.md:1: needle") {
|
|||
|
|
t.Errorf("grep under the lock should still find it:\n%s", out)
|
|||
|
|
}
|
|||
|
|
case <-time.After(5 * time.Second):
|
|||
|
|
t.Fatal("grep blocked on the volume lock")
|
|||
|
|
}
|
|||
|
|
}
|