dirsql 0.3.18__tar.gz → 0.3.19__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.3.18 → dirsql-0.3.19}/Cargo.lock +1 -1
- {dirsql-0.3.18 → dirsql-0.3.19}/PKG-INFO +1 -1
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/Cargo.toml +1 -1
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/mod.rs +10 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/db.rs +254 -11
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/lib.rs +120 -31
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/persist.rs +9 -2
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/async_sdk.rs +4 -1
- dirsql-0.3.19/packages/rust/tests/code_review_findings.rs +288 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/sdk.rs +5 -2
- {dirsql-0.3.18 → dirsql-0.3.19}/Cargo.toml +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/README.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/__init__.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/_async.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/_dirsql.pyi +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/__init__.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/binary_path.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/binary_path_test.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/__init__.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/dispatch_extract.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/dispatch_extract_test.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/load_app.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/load_app_test.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/run.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/run_test.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/write_message.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/write_message_test.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/is_windows.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/is_windows_test.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/main.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/main_test.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/py.typed +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/resolve_config.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/resolve_config_test.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/test_async.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/AGENTS.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/api/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/config.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/http-api.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/init.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/server.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/getting-started.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/async.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/crdt.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/persistence.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/querying.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/tables.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/watching.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/migrations.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/package.json +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/playwright.config.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/docs/vitest.config.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/README.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/conftest.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/AGENTS.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/api/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/config.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/http-api.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/init.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/server.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/getting-started.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/async.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/crdt.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/persistence.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/querying.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/tables.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/watching.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/migrations.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/package.json +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/playwright.config.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/vitest.config.ts +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/src/lib.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/__init__.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/conftest.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/e2e/__init__.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/data/a/meta.json +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/data/b/meta.json +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/dirsql.config.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/data/a/meta.json +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/data/b/meta.json +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_no_app.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_raises.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__init__.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/interpret_subprocess.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_async_dirsql.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_binding.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_dirsql.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_docs_examples.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_docs_gaps.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_from_config.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_interpret.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_native_config.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_persist.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_serialization.py +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/Cargo.toml +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/README.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/benches/db_bench.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/benches/differ_bench.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/benches/matcher_bench.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/benches/scanner_bench.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/api/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/config.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/http-api.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/init.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/server.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/getting-started.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/async.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/crdt.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/persistence.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/querying.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/tables.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/watching.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/index.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/migrations.md +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/bin/dirsql.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/init.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/native_config.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/router.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/serialize.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/server.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/config.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/differ.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/matcher.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/scanner.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/watcher.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/cli_e2e.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/cli_integration.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/docs_examples.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/docs_gaps.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/from_config.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/init_e2e.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/init_integration.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/persist.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/readonly_query.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/serialization.rs +0 -0
- {dirsql-0.3.18 → dirsql-0.3.19}/pyproject.toml +0 -0
|
@@ -4,7 +4,7 @@ name = "dirsql-py-ext"
|
|
|
4
4
|
# pypi/maturin handler can rewrite it via `write-version` before
|
|
5
5
|
# `maturin build`. `pyproject.toml` declares `dynamic = ["version"]`
|
|
6
6
|
# and maturin reads this field. Mirrors `packages/rust/Cargo.toml`.
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.19"
|
|
8
8
|
edition.workspace = true
|
|
9
9
|
publish = false
|
|
10
10
|
readme = "README.md"
|
|
@@ -97,6 +97,16 @@ impl From<DirSQL> for AppState {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/// Symmetric construction for the degraded arm. Lets call sites build the
|
|
101
|
+
/// degraded state with the same `.into()` ergonomics as the ready arm
|
|
102
|
+
/// instead of typing the variant name. The string is the diagnostic that
|
|
103
|
+
/// `/query` and `/events` echo back as a 503 body.
|
|
104
|
+
impl From<String> for AppState {
|
|
105
|
+
fn from(reason: String) -> Self {
|
|
106
|
+
Self::Unavailable(reason)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
/// Running server handle.
|
|
101
111
|
///
|
|
102
112
|
/// Always call [`shutdown`](Self::shutdown) to release the bound port
|
|
@@ -14,12 +14,41 @@ pub enum DbError {
|
|
|
14
14
|
#[error("DDL parse error: {0}")]
|
|
15
15
|
DdlParse(String),
|
|
16
16
|
|
|
17
|
+
#[error("invalid identifier: {0:?} (must match [A-Za-z_][A-Za-z0-9_]*)")]
|
|
18
|
+
InvalidIdentifier(String),
|
|
19
|
+
|
|
17
20
|
#[error(
|
|
18
21
|
"query() only accepts read-only statements; SQLite classified this statement as a write"
|
|
19
22
|
)]
|
|
20
23
|
WriteForbidden,
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
/// Validate that `s` is a safe unquoted SQL identifier: starts with an
|
|
27
|
+
/// ASCII letter or underscore, followed by ASCII letters / digits /
|
|
28
|
+
/// underscores. Used at every entry point that interpolates an identifier
|
|
29
|
+
/// into formatted SQL (`INSERT INTO {table} ...`, `PRAGMA table_info({table})`,
|
|
30
|
+
/// `INSERT INTO {table} ({col}, ...)`).
|
|
31
|
+
///
|
|
32
|
+
/// Why a strict character class instead of quoting? Quoting would let us
|
|
33
|
+
/// accept arbitrary identifiers, but every downstream caller (and every
|
|
34
|
+
/// language-binding consumer) would then need to follow the same quoting
|
|
35
|
+
/// discipline. The strict-class is simpler to audit and matches typical
|
|
36
|
+
/// dirsql usage (DDL-defined table names + extract-produced column names
|
|
37
|
+
/// both fit the class).
|
|
38
|
+
pub fn validate_identifier(s: &str) -> Result<()> {
|
|
39
|
+
let mut chars = s.chars();
|
|
40
|
+
match chars.next() {
|
|
41
|
+
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
|
42
|
+
_ => return Err(DbError::InvalidIdentifier(s.to_string())),
|
|
43
|
+
}
|
|
44
|
+
for c in chars {
|
|
45
|
+
if !(c.is_ascii_alphanumeric() || c == '_') {
|
|
46
|
+
return Err(DbError::InvalidIdentifier(s.to_string()));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
Ok(())
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
pub type Result<T> = std::result::Result<T, DbError>;
|
|
24
53
|
|
|
25
54
|
pub struct Db {
|
|
@@ -48,7 +77,14 @@ impl Db {
|
|
|
48
77
|
|
|
49
78
|
/// Create a table from a user-provided DDL statement.
|
|
50
79
|
/// Automatically injects internal tracking columns (_dirsql_file_path, _dirsql_row_index).
|
|
80
|
+
///
|
|
81
|
+
/// Validates that the parsed table name is a safe unquoted SQL identifier
|
|
82
|
+
/// before handing the DDL to SQLite — closes the gap where a DDL like
|
|
83
|
+
/// `CREATE TABLE foo;DROP_TABLE_bar--(id TEXT)` would parse to a poisoned
|
|
84
|
+
/// internal table name and break downstream `format!()`-built SQL.
|
|
51
85
|
pub fn create_table(&self, ddl: &str) -> Result<()> {
|
|
86
|
+
let table = parse_table_name(ddl).ok_or_else(|| DbError::DdlParse(ddl.to_string()))?;
|
|
87
|
+
validate_identifier(&table)?;
|
|
52
88
|
let augmented = inject_tracking_columns(ddl)?;
|
|
53
89
|
self.conn.execute(&augmented, [])?;
|
|
54
90
|
Ok(())
|
|
@@ -56,6 +92,7 @@ impl Db {
|
|
|
56
92
|
|
|
57
93
|
/// Return the user-defined column names for `table` (excludes `_dirsql_*` tracking columns).
|
|
58
94
|
pub fn get_table_columns(&self, table: &str) -> Result<Vec<String>> {
|
|
95
|
+
validate_identifier(table)?;
|
|
59
96
|
let mut stmt = self
|
|
60
97
|
.conn
|
|
61
98
|
.prepare(&format!("PRAGMA table_info({})", table))?;
|
|
@@ -70,7 +107,12 @@ impl Db {
|
|
|
70
107
|
/// Normalize a row to match the table schema.
|
|
71
108
|
///
|
|
72
109
|
/// In relaxed mode (strict=false): extra keys are dropped, missing keys become NULL.
|
|
73
|
-
/// In strict mode (strict=true):
|
|
110
|
+
/// In strict mode (strict=true): every row key is validated as a safe
|
|
111
|
+
/// SQL identifier, then any extra or missing key produces a
|
|
112
|
+
/// SchemaMismatch error. The identifier check runs *first* so a
|
|
113
|
+
/// malformed key (e.g. one containing SQL syntax) reports as
|
|
114
|
+
/// [`DbError::InvalidIdentifier`] rather than as a less-actionable
|
|
115
|
+
/// "extra columns" mismatch.
|
|
74
116
|
pub fn normalize_row(
|
|
75
117
|
&self,
|
|
76
118
|
table: &str,
|
|
@@ -83,6 +125,9 @@ impl Db {
|
|
|
83
125
|
let row_keys: std::collections::HashSet<&str> = row.keys().map(|s| s.as_str()).collect();
|
|
84
126
|
|
|
85
127
|
if strict {
|
|
128
|
+
for key in row.keys() {
|
|
129
|
+
validate_identifier(key)?;
|
|
130
|
+
}
|
|
86
131
|
let extra: Vec<&str> = row_keys.difference(&column_set).copied().collect();
|
|
87
132
|
if !extra.is_empty() {
|
|
88
133
|
return Err(DbError::SchemaMismatch(format!(
|
|
@@ -112,6 +157,12 @@ impl Db {
|
|
|
112
157
|
|
|
113
158
|
/// Insert a row into a table.
|
|
114
159
|
/// `row` contains user-defined columns only. `file_path` and `row_index` are tracked internally.
|
|
160
|
+
///
|
|
161
|
+
/// Both the table name and every user-provided column name are validated
|
|
162
|
+
/// as safe SQL identifiers before being interpolated. A column key with
|
|
163
|
+
/// SQL syntax (e.g. `id); DROP TABLE t; --`) produces a clean
|
|
164
|
+
/// [`DbError::InvalidIdentifier`] rather than a cryptic SQLite parse
|
|
165
|
+
/// failure.
|
|
115
166
|
pub fn insert_row(
|
|
116
167
|
&self,
|
|
117
168
|
table: &str,
|
|
@@ -119,6 +170,11 @@ impl Db {
|
|
|
119
170
|
file_path: &str,
|
|
120
171
|
row_index: usize,
|
|
121
172
|
) -> Result<()> {
|
|
173
|
+
validate_identifier(table)?;
|
|
174
|
+
for key in row.keys() {
|
|
175
|
+
validate_identifier(key)?;
|
|
176
|
+
}
|
|
177
|
+
|
|
122
178
|
let mut columns: Vec<String> = row.keys().cloned().collect();
|
|
123
179
|
columns.push("_dirsql_file_path".to_string());
|
|
124
180
|
columns.push("_dirsql_row_index".to_string());
|
|
@@ -147,6 +203,7 @@ impl Db {
|
|
|
147
203
|
|
|
148
204
|
/// Delete all rows that were produced by a given file path.
|
|
149
205
|
pub fn delete_rows_by_file(&self, table: &str, file_path: &str) -> Result<usize> {
|
|
206
|
+
validate_identifier(table)?;
|
|
150
207
|
let sql = format!("DELETE FROM {} WHERE _dirsql_file_path = ?1", table);
|
|
151
208
|
let count = self.conn.execute(&sql, [file_path])?;
|
|
152
209
|
Ok(count)
|
|
@@ -163,6 +220,13 @@ impl Db {
|
|
|
163
220
|
/// results so they don't leak. But if the user names one explicitly in the
|
|
164
221
|
/// projection (e.g. `SELECT _dirsql_file_path FROM t`), it's returned —
|
|
165
222
|
/// users opt into the tracking surface by typing the column name.
|
|
223
|
+
///
|
|
224
|
+
/// The "names one explicitly" check is **comment- and string-literal-
|
|
225
|
+
/// aware**: a comment that happens to mention `_dirsql_file_path`, or
|
|
226
|
+
/// the same name appearing only inside a string literal, does NOT count
|
|
227
|
+
/// as an opt-in. The check inspects the SQL with those regions stripped
|
|
228
|
+
/// (see [`strip_sql_noise`]) so the projection filter is a real
|
|
229
|
+
/// boundary rather than a substring match.
|
|
166
230
|
pub fn query(&self, sql: &str) -> Result<Vec<HashMap<String, Value>>> {
|
|
167
231
|
let mut stmt = self.conn.prepare(sql)?;
|
|
168
232
|
if !stmt.readonly() {
|
|
@@ -170,10 +234,21 @@ impl Db {
|
|
|
170
234
|
}
|
|
171
235
|
let column_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
|
|
172
236
|
|
|
237
|
+
// Strip comments and string literals once, up front. The result is
|
|
238
|
+
// the projection-relevant SQL that we scan for explicit `_dirsql_*`
|
|
239
|
+
// references; doing it once also collapses the per-row, per-column
|
|
240
|
+
// `sql.contains(name)` from O(rows × cols × |sql|) into O(|sql|).
|
|
241
|
+
let projection_sql = strip_sql_noise(sql);
|
|
242
|
+
let explicit_dirsql: std::collections::HashSet<&str> = column_names
|
|
243
|
+
.iter()
|
|
244
|
+
.filter(|n| n.starts_with("_dirsql_") && projection_sql.contains(n.as_str()))
|
|
245
|
+
.map(String::as_str)
|
|
246
|
+
.collect();
|
|
247
|
+
|
|
173
248
|
let rows = stmt.query_map([], |row| {
|
|
174
249
|
let mut map = HashMap::new();
|
|
175
250
|
for (i, name) in column_names.iter().enumerate() {
|
|
176
|
-
if name.starts_with("_dirsql_") && !
|
|
251
|
+
if name.starts_with("_dirsql_") && !explicit_dirsql.contains(name.as_str()) {
|
|
177
252
|
continue;
|
|
178
253
|
}
|
|
179
254
|
let val: rusqlite::types::Value = row.get(i)?;
|
|
@@ -190,6 +265,60 @@ impl Db {
|
|
|
190
265
|
}
|
|
191
266
|
}
|
|
192
267
|
|
|
268
|
+
/// Strip SQL comments and string literals from `sql`, returning a copy whose
|
|
269
|
+
/// remaining text is the projection-relevant part: identifiers, keywords,
|
|
270
|
+
/// punctuation. Used by [`Db::query`] to decide whether the user explicitly
|
|
271
|
+
/// named a `_dirsql_*` column without being fooled by the name appearing
|
|
272
|
+
/// inside `-- ...`, `/* ... */`, or `'...'`.
|
|
273
|
+
///
|
|
274
|
+
/// This is intentionally not a full SQL parser. It recognises:
|
|
275
|
+
/// - `-- ...` to end of line / end of input.
|
|
276
|
+
/// - `/* ... */` block comments (non-nesting, per SQL standard).
|
|
277
|
+
/// - `'...'` string literals, with the `''` escape for embedded quotes.
|
|
278
|
+
///
|
|
279
|
+
/// Identifier-quoting forms (`"..."`, `` `...` ``, `[...]`) are passed
|
|
280
|
+
/// through verbatim, so an explicit `SELECT "_dirsql_file_path" FROM t`
|
|
281
|
+
/// still counts as a mention.
|
|
282
|
+
fn strip_sql_noise(sql: &str) -> String {
|
|
283
|
+
let mut out = String::with_capacity(sql.len());
|
|
284
|
+
let mut chars = sql.chars().peekable();
|
|
285
|
+
while let Some(c) = chars.next() {
|
|
286
|
+
match c {
|
|
287
|
+
'-' if chars.peek() == Some(&'-') => {
|
|
288
|
+
chars.next();
|
|
289
|
+
for ch in chars.by_ref() {
|
|
290
|
+
if ch == '\n' {
|
|
291
|
+
out.push('\n');
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
'/' if chars.peek() == Some(&'*') => {
|
|
297
|
+
chars.next();
|
|
298
|
+
while let Some(ch) = chars.next() {
|
|
299
|
+
if ch == '*' && chars.peek() == Some(&'/') {
|
|
300
|
+
chars.next();
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
'\'' => {
|
|
306
|
+
while let Some(ch) = chars.next() {
|
|
307
|
+
if ch == '\'' {
|
|
308
|
+
if chars.peek() == Some(&'\'') {
|
|
309
|
+
chars.next();
|
|
310
|
+
} else {
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
_ => out.push(c),
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
out
|
|
320
|
+
}
|
|
321
|
+
|
|
193
322
|
/// Inject _dirsql_file_path and _dirsql_row_index columns into a CREATE TABLE DDL statement.
|
|
194
323
|
fn inject_tracking_columns(ddl: &str) -> Result<String> {
|
|
195
324
|
// Find the last closing paren in the DDL and insert our columns before it
|
|
@@ -809,22 +938,23 @@ mod tests {
|
|
|
809
938
|
assert!(matches!(err, DbError::Sqlite(_)), "got: {err}");
|
|
810
939
|
}
|
|
811
940
|
|
|
812
|
-
// --- Error path: create_table with a
|
|
813
|
-
// (
|
|
814
|
-
//
|
|
941
|
+
// --- Error path: create_table with a DDL that has no parseable table name.
|
|
942
|
+
// `CREATE TABLE (...)` (no name between TABLE and the paren) is rejected
|
|
943
|
+
// by `parse_table_name` before SQLite ever sees it. Previously this fell
|
|
944
|
+
// through to a SQLite syntax error; now it fails fast with DdlParse.
|
|
815
945
|
|
|
816
946
|
#[test]
|
|
817
|
-
fn
|
|
947
|
+
fn create_table_without_parseable_name_fails_at_parse() {
|
|
818
948
|
let db = Db::new().unwrap();
|
|
819
949
|
let err = db
|
|
820
950
|
.create_table("CREATE TABLE (this is not valid)")
|
|
821
951
|
.unwrap_err();
|
|
822
|
-
assert!(matches!(err, DbError::
|
|
952
|
+
assert!(matches!(err, DbError::DdlParse(_)), "got: {err}");
|
|
823
953
|
}
|
|
824
954
|
|
|
825
|
-
// --- Error path: normalize_row
|
|
826
|
-
//
|
|
827
|
-
// get_table_columns
|
|
955
|
+
// --- Error path: normalize_row with a table name that isn't a safe
|
|
956
|
+
// identifier. `validate_identifier` rejects whitespace before
|
|
957
|
+
// `get_table_columns` ever runs the PRAGMA. ---
|
|
828
958
|
|
|
829
959
|
#[test]
|
|
830
960
|
fn normalize_row_propagates_column_lookup_error() {
|
|
@@ -833,6 +963,119 @@ mod tests {
|
|
|
833
963
|
let err = db
|
|
834
964
|
.normalize_row("bad name with spaces", &row, false)
|
|
835
965
|
.unwrap_err();
|
|
836
|
-
assert!(matches!(err, DbError::
|
|
966
|
+
assert!(matches!(err, DbError::InvalidIdentifier(_)), "got: {err}");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// --- validate_identifier: identifier hygiene at every interpolation site ---
|
|
970
|
+
|
|
971
|
+
#[test]
|
|
972
|
+
fn validate_identifier_accepts_simple_names() {
|
|
973
|
+
for name in ["t", "_t", "_dirsql_file_path", "Photos2024", "Snake_Case"] {
|
|
974
|
+
validate_identifier(name).unwrap_or_else(|e| panic!("{name:?} rejected: {e}"));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
#[test]
|
|
979
|
+
fn validate_identifier_rejects_empty() {
|
|
980
|
+
assert!(matches!(
|
|
981
|
+
validate_identifier(""),
|
|
982
|
+
Err(DbError::InvalidIdentifier(_))
|
|
983
|
+
));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
#[test]
|
|
987
|
+
fn validate_identifier_rejects_sql_syntax() {
|
|
988
|
+
for bad in [
|
|
989
|
+
"foo;DROP",
|
|
990
|
+
"foo bar",
|
|
991
|
+
"foo)",
|
|
992
|
+
"1leading_digit",
|
|
993
|
+
"evil--",
|
|
994
|
+
"\"quoted\"",
|
|
995
|
+
"id; DROP TABLE t; --",
|
|
996
|
+
] {
|
|
997
|
+
assert!(
|
|
998
|
+
matches!(validate_identifier(bad), Err(DbError::InvalidIdentifier(_))),
|
|
999
|
+
"expected rejection for: {bad:?}"
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
#[test]
|
|
1005
|
+
fn create_table_rejects_ddl_with_sql_syntax_in_name_slot() {
|
|
1006
|
+
let db = Db::new().unwrap();
|
|
1007
|
+
let err = db
|
|
1008
|
+
.create_table("CREATE TABLE evil;DROP_TABLE--(id TEXT)")
|
|
1009
|
+
.unwrap_err();
|
|
1010
|
+
assert!(matches!(err, DbError::InvalidIdentifier(_)), "got: {err:?}");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
#[test]
|
|
1014
|
+
fn insert_row_rejects_column_name_with_sql_syntax() {
|
|
1015
|
+
let db = Db::new().unwrap();
|
|
1016
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1017
|
+
let row = HashMap::from([("id); DROP TABLE t; --".into(), Value::Text("x".into()))]);
|
|
1018
|
+
let err = db.insert_row("t", &row, "f.json", 0).unwrap_err();
|
|
1019
|
+
assert!(matches!(err, DbError::InvalidIdentifier(_)), "got: {err:?}");
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// --- strip_sql_noise: comment / literal aware ---
|
|
1023
|
+
|
|
1024
|
+
#[test]
|
|
1025
|
+
fn strip_sql_noise_removes_line_comments() {
|
|
1026
|
+
let s = strip_sql_noise("SELECT * FROM t -- _dirsql_file_path leak\nWHERE 1=1");
|
|
1027
|
+
assert!(!s.contains("_dirsql_file_path"), "got: {s}");
|
|
1028
|
+
assert!(s.contains("SELECT * FROM t"));
|
|
1029
|
+
assert!(s.contains("WHERE 1=1"));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
#[test]
|
|
1033
|
+
fn strip_sql_noise_removes_block_comments() {
|
|
1034
|
+
let s = strip_sql_noise("SELECT /* _dirsql_file_path */ x FROM t");
|
|
1035
|
+
assert!(!s.contains("_dirsql_file_path"), "got: {s}");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
#[test]
|
|
1039
|
+
fn strip_sql_noise_removes_string_literals() {
|
|
1040
|
+
let s = strip_sql_noise("SELECT * FROM t WHERE id != '_dirsql_file_path'");
|
|
1041
|
+
assert!(!s.contains("_dirsql_file_path"), "got: {s}");
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
#[test]
|
|
1045
|
+
fn strip_sql_noise_handles_escaped_quote() {
|
|
1046
|
+
// `''` inside a string is an escaped quote; the literal continues.
|
|
1047
|
+
let s = strip_sql_noise("SELECT 'it''s _dirsql_file_path' FROM t");
|
|
1048
|
+
assert!(!s.contains("_dirsql_file_path"), "got: {s}");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
#[test]
|
|
1052
|
+
fn strip_sql_noise_preserves_quoted_identifiers() {
|
|
1053
|
+
// Double-quoted identifiers must survive — they ARE the column name.
|
|
1054
|
+
let s = strip_sql_noise(r#"SELECT "_dirsql_file_path" FROM t"#);
|
|
1055
|
+
assert!(s.contains("_dirsql_file_path"), "got: {s}");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
#[test]
|
|
1059
|
+
fn query_does_not_leak_dirsql_for_comment_mention() {
|
|
1060
|
+
let db = Db::new().unwrap();
|
|
1061
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1062
|
+
let row = HashMap::from([("id".into(), Value::Text("1".into()))]);
|
|
1063
|
+
db.insert_row("t", &row, "file.json", 0).unwrap();
|
|
1064
|
+
let rows = db.query("SELECT * FROM t /* _dirsql_file_path */").unwrap();
|
|
1065
|
+
assert_eq!(rows.len(), 1);
|
|
1066
|
+
assert!(!rows[0].contains_key("_dirsql_file_path"));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
#[test]
|
|
1070
|
+
fn query_does_not_leak_dirsql_for_string_literal_mention() {
|
|
1071
|
+
let db = Db::new().unwrap();
|
|
1072
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1073
|
+
let row = HashMap::from([("id".into(), Value::Text("x".into()))]);
|
|
1074
|
+
db.insert_row("t", &row, "file.json", 0).unwrap();
|
|
1075
|
+
let rows = db
|
|
1076
|
+
.query("SELECT * FROM t WHERE id != '_dirsql_file_path'")
|
|
1077
|
+
.unwrap();
|
|
1078
|
+
assert_eq!(rows.len(), 1);
|
|
1079
|
+
assert!(!rows[0].contains_key("_dirsql_file_path"));
|
|
837
1080
|
}
|
|
838
1081
|
}
|