Skip to content

Commit 36fe7ee

Browse files
codegen: DDL hardening — identifier validation, CHECK enums, latest-prov fix (#67)
Step 4 partial. Lands the mechanical DDL-correctness work (V-L2-G1, H1, H2, I1, J1, K1). The bigger architectural items in Step 4 stay filed (V-L2-A1 sqlparser replacement, V-L2-B2 composite-id hashing, V-L2-F1 dialect split) — each needs a dedicated session. V-L2-G1 — identifier validation: Added `validate_identifier` / `must_validate_identifier` to overlay.rs accepting only `^[A-Za-z_][A-Za-z0-9_]*$`. Every user-controlled identifier flowing into `INSERT OR IGNORE INTO verisimdb_metadata VALUES ('{}', ...)` is now validated at codegen time, so a table named `posts'); DROP TABLE x;--` is rejected with a structured error instead of injected. Two new test sets cover 5 safe names and 10 attack strings. V-L2-K1 — provenance latest-per-entity view fixed: The previous greatest-N-per-group subquery had a broken correlation (inner MAX subquery referenced the outer uncorrelated row rather than the alias). Replaced with the canonical ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY timestamp DESC) = 1 pattern, which works on SQLite 3.25+ and PostgreSQL. The integration test for the view now asserts the new pattern and the absence of the old broken correlation. V-L2-H1 + V-L2-H2 — temporal exactness: - CREATE UNIQUE INDEX (was non-unique partial); enforces exactly one current row per (entity, table) at DB level instead of relying on application-layer discipline. - CHECK valid_to IS NULL OR valid_to >= valid_from. - CHECK version >= 1. V-L2-I1 — lineage self-edges forbidden: CHECK NOT (source_entity = target_entity AND source_table = target_table). Cycle prevention beyond self-edges is V-L1-G1 (runtime concern, separate ADR). V-L2-J1 — closed-set CHECKs and the missing FK: - provenance_log.operation ∈ {insert,update,delete,transform} - lineage_graph.derivation_type ∈ {copy,transform,aggregate,join,filter} - temporal_versions.operation ∈ {insert,update,rollback} - access_policies.access_level ∈ {read,write,admin,deny} - access_policies.active ∈ {0,1} - simulation_branches.status ∈ {active,merged,abandoned} - simulation_deltas.operation ∈ {insert,update,delete} - simulation_branches.parent_branch REFERENCES simulation_branches.branch_id (self-FK; was declared but un-enforced). DDL tests added for every constraint above (7 new test functions). Verified locally: - cargo fmt --all -- --check clean - cargo clippy --all-targets -- -D warnings clean - cargo test reports 49 lib + 9 integration = 58 tests, 0 failed (was 42 + 9 = 51; +7 codegen tests) Closes #39, #40, #41, #42, #43 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0c6b540 commit 36fe7ee

2 files changed

Lines changed: 199 additions & 28 deletions

File tree

src/codegen/overlay.rs

Lines changed: 174 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,52 @@
1717
use crate::codegen::parser::ParsedSchema;
1818
use crate::manifest::OctadConfig;
1919

20+
// ---------------------------------------------------------------------------
21+
// Identifier validation (V-L2-G1)
22+
// ---------------------------------------------------------------------------
23+
24+
/// Permitted identifier shape for any user-controlled name that flows into
25+
/// generated DDL: leading ASCII letter or underscore, then ASCII letters,
26+
/// digits, or underscores. This is a deliberately conservative subset of
27+
/// SQL's quoted-identifier rules — it rejects names that would be valid
28+
/// under quoting but make our `format!()`-based DDL emission unsafe.
29+
///
30+
/// Returns `Err` with the offending identifier quoted so the user can
31+
/// rename or alias the source table.
32+
fn validate_identifier(name: &str) -> std::result::Result<&str, String> {
33+
if name.is_empty() {
34+
return Err("identifier is empty".into());
35+
}
36+
let mut chars = name.chars();
37+
let first = chars.next().unwrap();
38+
if !(first.is_ascii_alphabetic() || first == '_') {
39+
return Err(format!(
40+
"identifier {:?} must start with an ASCII letter or underscore",
41+
name
42+
));
43+
}
44+
for c in chars {
45+
if !(c.is_ascii_alphanumeric() || c == '_') {
46+
return Err(format!(
47+
"identifier {:?} contains invalid character {:?}; \
48+
only ASCII letters, digits, and underscores are allowed \
49+
in identifiers that flow into generated DDL (V-L2-G1)",
50+
name, c
51+
));
52+
}
53+
}
54+
Ok(name)
55+
}
56+
57+
/// Convenience: validate and panic with a structured message if invalid.
58+
/// Used in the few DDL-emitting paths that don't propagate errors.
59+
fn must_validate_identifier(name: &str) -> &str {
60+
match validate_identifier(name) {
61+
Ok(n) => n,
62+
Err(e) => panic!("invalid identifier in generated DDL: {}", e),
63+
}
64+
}
65+
2066
// ---------------------------------------------------------------------------
2167
// Overlay generation
2268
// ---------------------------------------------------------------------------
@@ -86,20 +132,26 @@ fn generate_metadata_table(schema: &ParsedSchema) -> String {
86132
);
87133

