dirsql 0.1.5__tar.gz → 0.1.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dirsql-0.1.5 → dirsql-0.1.8}/PKG-INFO +1 -1
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/integration/test_dirsql.py +36 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/src/db.rs +13 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/src/lib.rs +52 -1
- dirsql-0.1.8/packages/rust/tests/readonly_query.rs +210 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/pyproject.toml +1 -1
- {dirsql-0.1.5 → dirsql-0.1.8}/Cargo.lock +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/Cargo.toml +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/README.md +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/Cargo.toml +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/README.md +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/src/lib.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/__init__.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/conftest.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/integration/__init__.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/integration/test_async_dirsql.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/integration/test_binding.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/integration/test_docs_examples.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/integration/test_docs_gaps.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/python/tests/integration/test_from_config.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/Cargo.toml +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/README.md +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/benches/db_bench.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/benches/differ_bench.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/benches/matcher_bench.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/benches/scanner_bench.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/src/config.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/src/differ.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/src/matcher.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/src/parser.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/src/scanner.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/src/watcher.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/tests/async_sdk.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/tests/docs_examples.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/tests/docs_gaps.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/tests/from_config.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/packages/rust/tests/sdk.rs +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/python/dirsql/__init__.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/python/dirsql/_async.py +0 -0
- {dirsql-0.1.5 → dirsql-0.1.8}/python/dirsql/test_async.py +0 -0
|
@@ -290,6 +290,42 @@ def describe_DirSQL():
|
|
|
290
290
|
with pytest.raises(Exception):
|
|
291
291
|
await db.query("NOT VALID SQL")
|
|
292
292
|
|
|
293
|
+
@pytest.mark.asyncio
|
|
294
|
+
async def it_rejects_write_statements_via_query(tmp_dir):
|
|
295
|
+
"""`query()` rejects non-SELECT statements so a caller can't mutate the index."""
|
|
296
|
+
with open(os.path.join(tmp_dir, "item.json"), "w") as f:
|
|
297
|
+
json.dump({"name": "apple"}, f)
|
|
298
|
+
|
|
299
|
+
db = DirSQL(
|
|
300
|
+
tmp_dir,
|
|
301
|
+
tables=[
|
|
302
|
+
Table(
|
|
303
|
+
ddl="CREATE TABLE items (name TEXT)",
|
|
304
|
+
glob="*.json",
|
|
305
|
+
extract=lambda path, content: [json.loads(content)],
|
|
306
|
+
),
|
|
307
|
+
],
|
|
308
|
+
)
|
|
309
|
+
await db.ready()
|
|
310
|
+
|
|
311
|
+
for stmt in [
|
|
312
|
+
"DELETE FROM items",
|
|
313
|
+
"DROP TABLE items",
|
|
314
|
+
"INSERT INTO items (name) VALUES ('evil')",
|
|
315
|
+
"UPDATE items SET name = 'x'",
|
|
316
|
+
"CREATE TABLE evil (id TEXT)",
|
|
317
|
+
"ALTER TABLE items ADD COLUMN evil TEXT",
|
|
318
|
+
"REPLACE INTO items (name) VALUES ('x')",
|
|
319
|
+
"VACUUM",
|
|
320
|
+
]:
|
|
321
|
+
with pytest.raises(Exception, match="(?i)read-only|writeforbidden"):
|
|
322
|
+
await db.query(stmt)
|
|
323
|
+
|
|
324
|
+
# Index remains intact.
|
|
325
|
+
results = await db.query("SELECT name FROM items")
|
|
326
|
+
assert len(results) == 1
|
|
327
|
+
assert results[0]["name"] == "apple"
|
|
328
|
+
|
|
293
329
|
@pytest.mark.asyncio
|
|
294
330
|
async def it_raises_on_invalid_ddl(tmp_dir):
|
|
295
331
|
"""Invalid DDL raises an exception during init."""
|
|
@@ -12,6 +12,11 @@ pub enum DbError {
|
|
|
12
12
|
|
|
13
13
|
#[error("DDL parse error: {0}")]
|
|
14
14
|
DdlParse(String),
|
|
15
|
+
|
|
16
|
+
#[error(
|
|
17
|
+
"query() only accepts read-only statements; SQLite classified this statement as a write"
|
|
18
|
+
)]
|
|
19
|
+
WriteForbidden,
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
pub type Result<T> = std::result::Result<T, DbError>;
|
|
@@ -134,12 +139,20 @@ impl Db {
|
|
|
134
139
|
|
|
135
140
|
/// Query the database, returning rows as a list of column-name -> value maps.
|
|
136
141
|
///
|
|
142
|
+
/// Rejects any statement that SQLite itself classifies as a write
|
|
143
|
+
/// (INSERT / UPDATE / DELETE / DROP / CREATE / ALTER / REPLACE / VACUUM /
|
|
144
|
+
/// ANALYZE / …) via `sqlite3_stmt_readonly`, surfaced here as
|
|
145
|
+
/// [`DbError::WriteForbidden`].
|
|
146
|
+
///
|
|
137
147
|
/// Internal tracking columns (`_dirsql_*`) are excluded from `SELECT *`
|
|
138
148
|
/// results so they don't leak. But if the user names one explicitly in the
|
|
139
149
|
/// projection (e.g. `SELECT _dirsql_file_path FROM t`), it's returned —
|
|
140
150
|
/// users opt into the tracking surface by typing the column name.
|
|
141
151
|
pub fn query(&self, sql: &str) -> Result<Vec<HashMap<String, Value>>> {
|
|
142
152
|
let mut stmt = self.conn.prepare(sql)?;
|
|
153
|
+
if !stmt.readonly() {
|
|
154
|
+
return Err(DbError::WriteForbidden);
|
|
155
|
+
}
|
|
143
156
|
let column_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
|
|
144
157
|
|
|
145
158
|
let rows = stmt.query_map([], |row| {
|
|
@@ -83,6 +83,11 @@ pub enum DirSqlError {
|
|
|
83
83
|
|
|
84
84
|
#[error("no format for table '{0}': specify format explicitly or use a recognized extension")]
|
|
85
85
|
NoFormat(String),
|
|
86
|
+
|
|
87
|
+
#[error(
|
|
88
|
+
"query() only accepts read-only statements; SQLite classified this statement as a write"
|
|
89
|
+
)]
|
|
90
|
+
WriteForbidden,
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
pub type Result<T> = std::result::Result<T, DirSqlError>;
|
|
@@ -210,13 +215,21 @@ impl DirSQL {
|
|
|
210
215
|
}
|
|
211
216
|
|
|
212
217
|
/// Run a SQL query against the in-memory database.
|
|
218
|
+
///
|
|
219
|
+
/// Only read-only statements are accepted. Each statement is prepared on
|
|
220
|
+
/// SQLite and then classified via `sqlite3_stmt_readonly`; anything that
|
|
221
|
+
/// SQLite itself flags as a write — `INSERT`, `UPDATE`, `DELETE`, `DROP`,
|
|
222
|
+
/// `CREATE`, `ALTER`, `REPLACE`, `VACUUM`, `ANALYZE`, etc. — is rejected
|
|
223
|
+
/// with [`DirSqlError::WriteForbidden`] before any rows are produced. This
|
|
224
|
+
/// keeps the in-memory index consistent with the on-disk files that back
|
|
225
|
+
/// it: mutations only happen through the watcher/indexer pipeline.
|
|
213
226
|
pub fn query(&self, sql: &str) -> Result<Vec<Row>> {
|
|
214
227
|
let db = self
|
|
215
228
|
.inner
|
|
216
229
|
.db
|
|
217
230
|
.lock()
|
|
218
231
|
.map_err(|e| DirSqlError::Lock(e.to_string()))?;
|
|
219
|
-
db.query(sql).map_err(
|
|
232
|
+
db.query(sql).map_err(map_db_error)
|
|
220
233
|
}
|
|
221
234
|
|
|
222
235
|
/// Lazily create the filesystem watcher. Idempotent; subsequent calls are
|
|
@@ -491,6 +504,18 @@ impl DirSQL {
|
|
|
491
504
|
}
|
|
492
505
|
}
|
|
493
506
|
|
|
507
|
+
/// Translate a [`DbError`] into a [`DirSqlError`], promoting the core's
|
|
508
|
+
/// structural write-rejection ([`DbError::WriteForbidden`]) into
|
|
509
|
+
/// [`DirSqlError::WriteForbidden`] so callers can distinguish a rejected
|
|
510
|
+
/// write from any other query error. Every other `DbError` flows through the
|
|
511
|
+
/// usual [`DirSqlError::Core`] conversion.
|
|
512
|
+
fn map_db_error(e: DbError) -> DirSqlError {
|
|
513
|
+
match e {
|
|
514
|
+
DbError::WriteForbidden => DirSqlError::WriteForbidden,
|
|
515
|
+
other => DirSqlError::Core(other),
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
494
519
|
fn error_event(table: Option<&str>, rel_path: &str, error: String) -> RowEvent {
|
|
495
520
|
RowEvent::Error {
|
|
496
521
|
table: table.map(str::to_string),
|
|
@@ -713,3 +738,29 @@ impl AsyncDirSQL {
|
|
|
713
738
|
}
|
|
714
739
|
}
|
|
715
740
|
}
|
|
741
|
+
|
|
742
|
+
#[cfg(test)]
|
|
743
|
+
mod readonly_tests {
|
|
744
|
+
use super::*;
|
|
745
|
+
|
|
746
|
+
#[test]
|
|
747
|
+
fn map_db_error_promotes_write_forbidden() {
|
|
748
|
+
let err = map_db_error(DbError::WriteForbidden);
|
|
749
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
#[test]
|
|
753
|
+
fn map_db_error_leaves_sqlite_errors_as_core() {
|
|
754
|
+
let err = map_db_error(DbError::Sqlite(rusqlite::Error::SqliteFailure(
|
|
755
|
+
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
|
|
756
|
+
Some("syntax error".into()),
|
|
757
|
+
)));
|
|
758
|
+
assert!(matches!(err, DirSqlError::Core(_)));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
#[test]
|
|
762
|
+
fn map_db_error_leaves_schema_mismatch_as_core() {
|
|
763
|
+
let err = map_db_error(DbError::SchemaMismatch("nope".into()));
|
|
764
|
+
assert!(matches!(err, DirSqlError::Core(_)));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
//! Integration tests for the read-only enforcement on `DirSQL::query`.
|
|
2
|
+
//!
|
|
3
|
+
//! `query()` is the user-facing surface for running SQL over the index. It
|
|
4
|
+
//! must only accept statements that SQLite itself classifies as read-only via
|
|
5
|
+
//! `sqlite3_stmt_readonly`, so a caller who reaches the method (SDK user,
|
|
6
|
+
//! HTTP client, etc.) cannot mutate the in-memory index via `DELETE`, `DROP`,
|
|
7
|
+
//! `INSERT`, `UPDATE`, `CREATE`, `ALTER`, `REPLACE`, `VACUUM`, `ANALYZE`,
|
|
8
|
+
//! etc.
|
|
9
|
+
|
|
10
|
+
use dirsql::{DirSQL, DirSqlError, Table, Value};
|
|
11
|
+
use std::collections::HashMap;
|
|
12
|
+
use std::fs;
|
|
13
|
+
use std::path::Path;
|
|
14
|
+
use tempfile::TempDir;
|
|
15
|
+
|
|
16
|
+
fn items_db(root: &Path) -> DirSQL {
|
|
17
|
+
fs::write(root.join("a.txt"), "apple").unwrap();
|
|
18
|
+
fs::write(root.join("b.txt"), "banana").unwrap();
|
|
19
|
+
DirSQL::new(
|
|
20
|
+
root,
|
|
21
|
+
vec![Table::new(
|
|
22
|
+
"CREATE TABLE items (name TEXT)",
|
|
23
|
+
"*.txt",
|
|
24
|
+
|_, content| {
|
|
25
|
+
vec![HashMap::from([(
|
|
26
|
+
"name".into(),
|
|
27
|
+
Value::Text(content.trim().to_string()),
|
|
28
|
+
)])]
|
|
29
|
+
},
|
|
30
|
+
)],
|
|
31
|
+
)
|
|
32
|
+
.unwrap()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[test]
|
|
36
|
+
fn it_rejects_delete_via_query() {
|
|
37
|
+
let root = TempDir::new().unwrap();
|
|
38
|
+
let db = items_db(root.path());
|
|
39
|
+
|
|
40
|
+
let err = db.query("DELETE FROM items").unwrap_err();
|
|
41
|
+
assert!(
|
|
42
|
+
matches!(err, DirSqlError::WriteForbidden),
|
|
43
|
+
"expected WriteForbidden, got {err:?}"
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
let rows = db.query("SELECT name FROM items").unwrap();
|
|
47
|
+
assert_eq!(rows.len(), 2);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[test]
|
|
51
|
+
fn it_rejects_drop_table_via_query() {
|
|
52
|
+
let root = TempDir::new().unwrap();
|
|
53
|
+
let db = items_db(root.path());
|
|
54
|
+
|
|
55
|
+
let err = db.query("DROP TABLE items").unwrap_err();
|
|
56
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
57
|
+
|
|
58
|
+
let rows = db.query("SELECT name FROM items").unwrap();
|
|
59
|
+
assert_eq!(rows.len(), 2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[test]
|
|
63
|
+
fn it_rejects_insert_via_query() {
|
|
64
|
+
let root = TempDir::new().unwrap();
|
|
65
|
+
let db = items_db(root.path());
|
|
66
|
+
|
|
67
|
+
let err = db
|
|
68
|
+
.query("INSERT INTO items (name) VALUES ('evil')")
|
|
69
|
+
.unwrap_err();
|
|
70
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
71
|
+
|
|
72
|
+
let rows = db.query("SELECT name FROM items").unwrap();
|
|
73
|
+
assert_eq!(rows.len(), 2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[test]
|
|
77
|
+
fn it_rejects_update_via_query() {
|
|
78
|
+
let root = TempDir::new().unwrap();
|
|
79
|
+
let db = items_db(root.path());
|
|
80
|
+
|
|
81
|
+
let err = db.query("UPDATE items SET name = 'zzz'").unwrap_err();
|
|
82
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
83
|
+
|
|
84
|
+
let rows = db.query("SELECT name FROM items ORDER BY name").unwrap();
|
|
85
|
+
assert_eq!(rows[0]["name"], Value::Text("apple".into()));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[test]
|
|
89
|
+
fn it_rejects_create_table_via_query() {
|
|
90
|
+
let root = TempDir::new().unwrap();
|
|
91
|
+
let db = items_db(root.path());
|
|
92
|
+
|
|
93
|
+
let err = db.query("CREATE TABLE evil (id TEXT)").unwrap_err();
|
|
94
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[test]
|
|
98
|
+
fn it_rejects_alter_table_via_query() {
|
|
99
|
+
let root = TempDir::new().unwrap();
|
|
100
|
+
let db = items_db(root.path());
|
|
101
|
+
|
|
102
|
+
let err = db
|
|
103
|
+
.query("ALTER TABLE items ADD COLUMN evil TEXT")
|
|
104
|
+
.unwrap_err();
|
|
105
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#[test]
|
|
109
|
+
fn it_rejects_replace_via_query() {
|
|
110
|
+
let root = TempDir::new().unwrap();
|
|
111
|
+
let db = items_db(root.path());
|
|
112
|
+
|
|
113
|
+
let err = db
|
|
114
|
+
.query("REPLACE INTO items (name) VALUES ('x')")
|
|
115
|
+
.unwrap_err();
|
|
116
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[test]
|
|
120
|
+
fn it_rejects_vacuum_via_query() {
|
|
121
|
+
let root = TempDir::new().unwrap();
|
|
122
|
+
let db = items_db(root.path());
|
|
123
|
+
|
|
124
|
+
let err = db.query("VACUUM").unwrap_err();
|
|
125
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[test]
|
|
129
|
+
fn it_rejects_analyze_via_query() {
|
|
130
|
+
let root = TempDir::new().unwrap();
|
|
131
|
+
let db = items_db(root.path());
|
|
132
|
+
|
|
133
|
+
let err = db.query("ANALYZE").unwrap_err();
|
|
134
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[test]
|
|
138
|
+
fn it_allows_select_with_leading_whitespace() {
|
|
139
|
+
let root = TempDir::new().unwrap();
|
|
140
|
+
let db = items_db(root.path());
|
|
141
|
+
|
|
142
|
+
let rows = db.query(" \n\t SELECT name FROM items").unwrap();
|
|
143
|
+
assert_eq!(rows.len(), 2);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[test]
|
|
147
|
+
fn it_allows_with_cte_queries() {
|
|
148
|
+
let root = TempDir::new().unwrap();
|
|
149
|
+
let db = items_db(root.path());
|
|
150
|
+
|
|
151
|
+
let rows = db
|
|
152
|
+
.query("WITH x AS (SELECT name FROM items) SELECT * FROM x")
|
|
153
|
+
.unwrap();
|
|
154
|
+
assert_eq!(rows.len(), 2);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn it_allows_mixed_case_keywords() {
|
|
159
|
+
let root = TempDir::new().unwrap();
|
|
160
|
+
let db = items_db(root.path());
|
|
161
|
+
|
|
162
|
+
let rows = db.query("select name from items").unwrap();
|
|
163
|
+
assert_eq!(rows.len(), 2);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#[test]
|
|
167
|
+
fn it_rejects_write_after_leading_line_comment() {
|
|
168
|
+
let root = TempDir::new().unwrap();
|
|
169
|
+
let db = items_db(root.path());
|
|
170
|
+
|
|
171
|
+
let err = db.query("-- innocent\nDELETE FROM items").unwrap_err();
|
|
172
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
173
|
+
|
|
174
|
+
let rows = db.query("SELECT name FROM items").unwrap();
|
|
175
|
+
assert_eq!(rows.len(), 2);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#[test]
|
|
179
|
+
fn it_rejects_write_after_leading_block_comment() {
|
|
180
|
+
let root = TempDir::new().unwrap();
|
|
181
|
+
let db = items_db(root.path());
|
|
182
|
+
|
|
183
|
+
let err = db.query("/* innocent */ DROP TABLE items").unwrap_err();
|
|
184
|
+
assert!(matches!(err, DirSqlError::WriteForbidden));
|
|
185
|
+
|
|
186
|
+
let rows = db.query("SELECT name FROM items").unwrap();
|
|
187
|
+
assert_eq!(rows.len(), 2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn it_allows_select_after_leading_comment() {
|
|
192
|
+
let root = TempDir::new().unwrap();
|
|
193
|
+
let db = items_db(root.path());
|
|
194
|
+
|
|
195
|
+
let rows = db.query("-- heading\nSELECT name FROM items").unwrap();
|
|
196
|
+
assert_eq!(rows.len(), 2);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn write_forbidden_error_mentions_read_only() {
|
|
201
|
+
let root = TempDir::new().unwrap();
|
|
202
|
+
let db = items_db(root.path());
|
|
203
|
+
|
|
204
|
+
let err = db.query("DELETE FROM items").unwrap_err();
|
|
205
|
+
let msg = err.to_string();
|
|
206
|
+
assert!(
|
|
207
|
+
msg.to_lowercase().contains("read-only"),
|
|
208
|
+
"error message should mention read-only, got: {msg}"
|
|
209
|
+
);
|
|
210
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|