Files
beardrive/cmd/bdrive/grep_test.go
T

339 lines
10 KiB
Go
Raw Normal View History

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")
}
}