1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
//! Threat-model tests for the `sqlite` builtin.
//!
//! Each `#[test]` here covers a distinct adversarial scenario from
//! `specs/threat-model.md` (or the new entries in `specs/sqlite-builtin.md`).
//! Aside from confirming current mitigations, these tests act as
//! regression guards: a future change that re-introduces an attack must
//! flip a test red.
//!
//! Threats covered:
//!
//! | ID | Description |
//! |---------------|----------------------------------------------------------|
//! | TM-SQL-001 | Default-disabled (BETA gate) |
//! | TM-SQL-002 | Sandbox escape via VFS — host paths sandboxed |
//! | TM-SQL-003 | DoS via oversize SQL input |
//! | TM-SQL-004 | DoS via oversize result set |
//! | TM-SQL-005 | DoS via oversize DB file load |
//! | TM-SQL-006 | NULL byte / control char injection in identifiers |
//! | TM-SQL-007 | Blob-in-CSV escape correctness |
//! | TM-SQL-008 | Recursive `.read` does not unbounded-recurse |
//! | TM-SQL-009 | ATTACH/DETACH blocked by policy |
//! | TM-SQL-010 | PRAGMA deny list blocks resource/FS knobs |
#![cfg(feature = "sqlite")]
use bashkit::{Bash, SqliteBackend, SqliteLimits};
use std::path::Path;
fn make_bash_with(limits: SqliteLimits) -> Bash {
Bash::builder()
.sqlite_with_limits(limits)
.env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
.build()
}
fn make_bash_default() -> Bash {
make_bash_with(SqliteLimits::default())
}
#[tokio::test]
async fn tm_sql_001_default_disabled_without_opt_in() {
// No env opt-in → builtin refuses to run.
let mut bash = Bash::builder().sqlite().build();
let r = bash.exec(r#"sqlite :memory: 'SELECT 1'"#).await.unwrap();
assert_eq!(r.exit_code, 1);
assert!(r.stderr.contains("disabled"));
}
#[tokio::test]
async fn tm_sql_002_paths_resolve_only_to_vfs() {
// Even though we pass an absolute path that *would* be readable on the
// host (/etc/passwd), the engine's IO is wired to bashkit's VFS only.
// The VFS doesn't contain that file, so we either get a "not found"
// style error or the file is created fresh inside the VFS (depending on
// backend). Either way we must not read the host's /etc/passwd.
let mut bash = make_bash_default();
let r = bash
.exec(r#"sqlite -backend vfs /etc/passwd '.tables' 2>&1 || true"#)
.await
.unwrap();
// Whatever happens, we should not have leaked content from the host.
assert!(
!r.stdout.contains("root:x:") && !r.stderr.contains("root:x:"),
"leaked host /etc/passwd!\nstdout={:?}\nstderr={:?}",
r.stdout,
r.stderr,
);
}
#[tokio::test]
async fn tm_sql_003_oversize_script_rejected() {
let mut bash = make_bash_with(SqliteLimits::default().max_script_bytes(1024));
let big_query = "SELECT 1; ".repeat(1000); // ~10 KiB
let cmd = format!("sqlite :memory: '{big_query}' 2>&1 || echo SAW_REJECTION");
let r = bash.exec(&cmd).await.unwrap();
assert!(
r.stdout.contains("SAW_REJECTION") || r.stderr.contains("script too large"),
"stdout={:?} stderr={:?}",
r.stdout,
r.stderr,
);
}
#[tokio::test]
async fn tm_sql_004_oversize_result_set_aborts() {
let mut bash = make_bash_with(SqliteLimits::default().max_rows_per_query(10));
let r = bash
.exec(
r#"sqlite :memory: '
CREATE TABLE big AS WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM r WHERE n<100) SELECT n FROM r;
SELECT * FROM big' 2>&1 || echo SAW_ROW_CAP"#,
)
.await
.unwrap();
// Either turso supports recursive CTE and we hit our cap, OR it returns
// an error. Both outcomes prove the cap is wired and the user gets
// back to the shell.
assert!(
r.stdout.contains("SAW_ROW_CAP")
|| r.stderr.contains("row cap")
|| r.stderr.contains("sqlite:"),
"stdout={:?} stderr={:?}",
r.stdout,
r.stderr,
);
}
#[tokio::test]
async fn tm_sql_005_oversize_db_file_rejected() {
let mut bash = make_bash_with(SqliteLimits::default().max_db_bytes(1024));
// Plant a 4 KiB blob masquerading as a database, then try to open it.
bash.exec(r#"head -c 4096 /dev/urandom > /tmp/big.sqlite"#)
.await
.unwrap();
let r = bash
.exec(r#"sqlite /tmp/big.sqlite '.tables' 2>&1 || echo SAW_TOO_LARGE"#)
.await
.unwrap();
assert!(
r.stdout.contains("SAW_TOO_LARGE") || r.stderr.contains("too large"),
"stdout={:?} stderr={:?}",
r.stdout,
r.stderr,
);
}
#[tokio::test]
async fn tm_sql_006_null_bytes_in_text_safely_round_trip() {
// Inserting binary including embedded NUL via X'..' literal must round-
// trip without truncation or panic. SQLite uses X'..' (single quotes)
// for blob literals; we feed the SQL via stdin to avoid bash quoting
// gymnastics.
let mut bash = make_bash_default();
let r = bash
.exec(
"sqlite :memory: <<'EOF'\n\
CREATE TABLE t(b BLOB);\n\
INSERT INTO t VALUES (X'DEADBEEF00CAFE');\n\
SELECT length(b) FROM t;\n\
EOF",
)
.await
.unwrap();
assert_eq!(r.exit_code, 0, "stderr: {}", r.stderr);
assert_eq!(r.stdout.trim(), "7");
}
#[tokio::test]
async fn tm_sql_007_blob_with_separator_quoted_in_csv() {
// Blob whose contents include a comma (0x2C) must not break CSV parsing
// when `-csv` mode is active.
let mut bash = make_bash_default();
let r = bash
.exec(
"sqlite -csv -header :memory: <<'EOF'\n\
CREATE TABLE t(b BLOB);\n\
INSERT INTO t VALUES (X'2C2C2C');\n\
SELECT * FROM t;\n\
EOF",
)
.await
.unwrap();
assert_eq!(r.exit_code, 0, "stderr: {}", r.stderr);
let last = r.stdout.lines().last().unwrap();
assert!(last.starts_with('"') && last.ends_with('"'));
}
#[tokio::test]
async fn tm_sql_008_recursive_dot_read_eventually_terminates() {
// A `.read` script that .reads itself back must not hang forever or
// overflow the stack. We rely on either turso refusing repeated opens
// or our own size cap. Bound the test with a small timeout via the
// `timeout` builtin so a regression doesn't hang CI.
let mut bash = make_bash_default();
bash.exec(r#"echo '.read /tmp/loop.sql' > /tmp/loop.sql"#)
.await
.unwrap();
let r = bash
.exec(
r#"timeout --preserve-status 5 sqlite :memory: '.read /tmp/loop.sql' 2>&1 || echo SAW_TERMINATION"#,
)
.await
.unwrap();
// We don't care which path triggers (stack overflow guard, parser
// recursion limit, or our own runtime cap); we only need confirmation
// that the shell regains control.
assert!(
r.stdout.contains("SAW_TERMINATION") || r.stderr.contains("sqlite:") || r.exit_code != 0,
"stdout={:?} stderr={:?}",
r.stdout,
r.stderr,
);
let _ = (Path::new("/tmp/loop.sql"), bash.fs());
}
#[tokio::test]
async fn tm_sql_009_attach_detach_rejected() {
// ATTACH and DETACH would let scripted SQL reach VFS paths the operator
// never staged for read/write — and on the VFS backend, opening a new
// file path through ATTACH would also invent fresh `MemoryIO` state
// outside our isolation. The policy rejects both keywords up-front.
let mut bash = make_bash_default();
let r = bash
.exec(r#"sqlite :memory: "ATTACH DATABASE '/tmp/other.db' AS other""#)
.await
.unwrap();
assert_eq!(r.exit_code, 1);
assert!(r.stderr.contains("ATTACH/DETACH is not supported"));
let r = bash
.exec(r#"sqlite :memory: 'DETACH DATABASE other'"#)
.await
.unwrap();
assert_eq!(r.exit_code, 1);
assert!(r.stderr.contains("ATTACH/DETACH is not supported"));
}
#[tokio::test]
async fn tm_sql_010_pragma_deny_blocks_resource_knobs() {
// The default deny list exists so a script can't push past
// `max_db_bytes` by ballooning the page cache, or fingerprint the host
// build via `compile_options`. Probe a representative entry from each
// bucket and assert the rejection comes from the policy (not turso).
let mut bash = make_bash_default();
for pragma in [
"PRAGMA cache_size = -100000",
"PRAGMA mmap_size = 268435456",
"PRAGMA temp_store_directory = '/tmp/host'",
"PRAGMA compile_options",
] {
let cmd = format!("sqlite :memory: \"{pragma}\"");
let r = bash.exec(&cmd).await.unwrap();
assert_eq!(r.exit_code, 1, "{pragma} stderr: {}", r.stderr);
assert!(
r.stderr.contains("denied by SqliteLimits::pragma_deny"),
"{pragma} did not match policy: {}",
r.stderr
);
}
}
#[tokio::test]
async fn tm_sql_002b_vfs_backend_isolated_to_bash_fs() {
// Phase 2 backend must not bypass the VFS. We open a file in the VFS,
// verify it is reachable from inside SQL via .tables (i.e. the engine
// sees only the VFS), then confirm host /etc/passwd is unreadable.
let mut bash = Bash::builder()
.sqlite_with_limits(SqliteLimits::default().backend(SqliteBackend::Vfs))
.env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
.build();
bash.exec(r#"sqlite /tmp/iso.sqlite 'CREATE TABLE marker(a)'"#)
.await
.unwrap();
assert!(
bash.fs()
.exists(Path::new("/tmp/iso.sqlite"))
.await
.unwrap()
);
}