package main // The CLI half of `carlos db` (spec 2026-09-02 §4): the read-only query, the // pull's landing rules, and the wire shape the session's replica client // sends. The whole path through a real console is db_e2e_test.go. import ( "bytes" "context" "crypto/sha256" "database/sql" "errors" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "slices" "strings" "testing" "github.com/carlosframework/platform/internal/dbdoor" ) // fixtureDB writes a small database the way a restore leaves one: WAL mode, // one table, a few rows, checkpointed into the main file. func fixtureDB(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "copy.db") db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } if _, err := db.Exec(`PRAGMA journal_mode=WAL; CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, note TEXT); INSERT INTO users (name, note) VALUES ('ada', NULL), ('grace', 'x'); PRAGMA wal_checkpoint(TRUNCATE);`); err != nil { t.Fatal(err) } if err := db.Close(); err != nil { t.Fatal(err) } return path } func sha(t *testing.T, path string) [32]byte { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return sha256.Sum256(b) } // A copy is harmless to write to. Read-only exists so a caller cannot // BELIEVE it changed production: an agent that sees "1 row affected" // concludes it fixed the bug. The error is the truth, in SQLite's words. func TestQueryRefusesAWriteWithSQLitesOwnError(t *testing.T) { path := fixtureDB(t) for _, stmt := range []string{ "UPDATE users SET name='x'", "DELETE FROM users", "INSERT INTO users (name) VALUES ('mallory')", "CREATE TABLE t2 (x)", } { err := runQuery(&bytes.Buffer{}, path, stmt) if err == nil || !strings.Contains(err.Error(), "attempt to write a readonly database") { t.Errorf("%s: error = %v, want SQLite's readonly refusal", stmt, err) } } } // query_only is set as well as mode=ro, and a statement list cannot talk // its way past either: switching query_only off first still leaves the // write refused, and the data unchanged. func TestQueryCannotTurnOffItsOwnGuard(t *testing.T) { path := fixtureDB(t) for _, stmt := range []string{"PRAGMA query_only=0; UPDATE users SET name='x'", "PRAGMA query_only=0"} { if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil { t.Fatalf("%q: succeeded, want a refusal", stmt) } } var out bytes.Buffer if err := runQuery(&out, path, "SELECT name FROM users ORDER BY id"); err != nil || out.String() != "name\nada\ngrace\n" { t.Fatalf("read after = %v %q, want the rows unchanged", err, out.String()) } } func TestQueryLeavesTheFileByteIdentical(t *testing.T) { path := fixtureDB(t) before := sha(t, path) _ = runQuery(&bytes.Buffer{}, path, "UPDATE users SET name='x'") if err := runQuery(&bytes.Buffer{}, path, "SELECT * FROM users"); err != nil { t.Fatal(err) } if sha(t, path) != before { t.Fatal("the database file changed under a read-only query") } } // Output is for an agent as much as a person: a header row, then one line // per row, tab-separated, NULL spelled out. func TestQueryPrintsAHeaderAndTabSeparatedRows(t *testing.T) { path := fixtureDB(t) var out bytes.Buffer if err := runQuery(&out, path, "SELECT id, name, note FROM users ORDER BY id"); err != nil { t.Fatal(err) } want := "id\tname\tnote\n1\tada\tNULL\n2\tgrace\tx\n" if out.String() != want { t.Fatalf("output = %q, want %q", out.String(), want) } } func TestQueryReportsABadStatement(t *testing.T) { if err := runQuery(&bytes.Buffer{}, fixtureDB(t), "SELEKT 1"); err == nil { t.Fatal("a syntax error succeeded") } } // --out must not exist, and what lands is 0600 whatever the umask. func TestPullRefusesAnExistingOutAndLands0600(t *testing.T) { src := fixtureDB(t) dir := t.TempDir() existing := filepath.Join(dir, "taken.db") if err := os.WriteFile(existing, []byte("mine"), 0o644); err != nil { t.Fatal(err) } if err := copyOut(context.Background(), src, existing); err == nil { t.Fatal("copyOut over an existing file succeeded") } if b, _ := os.ReadFile(existing); string(b) != "mine" { t.Fatal("copyOut touched the existing file") } out := filepath.Join(dir, "new.db") if err := copyOut(context.Background(), src, out); err != nil { t.Fatal(err) } fi, err := os.Stat(out) if err != nil { t.Fatal(err) } if perm := fi.Mode().Perm(); perm != 0o600 { t.Fatalf("pulled copy is %04o, want 0600", perm) } if sha(t, out) != sha(t, src) { t.Fatal("pulled copy differs from the restored one") } } // os.Rename fails across devices, and the temp directory and --out are // routinely on different filesystems. A copy leaves its source in place; a // rename would not. func TestPullCopiesAcrossFilesystems(t *testing.T) { src := fixtureDB(t) out := filepath.Join(t.TempDir(), "elsewhere", "pulled.db") if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { t.Fatal(err) } if err := copyOut(context.Background(), src, out); err != nil { t.Fatal(err) } if _, err := os.Stat(src); err != nil { t.Fatalf("the source is gone after copyOut (%v): that was a rename, which fails across filesystems", err) } } // A failed copy leaves nothing at --out. func TestPullLeavesNoPartialFile(t *testing.T) { out := filepath.Join(t.TempDir(), "pulled.db") if err := copyOut(context.Background(), filepath.Join(t.TempDir(), "missing.db"), out); err == nil { t.Fatal("copyOut of a missing source succeeded") } if _, err := os.Stat(out); !os.IsNotExist(err) { t.Fatalf("a failed copy left %s behind (%v)", out, err) } } // The session's replica client sends the coordinate under the app's db // path, carries the account segment and its ?exact=sqid switch verbatim, // and authenticates as the logged-in person. func TestAPISessionDBClientWireShape(t *testing.T) { var gotURI, gotAuth, gotRange string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotURI, gotAuth, gotRange = r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("Range") w.WriteHeader(http.StatusPartialContent) })) defer ts.Close() c := newTestSession(ts.URL).dbClient(apiAcct{Seg: "bab", Exact: true}, "titogo", "a.go.tito.io") rc, err := c.OpenLTXFile(context.Background(), 3, 16, 32, 0, 0) if err != nil { t.Fatal(err) } rc.Close() if want := "/api/cli/apps/bab/titogo/db/a.go.tito.io" + dbdoor.GetTail(3, 16, 32) + "?exact=sqid"; gotURI != want { t.Errorf("uri = %s, want %s", gotURI, want) } if gotAuth != "Bearer cli-token" || gotRange != "bytes=0-" { t.Errorf("auth %q range %q", gotAuth, gotRange) } } // Both guards are on, not one: mode=ro alone would pass every write test // above, so query_only is pinned on its own. func TestQueryRunsWithQueryOnlySet(t *testing.T) { var out bytes.Buffer if err := runQuery(&out, fixtureDB(t), "PRAGMA query_only"); err != nil || out.String() != "query_only\n1\n" { t.Fatalf("PRAGMA query_only = %v %q, want 1", err, out.String()) } } // --instance may be left out only when the app has exactly one. Several is // a refusal that names them all; guessing would hand over the wrong data // while saying it was the right data (spec §7). func TestResolveDBInstance(t *testing.T) { answer := "" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/bab/titogo/instances" { t.Errorf("path = %s", r.URL.Path) } w.Write([]byte(answer)) })) defer ts.Close() sess := newTestSession(ts.URL) ctx := context.Background() acct := apiAcct{Seg: "bab"} if h, err := resolveDBInstance(ctx, sess, acct, "titogo", "named.go.tito.io"); err != nil || h != "named.go.tito.io" { t.Fatalf("named = %q %v", h, err) } answer = `{"instances":[{"host":"only.go.tito.io"}]}` if h, err := resolveDBInstance(ctx, sess, acct, "titogo", ""); err != nil || h != "only.go.tito.io" { t.Fatalf("one instance = %q %v", h, err) } answer = `{"instances":[{"host":"b.go.tito.io"},{"host":"a.go.tito.io"}]}` _, err := resolveDBInstance(ctx, sess, acct, "titogo", "") if err == nil || !strings.Contains(err.Error(), "--instance") || !strings.Contains(err.Error(), "a.go.tito.io, b.go.tito.io") { t.Fatalf("two instances = %v, want a refusal naming both", err) } answer = `{"instances":[{"host":"a.go.tito.io"}],"truncated":true}` if _, err := resolveDBInstance(ctx, sess, acct, "titogo", ""); err == nil { t.Fatal("a truncated list of one was taken as the only instance") } answer = `{"instances":[]}` if _, err := resolveDBInstance(ctx, sess, acct, "titogo", ""); err == nil { t.Fatal("no instances resolved to something") } } // Every way a restore can fail is named, and none of them reads as a // transient "no replica found" (spec §5 R4, §7). func TestDBRestoreErrorsNameTheirCause(t *testing.T) { cases := []struct { err error want string }{ {&dbdoor.DoorError{Status: 409, Message: "a.go.tito.io keeps its data in region uk."}, "region uk"}, {&dbdoor.DoorError{Status: 404}, "no instance"}, // A proxy's HTML page during a console roll carries no sentence; // the status still has to reach the person. {&dbdoor.DoorError{Status: 502}, "502"}, {dbdoor.ErrNothingReplicated, `replicate: "app"`}, {dbdoor.ErrCheckFailed, "integrity check"}, {os.ErrNotExist, "changed while it was being read"}, } for _, c := range cases { if got := dbRestoreError("a.go.tito.io", c.err).Error(); !strings.Contains(got, c.want) { t.Errorf("%v -> %q, want it to mention %q", c.err, got, c.want) } } } // mode=ro covers only main, and query_only is a pragma a statement list can // switch off. ATTACH opens with the connection's read-write flags, so it is // the door around both: it could write the copy silently through a second // name, or create and fill a file anywhere the user can write. VACUUM INTO // attaches internally. All of them must fail, and leave no file behind. func TestQueryCannotAttachOrWriteFilesElsewhere(t *testing.T) { path := fixtureDB(t) elsewhere := t.TempDir() stray := filepath.Join(elsewhere, "stray.db") for _, stmt := range []string{ "PRAGMA query_only=0; ATTACH (SELECT file FROM pragma_database_list WHERE name='main') AS rw; UPDATE rw.users SET name='x'", "ATTACH '" + stray + "' AS e", "PRAGMA query_only=0; ATTACH '" + stray + "' AS e; CREATE TABLE e.t (x); INSERT INTO e.t VALUES (1)", "VACUUM INTO '" + stray + "'", "PRAGMA query_only=0; VACUUM INTO '" + stray + "'", } { if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil { t.Errorf("%s: succeeded, want a refusal", stmt) } if _, err := os.Stat(stray); !os.IsNotExist(err) { t.Fatalf("%s: left a file at %s", stmt, stray) } } var out bytes.Buffer if err := runQuery(&out, path, "SELECT name FROM users ORDER BY id"); err != nil || out.String() != "name\nada\ngrace\n" { t.Fatalf("rows after = %v %q, want them unchanged", err, out.String()) } } // errWriter fails every write, the way a closed pipe does. type errWriter struct{ n int } func (w *errWriter) Write([]byte) (int, error) { w.n++; return 0, errors.New("broken pipe") } // A reader that went away is an error the query loop stops on, not one it // prints a thousand more rows into. func TestQueryStopsWhenTheReaderGoesAway(t *testing.T) { w := &errWriter{} if err := runQuery(w, fixtureDB(t), "SELECT * FROM users"); err == nil { t.Fatal("a failing writer was not reported") } if w.n != 1 { t.Fatalf("kept writing after the first failure: %d writes", w.n) } } // `carlos db query ... | head` closes stdout early. Without SIGPIPE caught, // Go kills the process on the next write and every deferred cleanup is // skipped, leaving a plaintext copy of production in the temp directory. // The helper below runs dbSignalContext's handling in a child whose stdout // is a pipe nobody reads; it must get EPIPE back, clean up, and exit 0. func TestQueryCleansUpWhenStdoutIsClosed(t *testing.T) { if os.Getenv("CARLOS_DB_PIPE_HELPER") != "" { ctx, stop := dbSignalContext() defer stop() dir := os.Getenv("CARLOS_DB_PIPE_HELPER") for i := 0; i < 1<<20 && ctx.Err() == nil; i++ { if _, err := os.Stdout.Write([]byte("row\n")); err != nil { break } } os.RemoveAll(dir) os.Exit(0) } if runtime.GOOS == "windows" { t.Skip("no SIGPIPE on windows") } dir := t.TempDir() private := filepath.Join(dir, "carlos-db-x") if err := os.Mkdir(private, 0o700); err != nil { t.Fatal(err) } r, w, err := os.Pipe() if err != nil { t.Fatal(err) } r.Close() // the reader has gone, like head after its lines cmd := exec.Command(os.Args[0], "-test.run=^TestQueryCleansUpWhenStdoutIsClosed$") cmd.Env = append(os.Environ(), "CARLOS_DB_PIPE_HELPER="+private) cmd.Stdout = w err = cmd.Run() w.Close() if err != nil { t.Fatalf("helper died (%v): the write killed it before cleanup could run", err) } if _, err := os.Stat(private); !os.IsNotExist(err) { t.Fatalf("private directory survived a closed stdout (%v)", err) } } // dirEntries lists a directory's names, sorted. func dirEntries(t *testing.T, dir string) []string { t.Helper() ents, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } var out []string for _, e := range ents { out = append(out, e.Name()) } return out } // The review's reproduction, verbatim. mode=ro covers main only, the // attach limit does not cover the built-in temp schema, and the driver runs // every statement in a list, so switching query_only off first let a temp // copy of the data be written and changed with no error. It must fail, and // leave no file anywhere: not beside the copy, not in the temp directory. func TestQueryRefusesTheTempTableBypass(t *testing.T) { tmp := t.TempDir() t.Setenv("TMPDIR", tmp) path := fixtureDB(t) before := dirEntries(t, filepath.Dir(path)) err := runQuery(&bytes.Buffer{}, path, "PRAGMA query_only=0; CREATE TEMP TABLE stolen AS SELECT * FROM main.users; UPDATE stolen SET name='x'") if err == nil { t.Fatal("the temp-table bypass succeeded") } if got := dirEntries(t, tmp); len(got) != 0 { t.Fatalf("temp directory holds %v after the refusal", got) } after := dirEntries(t, filepath.Dir(path)) for _, name := range after { if !slices.Contains(before, name) && name != "copy.db-wal" && name != "copy.db-shm" { t.Fatalf("a new file %s appeared beside the copy", name) } } } // One statement per query. The guards that matter (mode=ro, query_only) // hold for a single statement; a list is how one statement turns them off // for the next. func TestQueryRunsOneStatementOnly(t *testing.T) { path := fixtureDB(t) for _, stmt := range []string{ "SELECT 1; SELECT 2", "PRAGMA query_only=0; UPDATE users SET name='x'", "SELECT 1;\n-- trailing comment\nDELETE FROM users", "SELECT 1; /* c */ SELECT 2", } { if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil || !strings.Contains(err.Error(), "one statement") { t.Errorf("%q: error = %v, want the one-statement refusal", stmt, err) } } for _, stmt := range []string{ "SELECT name FROM users;", "SELECT name FROM users; -- trailing comment", "SELECT ';' AS semi, \"a;b\" FROM (SELECT 1 AS \"a;b\")", "SELECT name FROM users /* ; */", "WITH x AS (SELECT 1) SELECT * FROM x", } { if err := runQuery(&bytes.Buffer{}, path, stmt); err != nil { t.Errorf("%q: error = %v, want it to run", stmt, err) } } } // A pragma that takes an argument can change how this connection reads or // where it spills (query_only, temp_store, temp_store_directory, // writable_schema...). Only the read-only introspection pragmas may take // one; bare reads of any pragma are fine. func TestQueryRefusesPragmaChanges(t *testing.T) { path := fixtureDB(t) for _, stmt := range []string{ "PRAGMA query_only=0", "PRAGMA query_only = OFF", "pragma temp_store=FILE", "PRAGMA temp_store_directory='/tmp/x'", "PRAGMA main.writable_schema=1", "PRAGMA journal_mode(DELETE)", " /* lead */ PRAGMA user_version=9", } { if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil || !strings.Contains(err.Error(), "pragma") { t.Errorf("%q: error = %v, want the pragma refusal", stmt, err) } } for _, stmt := range []string{ "PRAGMA table_info(users)", "PRAGMA main.table_info('users')", "PRAGMA query_only", "PRAGMA integrity_check", "SELECT * FROM pragma_table_info('users')", } { if err := runQuery(&bytes.Buffer{}, path, stmt); err != nil { t.Errorf("%q: error = %v, want it to run", stmt, err) } } } // Sorts and temp b-trees stay in memory, so a big query cannot spill // plaintext pages to a file outside the private directory. func TestQueryKeepsTempStorageInMemory(t *testing.T) { var out bytes.Buffer if err := runQuery(&out, fixtureDB(t), "PRAGMA temp_store"); err != nil || out.String() != "temp_store\n2\n" { t.Fatalf("PRAGMA temp_store = %v %q, want 2 (memory)", err, out.String()) } } // SQLite applies a pragma when it prepares the statement, EXPLAIN or not, // and skips a leading byte-order mark and every kind of SQL whitespace. The // pragma check must see through all of them. func TestQueryRefusesDisguisedPragmaChanges(t *testing.T) { path := fixtureDB(t) for _, stmt := range []string{ "EXPLAIN PRAGMA query_only=0", "explain query plan PRAGMA query_only=0", "EXPLAIN\fQUERY\tPLAN\nPRAGMA temp_store=FILE", "\uFEFFPRAGMA query_only=0", "\uFEFF EXPLAIN /* x */ PRAGMA writable_schema=1", "\fPRAGMA\fquery_only\f=\f0", } { if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil || !strings.Contains(err.Error(), "pragma") { t.Errorf("%q: error = %v, want the pragma refusal", stmt, err) } } for _, stmt := range []string{"EXPLAIN SELECT * FROM users", "EXPLAIN QUERY PLAN SELECT * FROM users", "\uFEFFSELECT 1", "EXPLAIN PRAGMA table_info(users)"} { if err := runQuery(&bytes.Buffer{}, path, stmt); err != nil { t.Errorf("%q: error = %v, want it to run", stmt, err) } } } // A pull interrupted before or during the copy publishes nothing at --out, // and so cannot go on to report success. func TestPullPublishesNothingOnceCancelled(t *testing.T) { src := fixtureDB(t) out := filepath.Join(t.TempDir(), "pulled.db") ctx, cancel := context.WithCancel(context.Background()) cancel() if err := copyOut(ctx, src, out); !errors.Is(err, context.Canceled) { t.Fatalf("copyOut after cancel = %v, want context.Canceled", err) } if _, err := os.Stat(out); !os.IsNotExist(err) { t.Fatalf("a cancelled pull left %s (%v)", out, err) } } // The pragma guard is an invariant over the whole statement, not a prefix // check: SQLite skips byte-order marks, comments and every kind of // whitespace anywhere, and applies a pragma at prepare even under EXPLAIN. // This generates the disguises from parts and asserts every one is refused. func TestQueryRefusesEveryDisguiseOfAPragmaChange(t *testing.T) { bom := "\uFEFF" gaps := []string{"", " ", "\t", "\n", "\f", "\r\n", bom, " " + bom, bom + bom, "/*x*/", "/*x*/" + bom, bom + "/**/ ", "-- c\n", " /* a */ /* b */ "} leads := []string{"", "EXPLAIN", "explain query plan", "EXPLAIN QUERY PLAN"} changes := []struct{ name, set string }{ {"query_only", "=0"}, {"query_only", "(0)"}, {"temp_store", "=1"}, {"temp_store", "=FILE"}, {"temp_store_directory", "='/tmp/x'"}, {"writable_schema", "=1"}, {"main.writable_schema", "=ON"}, {`"query_only"`, "=0"}, {"[query_only]", "=0"}, {"`temp_store`", "=1"}, {"main . query_only", "=0"}, } path := fixtureDB(t) n := 0 for _, lead := range leads { for _, g1 := range gaps { for _, g2 := range gaps { for _, c := range changes { sep := g2 if sep == "" { sep = " " // two words need something between them } stmt := g1 + lead + sep + "PRAGMA" + sep + c.name + g1 + c.set if err := checkQuerySQL(stmt); err == nil || !strings.Contains(err.Error(), "pragma") { t.Errorf("%q: error = %v, want the pragma refusal", stmt, err) } n++ } } } } if n < 1000 { t.Fatalf("generated only %d cases", n) } // And end to end, a handful through the real query path. for _, stmt := range []string{"/*x*/" + bom + "PRAGMA query_only=0", " " + bom + "PRAGMA query_only=0", bom + bom + "PRAGMA temp_store=1", "EXPLAIN " + bom + "PRAGMA query_only=0"} { if err := runQuery(&bytes.Buffer{}, path, stmt); err == nil || !strings.Contains(err.Error(), "pragma") { t.Errorf("%q through runQuery: error = %v, want the pragma refusal", stmt, err) } } // What must still run. for _, stmt := range []string{ "PRAGMA table_info(users)", bom + " PRAGMA table_info ( 'users' )", "PRAGMA query_only", "EXPLAIN PRAGMA query_only", "SELECT 'PRAGMA query_only=0' AS s", `SELECT 1 AS "pragma"`, "SELECT 1 -- PRAGMA query_only=0", "SELECT 1 /* PRAGMA temp_store=1 */", } { if err := runQuery(&bytes.Buffer{}, path, stmt); err != nil { t.Errorf("%q: error = %v, want it to run", stmt, err) } } } // "pragma" is only a statement in statement position. As a column name or // alias it is an ordinary identifier, and a read that uses it must run. func TestQueryAllowsPragmaAsAnIdentifier(t *testing.T) { for _, q := range []string{ "SELECT pragma FROM (SELECT 1 AS pragma)", "SELECT pragma = 1 FROM (SELECT 1 AS pragma)", "SELECT x FROM (SELECT 1 AS x) WHERE x IN (SELECT 1 AS pragma)", "EXPLAIN SELECT pragma = 1 FROM (SELECT 1 AS pragma)", } { if err := checkQuerySQL(q); err != nil { t.Errorf("checkQuerySQL(%q) = %v, want it allowed", q, err) } } }