| 1 | //go:build linux |
| 2 | |
| 3 | package operator |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/sha256" |
| 9 | "database/sql" |
| 10 | "encoding/hex" |
| 11 | "encoding/json" |
| 12 | "io" |
| 13 | "os" |
| 14 | "os/exec" |
| 15 | "path/filepath" |
| 16 | "testing" |
| 17 | "time" |
| 18 | ) |
| 19 | |
| 20 | func TestReadonlySubprocess(t *testing.T) { |
| 21 | if os.Getenv("OFICINA_READONLY_PROBE") == "1" { |
| 22 | var input struct { |
| 23 | Expected ReadonlyExpectation |
| 24 | Mode string |
| 25 | } |
| 26 | if json.NewDecoder(io.LimitReader(os.Stdin, 16384)).Decode(&input) != nil { |
| 27 | t.Fatal("input") |
| 28 | } |
| 29 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 30 | defer cancel() |
| 31 | calls := 0 |
| 32 | proof := func(context.Context, ReadonlyExpectation) error { |
| 33 | calls++ |
| 34 | if input.Mode == "proof-revoked" && calls > 1 { |
| 35 | return ErrRefused |
| 36 | } |
| 37 | return nil |
| 38 | } |
| 39 | if input.Mode == "missing-proof" { |
| 40 | proof = nil |
| 41 | } |
| 42 | if input.Mode == "wrong-hash" { |
| 43 | input.Expected.SHA256 = string(bytes.Repeat([]byte("0"), 64)) |
| 44 | } |
| 45 | if input.Mode == "wrong-inode" { |
| 46 | input.Expected.Inode++ |
| 47 | } |
| 48 | if input.Mode == "expired" { |
| 49 | cancel() |
| 50 | } |
| 51 | if input.Mode == "unbounded" { |
| 52 | ctx = context.Background() |
| 53 | } |
| 54 | var extra *os.File |
| 55 | defer func() { |
| 56 | if extra != nil { |
| 57 | extra.Close() |
| 58 | } |
| 59 | }() |
| 60 | inspect := func(ctx context.Context, c *sql.Conn) error { |
| 61 | if input.Mode == "unavailable" { |
| 62 | return ErrUnavailable |
| 63 | } |
| 64 | if input.Mode == "spill" { |
| 65 | var e error |
| 66 | extra, e = os.Create(input.Expected.Path + ".spill") |
| 67 | if e != nil { |
| 68 | return e |
| 69 | } |
| 70 | } |
| 71 | var count int |
| 72 | if e := c.QueryRowContext(ctx, "SELECT count(*) FROM changes").Scan(&count); e != nil { |
| 73 | return e |
| 74 | } |
| 75 | if count != 0 { |
| 76 | return ErrRefused |
| 77 | } |
| 78 | if input.Mode == "write" { |
| 79 | _, e := c.ExecContext(ctx, "INSERT INTO changes VALUES(1)") |
| 80 | return e |
| 81 | } |
| 82 | return nil |
| 83 | } |
| 84 | lastStage := "before-inspection" |
| 85 | hook := func(stage string) { |
| 86 | lastStage = stage |
| 87 | if input.Mode == "replace-restore" { |
| 88 | if stage == "before-driver" { |
| 89 | if os.Rename(input.Expected.Path, input.Expected.Path+".saved") != nil || os.Rename(input.Expected.Path+".other", input.Expected.Path) != nil { |
| 90 | t.Fatal("swap") |
| 91 | } |
| 92 | } |
| 93 | if stage == "after-open" { |
| 94 | if os.Rename(input.Expected.Path, input.Expected.Path+".other") != nil || os.Rename(input.Expected.Path+".saved", input.Expected.Path) != nil { |
| 95 | t.Fatal("restore") |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | if input.Mode == "change-after-read" && stage == "after-exec" { |
| 100 | // Alter an unused SQLite header byte through an external process: no |
| 101 | // extra descriptor is admitted into the checker process. |
| 102 | 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) |
| 103 | if e := cmd.Run(); e != nil { |
| 104 | t.Fatal(e) |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | err := inspectReadonly(ctx, input.Expected, proof, inspect, hook) |
| 109 | if input.Mode == "unavailable" && err != ErrUnavailable { |
| 110 | t.Fatalf("lost unavailable outcome: %v", err) |
| 111 | } |
| 112 | if input.Mode == "valid" { |
| 113 | if err != nil { |
| 114 | t.Fatalf("inspection refused after %s: %v", lastStage, err) |
| 115 | } |
| 116 | } else if err == nil { |
| 117 | t.Fatal("unsafe success", input.Mode) |
| 118 | } |
| 119 | return |
| 120 | } |
| 121 | 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"} { |
| 122 | t.Run(mode, func(t *testing.T) { |
| 123 | path := filepath.Join(t.TempDir(), "test.db") |
| 124 | seedOpenDB(t, path) |
| 125 | if mode == "replace-restore" { |
| 126 | seedOpenDB(t, path+".other") |
| 127 | } |
| 128 | raw, e := os.ReadFile(path) |
| 129 | if e != nil { |
| 130 | t.Fatal(e) |
| 131 | } |
| 132 | h := sha256.Sum256(raw) |
| 133 | id, e := statExisting(path) |
| 134 | if e != nil { |
| 135 | t.Fatal(e) |
| 136 | } |
| 137 | expected := ReadonlyExpectation{path, id.device, id.inode, int64(len(raw)), hex.EncodeToString(h[:])} |
| 138 | if mode == "wal" || mode == "shm" || mode == "journal" { |
| 139 | if e := os.WriteFile(path+"-"+mode, []byte("retained"), 0600); e != nil { |
| 140 | t.Fatal(e) |
| 141 | } |
| 142 | } |
| 143 | input, _ := json.Marshal(struct { |
| 144 | Expected ReadonlyExpectation |
| 145 | Mode string |
| 146 | }{expected, mode}) |
| 147 | cmd := exec.Command(os.Args[0], "-test.run=^TestReadonlySubprocess$") |
| 148 | cmd.Env = append(os.Environ(), "OFICINA_READONLY_PROBE=1") |
| 149 | cmd.Stdin = bytes.NewReader(input) |
| 150 | if out, e := cmd.CombinedOutput(); e != nil { |
| 151 | t.Fatalf("child: %v %s", e, out) |
| 152 | } |
| 153 | if mode != "change-after-read" { |
| 154 | after, e := os.ReadFile(path) |
| 155 | if e != nil || !bytes.Equal(raw, after) { |
| 156 | t.Fatal("database bytes changed") |
| 157 | } |
| 158 | } |
| 159 | }) |
| 160 | } |
| 161 | } |
| 162 | |