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.
Files changed (162) hide show
  1. {dirsql-0.3.18 → dirsql-0.3.19}/Cargo.lock +1 -1
  2. {dirsql-0.3.18 → dirsql-0.3.19}/PKG-INFO +1 -1
  3. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/mod.rs +10 -0
  5. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/db.rs +254 -11
  6. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/lib.rs +120 -31
  7. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/persist.rs +9 -2
  8. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/async_sdk.rs +4 -1
  9. dirsql-0.3.19/packages/rust/tests/code_review_findings.rs +288 -0
  10. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/sdk.rs +5 -2
  11. {dirsql-0.3.18 → dirsql-0.3.19}/Cargo.toml +0 -0
  12. {dirsql-0.3.18 → dirsql-0.3.19}/README.md +0 -0
  13. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/__init__.py +0 -0
  14. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/_async.py +0 -0
  15. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/_dirsql.pyi +0 -0
  16. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/__init__.py +0 -0
  17. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/binary_path.py +0 -0
  18. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/binary_path_test.py +0 -0
  19. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/__init__.py +0 -0
  20. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/dispatch_extract.py +0 -0
  21. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/dispatch_extract_test.py +0 -0
  22. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/load_app.py +0 -0
  23. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/load_app_test.py +0 -0
  24. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/run.py +0 -0
  25. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/run_test.py +0 -0
  26. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/write_message.py +0 -0
  27. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/interpret/write_message_test.py +0 -0
  28. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/is_windows.py +0 -0
  29. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/is_windows_test.py +0 -0
  30. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/main.py +0 -0
  31. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/cli/main_test.py +0 -0
  32. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/py.typed +0 -0
  33. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/resolve_config.py +0 -0
  34. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/resolve_config_test.py +0 -0
  35. {dirsql-0.3.18 → dirsql-0.3.19}/dirsql/test_async.py +0 -0
  36. {dirsql-0.3.18 → dirsql-0.3.19}/docs/.claude/CLAUDE.md +0 -0
  37. {dirsql-0.3.18 → dirsql-0.3.19}/docs/.vitepress/config.ts +0 -0
  38. {dirsql-0.3.18 → dirsql-0.3.19}/docs/.vitepress/theme/index.ts +0 -0
  39. {dirsql-0.3.18 → dirsql-0.3.19}/docs/.vitepress/theme/lang.ts +0 -0
  40. {dirsql-0.3.18 → dirsql-0.3.19}/docs/AGENTS.md +0 -0
  41. {dirsql-0.3.18 → dirsql-0.3.19}/docs/api/index.md +0 -0
  42. {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/config.md +0 -0
  43. {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/http-api.md +0 -0
  44. {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/index.md +0 -0
  45. {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/init.md +0 -0
  46. {dirsql-0.3.18 → dirsql-0.3.19}/docs/cli/server.md +0 -0
  47. {dirsql-0.3.18 → dirsql-0.3.19}/docs/getting-started.md +0 -0
  48. {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/async.md +0 -0
  49. {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/crdt.md +0 -0
  50. {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/persistence.md +0 -0
  51. {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/querying.md +0 -0
  52. {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/tables.md +0 -0
  53. {dirsql-0.3.18 → dirsql-0.3.19}/docs/guide/watching.md +0 -0
  54. {dirsql-0.3.18 → dirsql-0.3.19}/docs/index.md +0 -0
  55. {dirsql-0.3.18 → dirsql-0.3.19}/docs/migrations.md +0 -0
  56. {dirsql-0.3.18 → dirsql-0.3.19}/docs/package.json +0 -0
  57. {dirsql-0.3.18 → dirsql-0.3.19}/docs/playwright.config.ts +0 -0
  58. {dirsql-0.3.18 → dirsql-0.3.19}/docs/pnpm-lock.yaml +0 -0
  59. {dirsql-0.3.18 → dirsql-0.3.19}/docs/pnpm-workspace.yaml +0 -0
  60. {dirsql-0.3.18 → dirsql-0.3.19}/docs/tests/integration/home.spec.ts +0 -0
  61. {dirsql-0.3.18 → dirsql-0.3.19}/docs/tests/integration/language-flag.spec.ts +0 -0
  62. {dirsql-0.3.18 → dirsql-0.3.19}/docs/tests/unit/config.test.ts +0 -0
  63. {dirsql-0.3.18 → dirsql-0.3.19}/docs/tests/unit/lang.test.ts +0 -0
  64. {dirsql-0.3.18 → dirsql-0.3.19}/docs/vitest.config.ts +0 -0
  65. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/README.md +0 -0
  66. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/conftest.py +0 -0
  67. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/.claude/CLAUDE.md +0 -0
  68. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/.vitepress/config.ts +0 -0
  69. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  70. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  71. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/AGENTS.md +0 -0
  72. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/api/index.md +0 -0
  73. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/config.md +0 -0
  74. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/http-api.md +0 -0
  75. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/index.md +0 -0
  76. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/init.md +0 -0
  77. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/cli/server.md +0 -0
  78. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/getting-started.md +0 -0
  79. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/async.md +0 -0
  80. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/crdt.md +0 -0
  81. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/persistence.md +0 -0
  82. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/querying.md +0 -0
  83. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/tables.md +0 -0
  84. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/guide/watching.md +0 -0
  85. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/index.md +0 -0
  86. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/migrations.md +0 -0
  87. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/package.json +0 -0
  88. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/playwright.config.ts +0 -0
  89. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/pnpm-lock.yaml +0 -0
  90. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/pnpm-workspace.yaml +0 -0
  91. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  92. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  93. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/tests/unit/config.test.ts +0 -0
  94. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  95. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/docs/vitest.config.ts +0 -0
  96. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/src/lib.rs +0 -0
  97. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/__init__.py +0 -0
  98. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/conftest.py +0 -0
  99. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/e2e/__init__.py +0 -0
  100. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/data/a/meta.json +0 -0
  101. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/data/b/meta.json +0 -0
  102. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/dirsql.config.py +0 -0
  103. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/data/a/meta.json +0 -0
  104. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/data/b/meta.json +0 -0
  105. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config.py +0 -0
  106. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_no_app.py +0 -0
  107. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_raises.py +0 -0
  108. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/__init__.py +0 -0
  109. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/interpret_subprocess.py +0 -0
  110. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_async_dirsql.py +0 -0
  111. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_binding.py +0 -0
  112. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_dirsql.py +0 -0
  113. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_docs_examples.py +0 -0
  114. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_docs_gaps.py +0 -0
  115. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_from_config.py +0 -0
  116. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_interpret.py +0 -0
  117. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_native_config.py +0 -0
  118. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_persist.py +0 -0
  119. {dirsql-0.3.18 → dirsql-0.3.19}/packages/python/tests/integration/test_serialization.py +0 -0
  120. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/Cargo.toml +0 -0
  121. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/README.md +0 -0
  122. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/benches/db_bench.rs +0 -0
  123. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/benches/differ_bench.rs +0 -0
  124. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/benches/matcher_bench.rs +0 -0
  125. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/benches/scanner_bench.rs +0 -0
  126. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/api/index.md +0 -0
  127. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/config.md +0 -0
  128. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/http-api.md +0 -0
  129. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/index.md +0 -0
  130. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/init.md +0 -0
  131. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/cli/server.md +0 -0
  132. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/getting-started.md +0 -0
  133. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/async.md +0 -0
  134. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/crdt.md +0 -0
  135. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/persistence.md +0 -0
  136. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/querying.md +0 -0
  137. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/tables.md +0 -0
  138. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/guide/watching.md +0 -0
  139. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/index.md +0 -0
  140. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/docs/migrations.md +0 -0
  141. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/bin/dirsql.rs +0 -0
  142. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/init.rs +0 -0
  143. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/native_config.rs +0 -0
  144. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/router.rs +0 -0
  145. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/serialize.rs +0 -0
  146. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/cli/server.rs +0 -0
  147. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/config.rs +0 -0
  148. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/differ.rs +0 -0
  149. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/matcher.rs +0 -0
  150. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/scanner.rs +0 -0
  151. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/src/watcher.rs +0 -0
  152. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/cli_e2e.rs +0 -0
  153. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/cli_integration.rs +0 -0
  154. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/docs_examples.rs +0 -0
  155. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/docs_gaps.rs +0 -0
  156. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/from_config.rs +0 -0
  157. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/init_e2e.rs +0 -0
  158. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/init_integration.rs +0 -0
  159. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/persist.rs +0 -0
  160. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/readonly_query.rs +0 -0
  161. {dirsql-0.3.18 → dirsql-0.3.19}/packages/rust/tests/serialization.rs +0 -0
  162. {dirsql-0.3.18 → dirsql-0.3.19}/pyproject.toml +0 -0
@@ -499,7 +499,7 @@ dependencies = [
499
499
 
500
500
  [[package]]
501
501
  name = "dirsql-py-ext"
502
- version = "0.3.18"
502
+ version = "0.3.19"
503
503
  dependencies = [
504
504
  "dirsql",
505
505
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.18
3
+ Version: 0.3.19
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'
@@ -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.18"
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): extra or missing keys produce a SchemaMismatch error.
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_") && !sql.contains(name) {
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 syntactically-valid-looking DDL
813
- // (has a closing paren so it passes inject_tracking_columns) but invalid
814
- // SQL, so the failure happens at conn.execute rather than the injector ---
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 create_table_invalid_sql_with_paren_fails_at_execute() {
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::Sqlite(_)), "got: {err}");
952
+ assert!(matches!(err, DbError::DdlParse(_)), "got: {err}");
823
953
  }
824
954
 
825
- // --- Error path: normalize_row when the underlying table lookup fails.
826
- // A table name containing whitespace makes the PRAGMA prepare fail, so
827
- // get_table_columns errors and normalize_row's `?` propagates. ---
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::Sqlite(_)), "got: {err}");
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
  }