dirsql 0.1.6__tar.gz → 0.1.9__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.
Files changed (40) hide show
  1. {dirsql-0.1.6 → dirsql-0.1.9}/PKG-INFO +1 -1
  2. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/integration/test_dirsql.py +36 -0
  3. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/README.md +2 -0
  4. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/src/db.rs +13 -0
  5. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/src/lib.rs +52 -1
  6. dirsql-0.1.9/packages/rust/tests/readonly_query.rs +210 -0
  7. {dirsql-0.1.6 → dirsql-0.1.9}/pyproject.toml +1 -1
  8. {dirsql-0.1.6 → dirsql-0.1.9}/python/dirsql/__init__.py +4 -1
  9. {dirsql-0.1.6 → dirsql-0.1.9}/Cargo.lock +0 -0
  10. {dirsql-0.1.6 → dirsql-0.1.9}/Cargo.toml +0 -0
  11. {dirsql-0.1.6 → dirsql-0.1.9}/README.md +0 -0
  12. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/Cargo.toml +0 -0
  13. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/README.md +0 -0
  14. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/src/lib.rs +0 -0
  15. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/__init__.py +0 -0
  16. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/conftest.py +0 -0
  17. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/integration/__init__.py +0 -0
  18. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/integration/test_async_dirsql.py +0 -0
  19. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/integration/test_binding.py +0 -0
  20. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/integration/test_docs_examples.py +0 -0
  21. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/integration/test_docs_gaps.py +0 -0
  22. {dirsql-0.1.6 → dirsql-0.1.9}/packages/python/tests/integration/test_from_config.py +0 -0
  23. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/Cargo.toml +0 -0
  24. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/benches/db_bench.rs +0 -0
  25. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/benches/differ_bench.rs +0 -0
  26. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/benches/matcher_bench.rs +0 -0
  27. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/benches/scanner_bench.rs +0 -0
  28. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/src/config.rs +0 -0
  29. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/src/differ.rs +0 -0
  30. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/src/matcher.rs +0 -0
  31. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/src/parser.rs +0 -0
  32. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/src/scanner.rs +0 -0
  33. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/src/watcher.rs +0 -0
  34. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/tests/async_sdk.rs +0 -0
  35. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/tests/docs_examples.rs +0 -0
  36. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/tests/docs_gaps.rs +0 -0
  37. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/tests/from_config.rs +0 -0
  38. {dirsql-0.1.6 → dirsql-0.1.9}/packages/rust/tests/sdk.rs +0 -0
  39. {dirsql-0.1.6 → dirsql-0.1.9}/python/dirsql/_async.py +0 -0
  40. {dirsql-0.1.6 → dirsql-0.1.9}/python/dirsql/test_async.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.1.6
3
+ Version: 0.1.9
4
4
  Requires-Dist: pytest>=8 ; extra == 'dev'
5
5
  Requires-Dist: pytest-describe>=2 ; extra == 'dev'
6
6
  Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
@@ -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."""
@@ -4,6 +4,8 @@ Rust SDK for `dirsql`. Ephemeral SQL index over a local directory.
4
4
 
5
5
  [Documentation](https://thekevinscott.github.io/dirsql/?lang=rust)
6
6
 
7
+ Also available as [`dirsql` on PyPI](https://pypi.org/project/dirsql/) and [`dirsql` on npm](https://www.npmjs.com/package/dirsql).
8
+
7
9
  ## Installation
8
10
 
9
11
  ```bash
@@ -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(Into::into)
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
+ }
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "dirsql"
7
- version = "0.1.6"
7
+ version = "0.1.9"
8
8
  description = "Ephemeral SQL index over a local directory"
9
9
  license = "MIT"
10
10
  requires-python = ">=3.12"
@@ -1,4 +1,7 @@
1
- """dirsql - Ephemeral SQL index over a local directory."""
1
+ """dirsql - Ephemeral SQL index over a local directory.
2
+
3
+ Also available for Rust (crates.io: ``dirsql``) and TypeScript (npm: ``dirsql``).
4
+ """
2
5
 
3
6
  from dirsql._dirsql import Table, RowEvent, __version__
4
7
  from dirsql._async import DirSQL
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes