//go:build linux package operator import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "io" "os" "os/exec" "path/filepath" "testing" "time" ) func TestReadonlySubprocess(t *testing.T) { if os.Getenv("OFICINA_READONLY_PROBE") == "1" { var input struct { Expected ReadonlyExpectation Mode string } if json.NewDecoder(io.LimitReader(os.Stdin, 16384)).Decode(&input) != nil { t.Fatal("input") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() calls := 0 proof := func(context.Context, ReadonlyExpectation) error { calls++ if input.Mode == "proof-revoked" && calls > 1 { return ErrRefused } return nil } if input.Mode == "missing-proof" { proof = nil } if input.Mode == "wrong-hash" { input.Expected.SHA256 = string(bytes.Repeat([]byte("0"), 64)) } if input.Mode == "wrong-inode" { input.Expected.Inode++ } if input.Mode == "expired" { cancel() } if input.Mode == "unbounded" { ctx = context.Background() } var extra *os.File defer func() { if extra != nil { extra.Close() } }() inspect := func(ctx context.Context, c *sql.Conn) error { if input.Mode == "unavailable" { return ErrUnavailable } if input.Mode == "spill" { var e error extra, e = os.Create(input.Expected.Path + ".spill") if e != nil { return e } } var count int if e := c.QueryRowContext(ctx, "SELECT count(*) FROM changes").Scan(&count); e != nil { return e } if count != 0 { return ErrRefused } if input.Mode == "write" { _, e := c.ExecContext(ctx, "INSERT INTO changes VALUES(1)") return e } return nil } lastStage := "before-inspection" hook := func(stage string) { lastStage = stage if input.Mode == "replace-restore" { if stage == "before-driver" { if os.Rename(input.Expected.Path, input.Expected.Path+".saved") != nil || os.Rename(input.Expected.Path+".other", input.Expected.Path) != nil { t.Fatal("swap") } } if stage == "after-open" { if os.Rename(input.Expected.Path, input.Expected.Path+".other") != nil || os.Rename(input.Expected.Path+".saved", input.Expected.Path) != nil { t.Fatal("restore") } } } if input.Mode == "change-after-read" && stage == "after-exec" { // Alter an unused SQLite header byte through an external process: no // extra descriptor is admitted into the checker process. cmd := exec.Command("python3", "-c", "import sys; f=open(sys.argv[1],'r+b'); f.seek(72); f.write(b'\\x01'); f.close()", input.Expected.Path) if e := cmd.Run(); e != nil { t.Fatal(e) } } } err := inspectReadonly(ctx, input.Expected, proof, inspect, hook) if input.Mode == "unavailable" && err != ErrUnavailable { t.Fatalf("lost unavailable outcome: %v", err) } if input.Mode == "valid" { if err != nil { t.Fatalf("inspection refused after %s: %v", lastStage, err) } } else if err == nil { t.Fatal("unsafe success", input.Mode) } return } for _, mode := range []string{"valid", "unavailable", "proof-revoked", "replace-restore", "missing-proof", "wrong-hash", "wrong-inode", "expired", "unbounded", "wal", "shm", "journal", "spill", "write", "change-after-read"} { t.Run(mode, func(t *testing.T) { path := filepath.Join(t.TempDir(), "test.db") seedOpenDB(t, path) if mode == "replace-restore" { seedOpenDB(t, path+".other") } raw, e := os.ReadFile(path) if e != nil { t.Fatal(e) } h := sha256.Sum256(raw) id, e := statExisting(path) if e != nil { t.Fatal(e) } expected := ReadonlyExpectation{path, id.device, id.inode, int64(len(raw)), hex.EncodeToString(h[:])} if mode == "wal" || mode == "shm" || mode == "journal" { if e := os.WriteFile(path+"-"+mode, []byte("retained"), 0600); e != nil { t.Fatal(e) } } input, _ := json.Marshal(struct { Expected ReadonlyExpectation Mode string }{expected, mode}) cmd := exec.Command(os.Args[0], "-test.run=^TestReadonlySubprocess$") cmd.Env = append(os.Environ(), "OFICINA_READONLY_PROBE=1") cmd.Stdin = bytes.NewReader(input) if out, e := cmd.CombinedOutput(); e != nil { t.Fatalf("child: %v %s", e, out) } if mode != "change-after-read" { after, e := os.ReadFile(path) if e != nil || !bytes.Equal(raw, after) { t.Fatal("database bytes changed") } } }) } }