Skip to content

Commit 8db7095

Browse files
fix(codegen): validate every identifier before interpolation into DDL (#83)
Closes #39. `generate_sidecar_schema` interpolated `table.name` and PK column names directly into raw SQL via `format!()`. A table named `posts'); DROP TABLE x;--` would be injected verbatim into the metadata seed INSERT. The DDL generator was a SQL-injection sink wide open to whatever the schema parser handed it. Add a `crate::codegen::ident::validate_identifier` helper: - Accepts `^[A-Za-z_][A-Za-z0-9_]*$` with a 63-character length cap (Postgres' NAMEDATALEN minus null terminator). - Returns `anyhow::Error` whose message names the offending identifier (truncated to 60 chars for legibility) and the kind label ("table name" / "column name") so users can find it in their schema input. Gate every identifier at the entry of `generate_sidecar_schema` — every `table.name` and every `column.name` — so downstream `format!()` sites are safe by construction. Return type becomes `anyhow::Result<String>`. Update call sites: - `main.rs` Generate command: `?` propagates the error to the user. - In-module tests: `.expect("test schema must validate")`. - `tests/integration_test.rs`: same. New tests in `codegen::ident::tests`: - `valid_identifiers_pass`: 7 happy-path cases. - `injection_strings_rejected`: 16 attack strings including the canonical `posts'); DROP TABLE x;--`, semicolons, quotes, comments, whitespace, non-ASCII, dots, parens. - `overlong_identifiers_rejected`: 64-char input fails. - `error_names_offending_identifier`: error message includes both the bad identifier and the kind label. `cargo clippy --all-targets -- -D warnings` clean; 42 unit tests pass (up from 38). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b4d831f commit 8db7095

5 files changed

Lines changed: 175 additions & 13 deletions

File tree

src/codegen/ident.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Identifier validation for any user-controlled name flowing into
5+
// generated DDL. The codegen layer interpolates table and column names
6+
// directly into SQL via `format!()`; an unchecked name like
7+
// `posts'); DROP TABLE x;--` would be injected verbatim. Closes #39.
8+
9+
use anyhow::{Result, bail};
10+
11+
/// Allowed: leading letter or underscore, then letters/digits/underscores.
12+
/// Same shape as standard SQL identifiers without quoting. Length 1..=63
13+
/// (Postgres' NAMEDATALEN minus the null terminator).
14+
const MAX_IDENT_LEN: usize = 63;
15+
16+
/// Validate a single identifier (table or column name) for safe
17+
/// interpolation into generated DDL.
18+
///
19+
/// Returns `Ok(ident)` if `ident` matches `^[A-Za-z_][A-Za-z0-9_]*$` and
20+
/// is within the length bound; otherwise an `anyhow::Error` whose message
21+
/// includes the offending identifier (truncated) and the kind label so
22+
/// users can pinpoint the offending schema input.
23+
pub fn validate_identifier<'a>(ident: &'a str, kind: &str) -> Result<&'a str> {
24+
if ident.is_empty() {
25+
bail!("invalid {kind}: empty identifier");
26+
}
27+
if ident.len() > MAX_IDENT_LEN {
28+
bail!(
29+
"invalid {kind} '{}': identifier too long ({} > {} chars)",
30+
display_truncated(ident),
31+
ident.len(),
32+
MAX_IDENT_LEN,
33+
);
34+
}
35+
let mut chars = ident.chars();
36+
let first = chars.next().expect("non-empty checked above");
37+
if !(first.is_ascii_alphabetic() || first == '_') {
38+
bail!(
39+
"invalid {kind} '{}': must start with a letter or underscore",
40+
display_truncated(ident),
41+
);
42+
}
43+
for c in chars {
44+
if !(c.is_ascii_alphanumeric() || c == '_') {
45+
bail!(
46+
"invalid {kind} '{}': contains disallowed character {:?} \
47+
(only [A-Za-z0-9_] permitted)",
48+
display_truncated(ident),
49+
c,
50+
);
51+
}
52+
}
53+
Ok(ident)
54+
}
55+
56+
fn display_truncated(s: &str) -> String {
57+
const LIMIT: usize = 60;
58+
if s.len() <= LIMIT {
59+
s.to_string()
60+
} else {
61+
format!("{}…", &s[..LIMIT])
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use super::validate_identifier;
68+
69+
#[test]
70+
fn valid_identifiers_pass() {
71+
for ok in [
72+
"posts",
73+
"users",
74+
"Order",
75+
"_internal",
76+
"user_2024",
77+
"T",
78+
"a_b_c_d",
79+
] {
80+
validate_identifier(ok, "table").unwrap_or_else(|e| panic!("{ok} should pass: {e}"));
81+
}
82+
}
83+
84+
/// Attack strings that the validator must reject. At least 10 per the
85+
/// V-L2-G1 acceptance criteria.
86+
#[test]
87+
fn injection_strings_rejected() {
88+
let attacks = [
89+
"posts'); DROP TABLE x;--",
90+
"posts; DROP TABLE x;",
91+
"posts--",
92+
"1posts", // leading digit
93+
"", // empty
94+
"posts table", // space
95+
"posts;", // semicolon
96+
"posts'", // single quote
97+
"posts\"x\"", // double quote
98+
"posts/*x*/", // comment
99+
"posts\nx", // newline
100+
"posts\tx", // tab
101+
"posts UNION SELECT 1",
102+
"ünicode", // non-ASCII
103+
"posts.col", // dot
104+
"posts(", // paren
105+
];
106+
for attack in attacks {
107+
let result = validate_identifier(attack, "table");
108+
assert!(
109+
result.is_err(),
110+
"injection string {attack:?} must be rejected"
111+
);
112+
}
113+
}
114+
115+
/// Long identifiers (over 63 chars) must be rejected.
116+
#[test]
117+
fn overlong_identifiers_rejected() {
118+
let long = "a".repeat(64);
119+
let err = validate_identifier(&long, "column").unwrap_err();
120+
assert!(
121+
err.to_string().contains("too long"),
122+
"expected length error, got: {err}"
123+
);
124+
}
125+
126+
/// The error message must include the offending identifier so users
127+
/// can find it in their schema.
128+
#[test]
129+
fn error_names_offending_identifier() {
130+
let err = validate_identifier("bad name", "table").unwrap_err();
131+
let msg = err.to_string();
132+
assert!(
133+
msg.contains("bad name"),
134+
"error must name the offending identifier; got: {msg}"
135+
);
136+
assert!(
137+
msg.contains("table"),
138+
"error must name the kind; got: {msg}"
139+
);
140+
}
141+
}

src/codegen/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// - overlay: Generate sidecar schema DDL for enabled octad dimensions
1414
// - query: Generate query interceptor SQL for octad enrichment
1515

16+
pub mod ident;
1617
pub mod overlay;
1718
pub mod parser;
1819
pub mod query;

src/codegen/overlay.rs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,24 @@ use crate::manifest::OctadConfig;
3434
/// dimension tables to generate.
3535
///
3636
/// # Returns
37-
/// A string containing the complete DDL for the sidecar database.
38-
pub fn generate_sidecar_schema(schema: &ParsedSchema, octad: &OctadConfig) -> String {
37+
/// `Ok(String)` with the complete DDL on success. `Err` if any table or
38+
/// column name in `schema` is not a valid SQL identifier per
39+
/// [`crate::codegen::ident::validate_identifier`] — guards against SQL
40+
/// injection via parsed schema input. Closes #39.
41+
pub fn generate_sidecar_schema(
42+
schema: &ParsedSchema,
43+
octad: &OctadConfig,
44+
) -> anyhow::Result<String> {
45+
use crate::codegen::ident::validate_identifier;
46+
47+
// Fail fast on any unsafe identifier flowing into generated DDL.
48+
for table in &schema.tables {
49+
validate_identifier(&table.name, "table name")?;
50+
for column in &table.columns {
51+
validate_identifier(&column.name, "column name")?;
52+
}
53+
}
54+
3955
let mut ddl = String::new();
4056

4157
ddl.push_str("-- SPDX-License-Identifier: PMPL-1.0-or-later\n");
@@ -65,7 +81,7 @@ pub fn generate_sidecar_schema(schema: &ParsedSchema, octad: &OctadConfig) -> St
6581
ddl.push_str(&generate_simulation_table());
6682
}
6783

68-
ddl
84+
Ok(ddl)
6985
}
7086

7187
/// Generate the metadata table that tracks which target tables are augmented.
@@ -284,7 +300,7 @@ mod tests {
284300
enable_constraints: true,
285301
enable_simulation: true,
286302
};
287-
let ddl = generate_sidecar_schema(&schema, &octad);
303+
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
288304

289305
assert!(ddl.contains("verisimdb_metadata"));
290306
assert!(ddl.contains("verisimdb_provenance_log"));
@@ -308,7 +324,7 @@ mod tests {
308324
enable_constraints: true,
309325
enable_simulation: true,
310326
};
311-
let ddl = generate_sidecar_schema(&schema, &octad);
327+
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
312328

313329
// Self-referencing FK on parent_branch.
314330
assert!(
@@ -351,7 +367,7 @@ mod tests {
351367
enable_constraints: false,
352368
enable_simulation: false,
353369
};
354-
let ddl = generate_sidecar_schema(&schema, &octad);
370+
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
355371
assert!(ddl.contains("verisimdb_temporal_versions"));
356372
assert!(
357373
ddl.contains(
@@ -379,7 +395,7 @@ mod tests {
379395
enable_constraints: false,
380396
enable_simulation: false,
381397
};
382-
let ddl = generate_sidecar_schema(&schema, &octad);
398+
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
383399
assert!(ddl.contains("verisimdb_lineage_graph"));
384400
// The exact CHECK clause must be present in the emitted DDL.
385401
assert!(
@@ -399,7 +415,7 @@ mod tests {
399415
enable_constraints: false,
400416
enable_simulation: false,
401417
};
402-
let ddl = generate_sidecar_schema(&schema, &octad);
418+
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
403419

404420
// Metadata is always generated.
405421
assert!(ddl.contains("verisimdb_metadata"));
@@ -415,7 +431,7 @@ mod tests {
415431
fn test_metadata_seeds_table_info() {
416432
let schema = test_schema();
417433
let octad = OctadConfig::default();
418-
let ddl = generate_sidecar_schema(&schema, &octad);
434+
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
419435

420436
assert!(ddl.contains("INSERT OR IGNORE INTO verisimdb_metadata"));
421437
assert!(ddl.contains("'posts'"));

src/main.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,10 @@ fn main() -> Result<()> {
141141
// Create output directory.
142142
std::fs::create_dir_all(&output)?;
143143

144-
// Generate sidecar overlay schema.
145-
let overlay_ddl = codegen::overlay::generate_sidecar_schema(&schema, &m.octad);
144+
// Generate sidecar overlay schema. Errors here surface invalid
145+
// table/column identifiers in the parsed schema before they
146+
// reach disk.
147+
let overlay_ddl = codegen::overlay::generate_sidecar_schema(&schema, &m.octad)?;
146148
let overlay_path = format!("{}/sidecar_schema.sql", output);
147149
std::fs::write(&overlay_path, &overlay_ddl)?;
148150
println!("Generated sidecar schema: {}", overlay_path);

tests/integration_test.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ fn test_full_pipeline_blog_schema() {
7878
enable_constraints: true,
7979
enable_simulation: false,
8080
};
81-
let overlay_ddl = overlay::generate_sidecar_schema(&schema, &octad);
81+
let overlay_ddl =
82+
overlay::generate_sidecar_schema(&schema, &octad).expect("schema is valid");
8283

8384
// Verify all expected sidecar tables are present.
8485
assert!(
@@ -476,7 +477,8 @@ path = ".verisim/test.db"
476477
assert_eq!(schema.tables[0].name, "articles");
477478

478479
// Generate overlay.
479-
let overlay_ddl = overlay::generate_sidecar_schema(&schema, &manifest.octad);
480+
let overlay_ddl = overlay::generate_sidecar_schema(&schema, &manifest.octad)
481+
.expect("schema is valid");
480482
assert!(overlay_ddl.contains("verisim_provenance_log"));
481483
assert!(overlay_ddl.contains("verisim_temporal_versions"));
482484
assert!(

0 commit comments

Comments
 (0)