Verify operator SQLite connections against retained file identity
2 files changed,
+884
−0
internal/operator/open_linux.go+481 −0internal/operator/open_linux_test.go+403 −0
CI passed — run details
diff --git a/internal/operator/open_linux.go b/internal/operator/open_linux.go| new file mode 100644 |
| index 0000000..08546ae |
| --- /dev/null |
| +++ b/internal/operator/open_linux.go |
| @@ -0,0 +1,481 @@ |
| +//go:build linux |
| + |
| +package operator |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "database/sql/driver" |
| + "io" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| + "modernc.org/sqlite" |
| +) |
| + |
| +type fileIdentity struct{ device, inode uint64 } |
| +type descriptor struct { |
| + identity fileIdentity |
| + flags int |
| + links uint64 |
| +} |
| +type boundDatabase struct { |
| + db *sql.DB |
| + connector *boundConnector |
| +} |
| +type boundConnector struct { |
| + mu sync.Mutex |
| + path string |
| + pin *os.File |
| + identity fileIdentity |
| + deadline time.Time |
| + sqlite *sqlite.Driver |
| + active bool |
| + hook func(string) |
| +} |
| +type boundConnection struct { |
| + driver.Conn |
| + owner *boundConnector |
| + mainFD int |
| + siblings map[string]fileIdentity |
| + closed bool |
| +} |
| + |
| +// newBoundDatabase proves file identity only. The unmounted command adapter must |
| +// first establish the separately verified launch context and signed authority. |
| +func newBoundDatabase(path string, device, inode uint64, deadline time.Time) (*boundDatabase, error) { |
| + if device == 0 || inode == 0 || !deadline.After(time.Now()) { |
| + return nil, ErrRefused |
| + } |
| + pin, id, e := pinExisting(path) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if id != (fileIdentity{device, inode}) { |
| + pin.Close() |
| + return nil, ErrRefused |
| + } |
| + c := &boundConnector{path: path, pin: pin, identity: id, deadline: deadline, sqlite: &sqlite.Driver{}} |
| + db := sql.OpenDB(c) |
| + db.SetMaxOpenConns(1) |
| + db.SetMaxIdleConns(1) |
| + return &boundDatabase{db, c}, nil |
| +} |
| +func (b *boundDatabase) close() error { |
| + e := b.db.Close() |
| + b.connector.mu.Lock() |
| + defer b.connector.mu.Unlock() |
| + if b.connector.active { |
| + return ErrUnavailable |
| + } |
| + if x := b.connector.pin.Close(); e == nil { |
| + e = x |
| + } |
| + return e |
| +} |
| +func pinExisting(path string) (*os.File, fileIdentity, error) { |
| + if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" || len(path) > 1024 || strings.ContainsRune(path, 0) { |
| + return nil, fileIdentity{}, ErrRefused |
| + } |
| + fd, e := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| + if e != nil { |
| + return nil, fileIdentity{}, ErrUnavailable |
| + } |
| + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") |
| + for i, part := range parts { |
| + flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK |
| + if i < len(parts)-1 { |
| + flags |= unix.O_DIRECTORY |
| + } |
| + next, err := unix.Openat(fd, part, flags, 0) |
| + unix.Close(fd) |
| + if err != nil { |
| + return nil, fileIdentity{}, ErrRefused |
| + } |
| + fd = next |
| + } |
| + var s unix.Stat_t |
| + if unix.Fstat(fd, &s) != nil { |
| + unix.Close(fd) |
| + return nil, fileIdentity{}, ErrUnavailable |
| + } |
| + if s.Mode&unix.S_IFMT != unix.S_IFREG || s.Nlink != 1 || s.Dev == 0 || s.Ino == 0 { |
| + unix.Close(fd) |
| + return nil, fileIdentity{}, ErrRefused |
| + } |
| + return os.NewFile(uintptr(fd), path), fileIdentity{uint64(s.Dev), s.Ino}, nil |
| +} |
| + |
| +// statExisting does not open the regular file: closing a second descriptor for |
| +// a SQLite inode would release this process's POSIX locks on that inode. |
| +func statExisting(path string) (fileIdentity, error) { |
| + if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" || len(path) > 1024 || strings.ContainsRune(path, 0) { |
| + return fileIdentity{}, ErrRefused |
| + } |
| + fd, e := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| + if e != nil { |
| + return fileIdentity{}, ErrUnavailable |
| + } |
| + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") |
| + for _, part := range parts[:len(parts)-1] { |
| + next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + unix.Close(fd) |
| + if err != nil { |
| + return fileIdentity{}, ErrRefused |
| + } |
| + fd = next |
| + } |
| + defer unix.Close(fd) |
| + var s unix.Stat_t |
| + if unix.Fstatat(fd, parts[len(parts)-1], &s, unix.AT_SYMLINK_NOFOLLOW) != nil { |
| + return fileIdentity{}, ErrRefused |
| + } |
| + if s.Mode&unix.S_IFMT != unix.S_IFREG || s.Nlink != 1 || s.Dev == 0 || s.Ino == 0 { |
| + return fileIdentity{}, ErrRefused |
| + } |
| + return fileIdentity{uint64(s.Dev), s.Ino}, nil |
| +} |
| +func descriptors() (map[int]descriptor, error) { |
| + directory, e := os.Open("/proc/self/fd") |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + defer directory.Close() |
| + entries, e := directory.Readdirnames(-1) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + out := map[int]descriptor{} |
| + for _, entry := range entries { |
| + fd, e := strconv.Atoi(entry) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + var s unix.Stat_t |
| + if fd == int(directory.Fd()) { |
| + continue |
| + } |
| + if e = unix.Fstat(fd, &s); e != nil { |
| + return nil, ErrRefused |
| + } |
| + if s.Mode&unix.S_IFMT != unix.S_IFREG { |
| + continue |
| + } |
| + flags, e := unix.FcntlInt(uintptr(fd), unix.F_GETFL, 0) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + out[fd] = descriptor{fileIdentity{uint64(s.Dev), s.Ino}, flags, s.Nlink} |
| + } |
| + return out, nil |
| +} |
| +func writable(flags int) bool { return flags&unix.O_ACCMODE != unix.O_RDONLY } |
| +func (c *boundConnector) Driver() driver.Driver { return c.sqlite } |
| +func (c *boundConnector) checkpoint(stage string) { |
| + if c.hook != nil { |
| + c.hook(stage) |
| + } |
| +} |
| +func (c *boundConnector) pathMatches() error { |
| + if !time.Now().Before(c.deadline) { |
| + return ErrRefused |
| + } |
| + var stat unix.Stat_t |
| + if unix.Fstat(int(c.pin.Fd()), &stat) != nil || (fileIdentity{uint64(stat.Dev), stat.Ino}) != c.identity || stat.Nlink != 1 { |
| + return ErrRefused |
| + } |
| + id, e := statExisting(c.path) |
| + if e != nil { |
| + return e |
| + } |
| + if id != c.identity { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| +func (c *boundConnector) Connect(ctx context.Context) (driver.Conn, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if c.active || ctx.Err() != nil || c.pathMatches() != nil { |
| + return nil, ErrRefused |
| + } |
| + c.checkpoint("before-inventory") |
| + baseline, e := descriptors() |
| + if e != nil { |
| + return nil, e |
| + } |
| + for _, d := range baseline { |
| + if writable(d.flags) { |
| + return nil, ErrRefused |
| + } |
| + } |
| + c.checkpoint("before-open") |
| + if c.pathMatches() != nil { |
| + return nil, ErrRefused |
| + } |
| + c.checkpoint("before-driver") |
| + u := url.URL{Scheme: "file", Path: c.path} |
| + q := u.Query() |
| + q.Set("mode", "rw") |
| + u.RawQuery = q.Encode() |
| + native, e := c.sqlite.Open(u.String()) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + keep := false |
| + defer func() { |
| + if !keep { |
| + native.Close() |
| + } |
| + }() |
| + c.checkpoint("after-open") |
| + current, e := descriptors() |
| + if e != nil { |
| + return nil, e |
| + } |
| + mainFD := -1 |
| + for fd, d := range current { |
| + if !writable(d.flags) { |
| + continue |
| + } |
| + if _, old := baseline[fd]; old { |
| + return nil, ErrRefused |
| + } |
| + if d.identity == c.identity { |
| + if d.flags&unix.O_ACCMODE != unix.O_RDWR || d.links != 1 || mainFD != -1 { |
| + return nil, ErrRefused |
| + } |
| + mainFD = fd |
| + } |
| + } |
| + if mainFD < 0 { |
| + return nil, ErrRefused |
| + } |
| + bound := &boundConnection{Conn: native, owner: c, mainFD: mainFD, siblings: map[string]fileIdentity{}} |
| + if e = bound.check(); e != nil { |
| + return nil, e |
| + } |
| + query, ok := native.(driver.QueryerContext) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + rows, e := query.QueryContext(ctx, "PRAGMA database_list", nil) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + values := make([]driver.Value, 3) |
| + if rows.Next(values) != nil || values[1] != "main" || values[2] != c.path { |
| + rows.Close() |
| + return nil, ErrRefused |
| + } |
| + if e = rows.Next(values); e != io.EOF { |
| + rows.Close() |
| + return nil, ErrRefused |
| + } |
| + if rows.Close() != nil { |
| + return nil, ErrUnavailable |
| + } |
| + c.checkpoint("after-metadata") |
| + if e = bound.check(); e != nil { |
| + return nil, e |
| + } |
| + c.active = true |
| + keep = true |
| + return bound, nil |
| +} |
| +func (c *boundConnection) check() error { |
| + if c.closed || c.owner.pathMatches() != nil { |
| + return ErrRefused |
| + } |
| + all, e := descriptors() |
| + if e != nil { |
| + return e |
| + } |
| + main, ok := all[c.mainFD] |
| + if !ok || main.identity != c.owner.identity || main.flags&unix.O_ACCMODE != unix.O_RDWR || main.links != 1 { |
| + return ErrRefused |
| + } |
| + count := 0 |
| + seenSiblings := map[string]bool{} |
| + for fd, d := range all { |
| + if !writable(d.flags) { |
| + continue |
| + } |
| + if fd == c.mainFD { |
| + count++ |
| + continue |
| + } |
| + if d.flags&unix.O_ACCMODE != unix.O_RDWR || d.links != 1 { |
| + return ErrRefused |
| + } |
| + matched := false |
| + for _, suffix := range []string{"-wal", "-shm"} { |
| + id, e := statExisting(c.owner.path + suffix) |
| + if e != nil { |
| + continue |
| + } |
| + if id == d.identity { |
| + if seenSiblings[suffix] { |
| + return ErrRefused |
| + } |
| + seenSiblings[suffix] = true |
| + if old, exists := c.siblings[suffix]; exists && old != id { |
| + return ErrRefused |
| + } |
| + c.siblings[suffix] = id |
| + matched = true |
| + break |
| + } |
| + } |
| + if !matched { |
| + return ErrRefused |
| + } |
| + } |
| + if count != 1 { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| +func (c *boundConnection) Close() error { |
| + c.owner.mu.Lock() |
| + defer c.owner.mu.Unlock() |
| + if c.closed { |
| + return nil |
| + } |
| + c.closed = true |
| + e := c.Conn.Close() |
| + c.owner.active = false |
| + return e |
| +} |
| +func (c *boundConnection) Prepare(string) (driver.Stmt, error) { return nil, ErrRefused } |
| +func (c *boundConnection) PrepareContext(context.Context, string) (driver.Stmt, error) { |
| + return nil, ErrRefused |
| +} |
| +func (c *boundConnection) Begin() (driver.Tx, error) { |
| + return c.BeginTx(context.Background(), driver.TxOptions{}) |
| +} |
| +func (c *boundConnection) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { |
| + if c.check() != nil { |
| + return nil, ErrRefused |
| + } |
| + x, ok := c.Conn.(driver.ConnBeginTx) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + tx, e := x.BeginTx(ctx, opts) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if c.check() != nil { |
| + tx.Rollback() |
| + return nil, ErrUnavailable |
| + } |
| + return &boundTransaction{tx, c}, nil |
| +} |
| +func (c *boundConnection) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { |
| + if query != "ROLLBACK" && c.check() != nil { |
| + return nil, ErrRefused |
| + } |
| + x, ok := c.Conn.(driver.ExecerContext) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + result, e := x.ExecContext(ctx, query, args) |
| + if e != nil { |
| + return nil, e |
| + } |
| + c.owner.checkpoint("after-exec") |
| + if query != "ROLLBACK" && c.check() != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return result, nil |
| +} |
| +func (c *boundConnection) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { |
| + if c.check() != nil { |
| + return nil, ErrRefused |
| + } |
| + x, ok := c.Conn.(driver.QueryerContext) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + rows, e := x.QueryContext(ctx, query, args) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if c.check() != nil { |
| + rows.Close() |
| + return nil, ErrUnavailable |
| + } |
| + return &boundRows{rows, c}, nil |
| +} |
| +func (c *boundConnection) Ping(ctx context.Context) error { |
| + if c.check() != nil { |
| + return ErrRefused |
| + } |
| + p, ok := c.Conn.(driver.Pinger) |
| + if !ok { |
| + return ErrRefused |
| + } |
| + if e := p.Ping(ctx); e != nil { |
| + return e |
| + } |
| + if c.check() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| +func (c *boundConnection) ResetSession(ctx context.Context) error { |
| + if c.check() != nil { |
| + return ErrRefused |
| + } |
| + if r, ok := c.Conn.(driver.SessionResetter); ok { |
| + return r.ResetSession(ctx) |
| + } |
| + return nil |
| +} |
| +func (c *boundConnection) IsValid() bool { return c.check() == nil } |
| + |
| +type boundRows struct { |
| + driver.Rows |
| + c *boundConnection |
| +} |
| + |
| +func (r *boundRows) Next(values []driver.Value) error { |
| + if r.c.check() != nil { |
| + return ErrRefused |
| + } |
| + e := r.Rows.Next(values) |
| + if e != nil { |
| + return e |
| + } |
| + if r.c.check() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| + |
| +type boundTransaction struct { |
| + driver.Tx |
| + c *boundConnection |
| +} |
| + |
| +func (t *boundTransaction) Commit() error { |
| + if t.c.check() != nil { |
| + return ErrRefused |
| + } |
| + e := t.Tx.Commit() |
| + t.c.owner.checkpoint("after-commit") |
| + if e != nil { |
| + return e |
| + } |
| + if t.c.check() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
diff --git a/internal/operator/open_linux_test.go b/internal/operator/open_linux_test.go| new file mode 100644 |
| index 0000000..5d1cd2f |
| --- /dev/null |
| +++ b/internal/operator/open_linux_test.go |
| @@ -0,0 +1,403 @@ |
| +//go:build linux |
| + |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + sharedseal "amadan.net/oficina/home/handoff/seal" |
| + "golang.org/x/sys/unix" |
| + "modernc.org/sqlite" |
| +) |
| + |
| +type openProbe struct { |
| + Path, Other, Mode string |
| + Device, Inode uint64 |
| +} |
| + |
| +func seedOpenDB(t *testing.T, path string) { |
| + t.Helper() |
| + db, e := sql.Open("sqlite", "file:"+path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = db.Exec("PRAGMA journal_mode=WAL; CREATE TABLE changes(value INTEGER)"); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = db.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func TestBoundDatabaseSubprocess(t *testing.T) { |
| + if os.Getenv("OFICINA_OPEN_PROBE") == "1" { |
| + raw, e := io.ReadAll(io.LimitReader(os.Stdin, 16384)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + var p openProbe |
| + if json.Unmarshal(raw, &p) != nil { |
| + t.Fatal("probe input") |
| + } |
| + if p.Mode == "contender" { |
| + db, err := sql.Open("sqlite", "file:"+p.Path+"?mode=rw") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer db.Close() |
| + _, err = db.Exec("BEGIN IMMEDIATE") |
| + var sqliteErr *sqlite.Error |
| + if !errors.As(err, &sqliteErr) || sqliteErr.Code() != 5 { |
| + t.Fatal("expected SQLITE_BUSY while owner holds transaction", err) |
| + } |
| + return |
| + } |
| + saved := p.Path + ".saved" |
| + swap := func() { |
| + t.Helper() |
| + if e := os.Rename(p.Path, saved); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := os.Rename(p.Other, p.Path); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + restore := func() { |
| + t.Helper() |
| + if e := os.Rename(p.Path, p.Other); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := os.Rename(saved, p.Path); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if p.Mode == "symlink" { |
| + if e = os.Rename(p.Path, saved); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = os.Symlink(p.Other, p.Path); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if p.Mode == "symlink-ancestor" { |
| + link := filepath.Join(filepath.Dir(p.Path), "alias") |
| + if e = os.Symlink(filepath.Dir(p.Path), link); e != nil { |
| + t.Fatal(e) |
| + } |
| + p.Path = filepath.Join(link, filepath.Base(p.Path)) |
| + } |
| + if p.Mode == "missing" { |
| + if e = os.Remove(p.Path); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if p.Mode == "hardlink" { |
| + if e = os.Link(p.Path, saved); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if p.Mode == "inherited-writeonly" || p.Mode == "inherited-readwrite" { |
| + flag := os.O_WRONLY |
| + if p.Mode == "inherited-readwrite" { |
| + flag = os.O_RDWR |
| + } |
| + f, e := os.OpenFile(p.Other, flag, 0) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer f.Close() |
| + } |
| + deadline := time.Now().Add(time.Minute) |
| + if p.Mode == "expired" { |
| + deadline = time.Now().Add(-time.Second) |
| + } |
| + bound, e := newBoundDatabase(p.Path, p.Device, p.Inode, deadline) |
| + early := map[string]bool{"symlink": true, "symlink-ancestor": true, "missing": true, "hardlink": true, "expired": true} |
| + if early[p.Mode] { |
| + if e != ErrRefused { |
| + if bound != nil { |
| + bound.close() |
| + } |
| + t.Fatal("pin refusal", e) |
| + } |
| + return |
| + } |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer bound.close() |
| + if p.Mode == "replace-restore" { |
| + bound.connector.hook = func(stage string) { |
| + if stage == "before-driver" { |
| + swap() |
| + } |
| + if stage == "after-open" { |
| + restore() |
| + } |
| + } |
| + } |
| + if p.Mode == "replace-after" { |
| + bound.connector.hook = func(stage string) { |
| + if stage == "after-open" { |
| + swap() |
| + } |
| + } |
| + } |
| + var extra *os.File |
| + defer func() { |
| + if extra != nil { |
| + extra.Close() |
| + } |
| + }() |
| + if p.Mode == "symlink-after" || p.Mode == "ambiguous" { |
| + bound.connector.hook = func(stage string) { |
| + if stage != "after-open" { |
| + return |
| + } |
| + if p.Mode == "ambiguous" { |
| + extra, e = os.OpenFile(p.Path, os.O_RDWR, 0) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + } else { |
| + if e = os.Rename(p.Path, saved); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = os.Symlink(saved, p.Path); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + } |
| + } |
| + e = bound.db.PingContext(context.Background()) |
| + refused := p.Mode == "symlink-after" || p.Mode == "ambiguous" || p.Mode == "replace-restore" || p.Mode == "replace-after" || p.Mode == "inherited-writeonly" || p.Mode == "inherited-readwrite" |
| + if refused { |
| + if e != ErrRefused { |
| + t.Fatal("connection refusal", e) |
| + } |
| + return |
| + } |
| + if e != nil { |
| + t.Fatal("ordinary open", e) |
| + } |
| + if p.Mode == "locks" || p.Mode == "reconnect" { |
| + if p.Mode == "reconnect" { |
| + bound.db.SetMaxIdleConns(0) |
| + if err := bound.db.Ping(); err != nil { |
| + t.Fatal("reconnect", err) |
| + } |
| + bound.db.SetMaxIdleConns(1) |
| + } |
| + conn, err := bound.db.Conn(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer conn.Close() |
| + if _, err = conn.ExecContext(context.Background(), "BEGIN IMMEDIATE"); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer conn.ExecContext(context.Background(), "ROLLBACK") |
| + if _, err = conn.ExecContext(context.Background(), "INSERT INTO changes VALUES(1)"); err != nil { |
| + t.Fatal(err) |
| + } |
| + p.Mode = "contender" |
| + raw, _ := json.Marshal(p) |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestBoundDatabaseSubprocess$") |
| + cmd.Env = append(os.Environ(), "OFICINA_OPEN_PROBE=1") |
| + cmd.Stdin = bytes.NewReader(raw) |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("lock contender: %v %s", err, output) |
| + } |
| + return |
| + } |
| + if p.Mode == "wal-replaced" || p.Mode == "shm-symlink" || p.Mode == "foreign-descriptor" || p.Mode == "unlinked-temp" || p.Mode == "expired-connected" { |
| + conn, err := bound.db.Conn(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer conn.Close() |
| + var count int |
| + if err = conn.QueryRowContext(context.Background(), "SELECT count(*) FROM changes").Scan(&count); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p.Mode == "wal-replaced" || p.Mode == "shm-symlink" { |
| + suffix := "-wal" |
| + if p.Mode == "shm-symlink" { |
| + suffix = "-shm" |
| + } |
| + if err = os.Rename(p.Path+suffix, p.Path+suffix+".saved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p.Mode == "shm-symlink" { |
| + err = os.Symlink(p.Path+suffix+".saved", p.Path+suffix) |
| + } else { |
| + err = os.WriteFile(p.Path+suffix, nil, 0600) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer func() { |
| + os.Remove(p.Path + suffix) |
| + os.Rename(p.Path+suffix+".saved", p.Path+suffix) |
| + }() |
| + } else if p.Mode == "expired-connected" { |
| + bound.connector.deadline = time.Now().Add(-time.Second) |
| + } else { |
| + f, err := os.OpenFile(p.Other, os.O_RDWR, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer f.Close() |
| + if p.Mode == "unlinked-temp" { |
| + if err = os.Remove(p.Other); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + if _, err = conn.ExecContext(context.Background(), "INSERT INTO changes VALUES(1)"); err != ErrRefused { |
| + t.Fatal("boundary refusal", err) |
| + } |
| + return |
| + } |
| + if p.Mode == "post-commit" { |
| + bound.connector.hook = func(stage string) { |
| + if stage == "after-exec" { |
| + swap() |
| + } |
| + } |
| + } |
| + _, e = bound.db.ExecContext(context.Background(), "INSERT INTO changes VALUES(1)") |
| + if p.Mode == "post-commit" { |
| + if e != ErrUnavailable || errors.Is(e, sql.ErrConnDone) { |
| + t.Fatal("commit ambiguity", e) |
| + } |
| + restore() |
| + bound.connector.hook = nil |
| + return |
| + } |
| + if e != nil { |
| + t.Fatal("ordinary write", e) |
| + } |
| + return |
| + } |
| + modes := []string{"symlink-after", "symlink-ancestor", "ambiguous", "wal-replaced", "shm-symlink", "foreign-descriptor", "unlinked-temp", "expired-connected", "reconnect", "locks", "ordinary", "replace-restore", "replace-after", "symlink", "missing", "hardlink", "expired", "inherited-writeonly", "inherited-readwrite", "post-commit"} |
| + for _, mode := range modes { |
| + t.Run(mode, func(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "home.db") |
| + other := filepath.Join(dir, "other.db") |
| + seedOpenDB(t, path) |
| + seedOpenDB(t, other) |
| + var stat unix.Stat_t |
| + if e := unix.Stat(path, &stat); e != nil { |
| + t.Fatal(e) |
| + } |
| + raw, e := json.Marshal(openProbe{path, other, mode, uint64(stat.Dev), stat.Ino}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestBoundDatabaseSubprocess$") |
| + cmd.Env = append(os.Environ(), "OFICINA_OPEN_PROBE=1") |
| + cmd.Stdin = bytes.NewReader(raw) |
| + if output, e := cmd.CombinedOutput(); e != nil { |
| + t.Fatalf("probe failed: %v %s", e, output) |
| + } |
| + if mode == "missing" { |
| + if _, e := os.Stat(path); !os.IsNotExist(e) { |
| + t.Fatal("missing DB created") |
| + } |
| + return |
| + } |
| + readPath := path |
| + if mode == "replace-after" || mode == "symlink" || mode == "symlink-after" { |
| + readPath = path + ".saved" |
| + } |
| + db, e := sql.Open("sqlite", "file:"+readPath+"?mode=rw") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer db.Close() |
| + var count int |
| + if e = db.QueryRow("SELECT count(*) FROM changes").Scan(&count); e != nil { |
| + t.Fatal(e) |
| + } |
| + expected := 0 |
| + if mode == "ordinary" || mode == "post-commit" { |
| + expected = 1 |
| + } |
| + if count != expected { |
| + t.Fatal("wrong mutation count", count, expected) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestBoundDatabaseEngineSubprocess(t *testing.T) { |
| + if os.Getenv("OFICINA_ENGINE_OPEN_PROBE") == "1" { |
| + var p openProbe |
| + if json.NewDecoder(io.LimitReader(os.Stdin, 16384)).Decode(&p) != nil { |
| + t.Fatal("probe input") |
| + } |
| + bound, err := newBoundDatabase(p.Path, p.Device, p.Inode, time.Now().Add(time.Minute)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer bound.close() |
| + k, verifier, auth := fixture(t) |
| + sealer, err := sharedseal.New(bytes.Repeat([]byte{6}, 32), "oficina/operator") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Unix(auth.Issued, 0) |
| + engine, err := NewEngine(bound.db, verifier, sealer, bytes.Repeat([]byte{7}, 32), func() time.Time { return now }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := signed(t, k, auth) |
| + out, err := engine.Execute(context.Background(), raw, insertOne) |
| + if err != nil || !bytes.Equal(out, privateResult) { |
| + t.Fatal("bound engine", err) |
| + } |
| + now = now.Add(301 * time.Second) |
| + out, err = engine.Execute(context.Background(), raw, func(context.Context, *sql.Conn, Request) ([]byte, error) { |
| + t.Fatal("replay mutated") |
| + return nil, nil |
| + }) |
| + if err != nil || !bytes.Equal(out, privateResult) { |
| + t.Fatal("bound recovery", err) |
| + } |
| + return |
| + } |
| + f := newEngineFixture(t) |
| + if err := f.db.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + var stat unix.Stat_t |
| + if err := unix.Stat(f.path, &stat); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(openProbe{Path: f.path, Device: uint64(stat.Dev), Inode: stat.Ino}) |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestBoundDatabaseEngineSubprocess$") |
| + cmd.Env = append(os.Environ(), "OFICINA_ENGINE_OPEN_PROBE=1") |
| + cmd.Stdin = bytes.NewReader(raw) |
| + if out, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("engine probe: %v %s", err, out) |
| + } |
| + db, err := sql.Open("sqlite", "file:"+f.path+"?mode=rw") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer db.Close() |
| + if countRows(t, db, "test_items") != 1 || countRows(t, db, "directory_operator_results") != 1 { |
| + t.Fatal("bound atomic recovery") |
| + } |
| +} |