1717use crate :: codegen:: parser:: ParsedSchema ;
1818use 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?"
163215fn 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`).
185241fn 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.
206271fn 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.
227294fn 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}
0 commit comments