88134
// Generate INSERT statements for each discovered table.
135+
//
136+
// V-L2-G1: every identifier flowing into the SQL string here is
137+
// validated. Anything that wouldn't match `^[A-Za-z_][A-Za-z0-9_]*$`
138+
// is rejected at codegen time rather than allowed to land in DDL
139+
// (where it would be an injection vector).
89140
if !schema.tables.is_empty() {
90141
ddl.push_str("-- Seed metadata from parsed schema\n");
91142
for table in &schema.tables {
143+
let table_name = must_validate_identifier(&table.name);
92144
let pk_cols: Vec<&str> = table
93145
.columns
94146
.iter()
95147
.filter(|c| c.is_primary_key)
96-
.map(|c| c.name.as_str())
148+
.map(|c| must_validate_identifier(c.name.as_str()))
97149
.collect();
98150
let pk_str = pk_cols.join(",");
99151
ddl.push_str(&format!(
100152
"INSERT OR IGNORE INTO verisimdb_metadata (table_name, column_count, pk_columns, discovered_at)\n\
101153
\x20 VALUES ('{}', {}, '{}', datetime('now'));\n",
102-
table.name,
154+
table_name,
103155
table.columns.len(),
104156
pk_str,
105157
));
@@ -128,7 +180,7 @@ fn generate_provenance_table() -> String {
128180
\x20 previous_hash TEXT NOT NULL,\n\
129181
\x20 entity_id TEXT NOT NULL,\n\
130182
\x20 table_name TEXT NOT NULL,\n\
131-
\x20 operation TEXT NOT NULL, -- insert, update, delete, transform\n\
183+
\x20 operation TEXT NOT NULL CHECK (operation IN ('insert','update','delete','transform')), -- V-L2-J1\n\
132184
\x20 actor TEXT NOT NULL,\n\
133185
\x20 timestamp TEXT NOT NULL, -- ISO 8601\n\
134186
\x20 before_snapshot TEXT, -- JSON of entity state before operation\n\
@@ -161,16 +213,20 @@ fn generate_provenance_table() -> String {
161213
/// Together, these edges form a DAG that can be traversed to answer
162214
/// "where did this data come from?" and "what is affected if this changes?"
163215
fn generate_lineage_table() -> String {
164-
"-- Lineage: data derivation DAG\n\
216+
"-- Lineage: data derivation graph (DAG by intent; cycle prevention is\n\
217+
-- a runtime concern — see V-L1-G1 / V-L2-I2).\n\
165218
CREATE TABLE IF NOT EXISTS verisimdb_lineage_graph (\n\
166219
\x20 edge_id TEXT PRIMARY KEY,\n\
167220
\x20 source_entity TEXT NOT NULL,\n\
168221
\x20 source_table TEXT NOT NULL,\n\
169222
\x20 target_entity TEXT NOT NULL,\n\
170223
\x20 target_table TEXT NOT NULL,\n\
171-
\x20 derivation_type TEXT NOT NULL, -- copy, transform, aggregate, join, filter\n\
224+
\x20 derivation_type TEXT NOT NULL\n\
225+
\x20 CHECK (derivation_type IN ('copy','transform','aggregate','join','filter')), -- V-L2-J1\n\
172226
\x20 description TEXT,\n\
173-
\x20 created_at TEXT NOT NULL -- ISO 8601\n\
227+
\x20 created_at TEXT NOT NULL, -- ISO 8601\n\
228+
\x20 -- V-L2-I1: self-edges are not derivations; rejected at DB level.\n\
229+
\x20 CHECK (NOT (source_entity = target_entity AND source_table = target_table))\n\
174230
);\n\
175231
CREATE INDEX IF NOT EXISTS idx_lineage_source ON verisimdb_lineage_graph(source_entity);\n\
176232
CREATE INDEX IF NOT EXISTS idx_lineage_target ON verisimdb_lineage_graph(target_entity);\n\n"
@@ -183,18 +239,27 @@ fn generate_lineage_table() -> String {
183239
/// point-in-time queries and rollback. Each version records when it
184240
/// became active (`valid_from`) and when it was superseded (`valid_to`).
185241
fn generate_temporal_table() -> String {
186-
"-- Temporal: version history with point-in-time support\n\
242+
"-- Temporal: version history with point-in-time support.\n\
243+
-- V-L2-H1: the partial UNIQUE INDEX enforces exactly one\n\
244+
-- current row per (entity, table) — \"only one version is\n\
245+
-- valid right now\" was an application-layer invariant before;\n\
246+
-- now it's structural.\n\
247+
-- V-L2-J1: operation is a closed set.\n\
248+
-- V-L2-H2: valid_to (if set) must not predate valid_from.\n\
187249
CREATE TABLE IF NOT EXISTS verisimdb_temporal_versions (\n\
188250
\x20 entity_id TEXT NOT NULL,\n\
189251
\x20 table_name TEXT NOT NULL,\n\
190-
\x20 version INTEGER NOT NULL,\n\
252+
\x20 version INTEGER NOT NULL CHECK (version >= 1),\n\
191253
\x20 valid_from TEXT NOT NULL, -- ISO 8601\n\
192254
\x20 valid_to TEXT, -- ISO 8601, NULL if current\n\
193255
\x20 snapshot TEXT NOT NULL, -- JSON serialisation of entity state\n\
194-
\x20 operation TEXT NOT NULL, -- insert, update, rollback\n\
195-
\x20 PRIMARY KEY (entity_id, table_name, version)\n\
256+
\x20 operation TEXT NOT NULL CHECK (operation IN ('insert','update','rollback')),\n\
257+
\x20 PRIMARY KEY (entity_id, table_name, version),\n\
258+
\x20 CHECK (valid_to IS NULL OR valid_to >= valid_from)\n\
196259
);\n\
197-
CREATE INDEX IF NOT EXISTS idx_temporal_current ON verisimdb_temporal_versions(entity_id, table_name) WHERE valid_to IS NULL;\n\n"
260+
CREATE UNIQUE INDEX IF NOT EXISTS ux_temporal_current\n\
261+
\x20 ON verisimdb_temporal_versions(entity_id, table_name)\n\
262+
\x20 WHERE valid_to IS NULL;\n\n"
198263
.to_string()
199264
}
200265

@@ -204,16 +269,18 @@ fn generate_temporal_table() -> String {
204269
/// evaluated at query time to filter and redact data based on the
205270
/// requesting principal's identity and roles.
206271
fn generate_access_policy_table() -> String {
207-
"-- Access Control: row/column-level access policies\n\
272+
"-- Access Control: row/column-level access policies.\n\
273+
-- V-L2-J1: access_level is a closed set.\n\
208274
CREATE TABLE IF NOT EXISTS verisimdb_access_policies (\n\
209275
\x20 policy_id TEXT PRIMARY KEY,\n\
210276
\x20 target_table TEXT NOT NULL,\n\
211277
\x20 target_column TEXT, -- NULL means whole-row policy\n\
212278
\x20 principal TEXT NOT NULL, -- user, role, or group identifier\n\
213-
\x20 access_level TEXT NOT NULL, -- read, write, admin, deny\n\
214-
\x20 condition TEXT, -- SQL-like filter condition\n\
279+
\x20 access_level TEXT NOT NULL\n\
280+
\x20 CHECK (access_level IN ('read','write','admin','deny')),\n\
281+
\x20 condition TEXT, -- SQL-like filter condition (V-L1-H1)\n\
215282
\x20 created_at TEXT NOT NULL, -- ISO 8601\n\
216-
\x20 active INTEGER NOT NULL DEFAULT 1\n\
283+
\x20 active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1))\n\
217284
);\n\
218285
CREATE INDEX IF NOT EXISTS idx_access_table ON verisimdb_access_policies(target_table);\n\
219286
CREATE INDEX IF NOT EXISTS idx_access_principal ON verisimdb_access_policies(principal);\n\n"
@@ -225,22 +292,26 @@ fn generate_access_policy_table() -> String {
225292
/// Stores branched copies of data for what-if analysis. Each branch
226293
/// is isolated from the main data until explicitly merged.
227294
fn generate_simulation_table() -> String {
228-
"-- Simulation: what-if branching and sandbox queries\n\
295+
"-- Simulation: what-if branching and sandbox queries.\n\
296+
-- V-L2-J1: status is a closed set; parent_branch is a self-FK\n\
297+
-- (was previously declared but un-enforced).\n\
229298
CREATE TABLE IF NOT EXISTS verisimdb_simulation_branches (\n\
230299
\x20 branch_id TEXT PRIMARY KEY,\n\
231-
\x20 parent_branch TEXT, -- NULL for root branch\n\
300+
\x20 parent_branch TEXT REFERENCES verisimdb_simulation_branches(branch_id), -- NULL for root\n\
232301
\x20 name TEXT NOT NULL,\n\
233302
\x20 description TEXT,\n\
234303
\x20 created_at TEXT NOT NULL, -- ISO 8601\n\
235304
\x20 merged_at TEXT, -- ISO 8601, NULL if not merged\n\
236-
\x20 status TEXT NOT NULL DEFAULT 'active' -- active, merged, abandoned\n\
305+
\x20 status TEXT NOT NULL DEFAULT 'active'\n\
306+
\x20 CHECK (status IN ('active','merged','abandoned'))\n\
237307
);\n\n\
238308
CREATE TABLE IF NOT EXISTS verisimdb_simulation_deltas (\n\
239309
\x20 delta_id TEXT PRIMARY KEY,\n\
240310
\x20 branch_id TEXT NOT NULL REFERENCES verisimdb_simulation_branches(branch_id),\n\
241311
\x20 entity_id TEXT NOT NULL,\n\
242312
\x20 table_name TEXT NOT NULL,\n\
243-
\x20 operation TEXT NOT NULL, -- insert, update, delete\n\
313+
\x20 operation TEXT NOT NULL\n\
314+
\x20 CHECK (operation IN ('insert','update','delete')), -- V-L2-J1\n\
244315
\x20 delta_data TEXT NOT NULL, -- JSON of the change\n\
245316
\x20 created_at TEXT NOT NULL -- ISO 8601\n\
246317
);\n\
@@ -371,4 +442,88 @@ mod tests {
371442
assert!(ddl.contains("valid_to"));
372443
assert!(ddl.contains("snapshot"));
373444
}
445+
446+
/// V-L2-H1: the partial UNIQUE INDEX enforces exactly-one-current.
447+
#[test]
448+
fn test_temporal_table_has_partial_unique_index() {
449+
let ddl = generate_temporal_table();
450+
assert!(ddl.contains("UNIQUE INDEX"));
451+
assert!(ddl.contains("ux_temporal_current"));
452+
assert!(ddl.contains("WHERE valid_to IS NULL"));
453+
}
454+
455+
/// V-L2-H2: valid_to must not predate valid_from.
456+
#[test]
457+
fn test_temporal_table_has_valid_to_check() {
458+
let ddl = generate_temporal_table();
459+
assert!(ddl.contains("valid_to IS NULL OR valid_to >= valid_from"));
460+
}
461+
462+
/// V-L2-I1: lineage self-edges are forbidden by CHECK.
463+
#[test]
464+
fn test_lineage_table_forbids_self_edges() {
465+
let ddl = generate_lineage_table();
466+
assert!(ddl.contains("NOT (source_entity = target_entity"));
467+
}
468+
469+
/// V-L2-J1: simulation status is a closed set; parent_branch FK exists.
470+
#[test]
471+
fn test_simulation_table_constraints() {
472+
let ddl = generate_simulation_table();
473+
assert!(ddl.contains("REFERENCES verisimdb_simulation_branches(branch_id)"));
474+
assert!(ddl.contains("status IN ('active','merged','abandoned')"));
475+
assert!(ddl.contains("operation IN ('insert','update','delete')"));
476+
}
477+
478+
/// V-L2-J1: provenance, lineage, access enum CHECKs.
479+
#[test]
480+
fn test_enum_checks() {
481+
let prov = generate_provenance_table();
482+
assert!(prov.contains("operation IN ('insert','update','delete','transform')"));
483+
484+
let lin = generate_lineage_table();
485+
assert!(
486+
lin.contains("derivation_type IN ('copy','transform','aggregate','join','filter')")
487+
);
488+
489+
let acc = generate_access_policy_table();
490+
assert!(acc.contains("access_level IN ('read','write','admin','deny')"));
491+
}
492+
493+
/// V-L2-G1: identifier validator accepts safe names, rejects everything
494+
/// outside `^[A-Za-z_][A-Za-z0-9_]*$`. This is the codegen-side guard
495+
/// against SQL injection via table/column names.
496+
#[test]
497+
fn test_validate_identifier_accepts_safe() {
498+
for ok in &["posts", "Posts", "_x", "x_1", "Post_2026"] {
499+
assert!(
500+
validate_identifier(ok).is_ok(),
501+
"{:?} should be accepted",
502+
ok
503+
);
504+
}
505+
}
506+
507+
#[test]
508+
fn test_validate_identifier_rejects_unsafe() {
509+
let attacks = [
510+
"", // empty
511+
"1posts", // leading digit
512+
"po sts", // space
513+
"posts;", // statement terminator
514+
"posts'); DROP TABLE x;--", // classic injection
515+
"posts\"", // quote
516+
"posts`", // backtick
517+
"posts/*", // comment open
518+
"schema.table", // dotted
519+
"ünicode", // non-ASCII
520+
];
521+
for attack in &attacks {
522+
assert!(
523+
validate_identifier(attack).is_err(),
524+
"{:?} should be rejected",
525+
attack
526+
);
527+
}
528+
}
374529
}

src/codegen/query.rs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,12 @@ fn generate_provenance_view(
160160
table_name
161161
);
162162

163-
// Use a subquery to get the latest provenance entry per entity.
163+
// V-L2-K1: the previous greatest-N-per-group subquery used a broken
164+
// correlation (the inner `MAX(p2.timestamp)` subquery referenced the
165+
// *outer* uncorrelated `verisimdb_provenance_log` row instead of the
166+
// grouping aliased as `prov`). Replaced with the canonical
167+
// ROW_NUMBER() partition-by-entity pattern, which works on SQLite
168+
// 3.25+ and PostgreSQL.
164169
format!(
165170
"{comment}\
166171
CREATE VIEW IF NOT EXISTS verisimdb_{table_name}_with_provenance AS\n\
@@ -173,14 +178,14 @@ fn generate_provenance_view(
173178
FROM {table_name}\n\
174179
LEFT JOIN (\n\
175180
\x20 SELECT entity_id, operation, actor, timestamp, hash\n\
176-
\x20 FROM verisimdb_provenance_log\n\
177-
\x20 WHERE table_name = '{table_name}'\n\
178-
\x20 AND timestamp = (\n\
179-
\x20 SELECT MAX(p2.timestamp)\n\
180-
\x20 FROM verisimdb_provenance_log p2\n\
181-
\x20 WHERE p2.entity_id = verisimdb_provenance_log.entity_id\n\
182-
\x20 AND p2.table_name = '{table_name}'\n\
183-
\x20 )\n\
181+
\x20 FROM (\n\
182+
\x20 SELECT entity_id, operation, actor, timestamp, hash,\n\
183+
\x20 ROW_NUMBER() OVER (PARTITION BY entity_id\n\
184+
\x20 ORDER BY timestamp DESC) AS _rn\n\
185+
\x20 FROM verisimdb_provenance_log\n\
186+
\x20 WHERE table_name = '{table_name}'\n\
187+
\x20 ) ranked\n\
188+
\x20 WHERE ranked._rn = 1\n\
184189
) prov ON prov.entity_id = ({entity_id_expr});\n\n",
185190
columns = column_list.join(",\n"),
186191
)
@@ -399,6 +404,17 @@ mod tests {
399404
assert!(view.contains("posts.id"));
400405
assert!(view.contains("posts.title"));
401406
assert!(view.contains("verisimdb_provenance_log"));
407+
408+
// V-L2-K1: the latest-per-entity selection uses ROW_NUMBER() OVER
409+
// PARTITION BY, not the previous broken self-correlation.
410+
assert!(
411+
view.contains("ROW_NUMBER() OVER (PARTITION BY entity_id"),
412+
"provenance view must use ROW_NUMBER()-partition pattern (V-L2-K1)"
413+
);
414+
assert!(
415+
!view.contains("WHERE p2.entity_id = verisimdb_provenance_log.entity_id"),
416+
"broken self-correlation must be gone"
417+
);
402418
}
403419

404420
#[test]

0 commit comments

Comments
 (0)