Closes the remaining v0.29.0 follow-ups + post-audit MEDIUMs. ## HttpDriver (kei-spawn http-driver feature) - Real reqwest::blocking POST to api.anthropic.com/v1/messages - Feature flag `http-driver = ["dep:reqwest"]` (default off, zero breaking) - KEI_ANTHROPIC_KEY read at invoke time (rotation-friendly) - 5 httpmock tests (missing key, 200, 4xx, 5xx, malformed json) - Endpoint override via KEI_ANTHROPIC_ENDPOINT env for tests - Files: drive.rs, drive_http.rs (new), drive_http_parse.rs (new), tests/http_driver.rs ## agent_id path-traversal validator (HIGH) - New validate.rs with validate_agent_id() — whitelist grammar, 64-char cap, rejects /, \, .., leading dot/dash, NUL, :, whitespace, non-ASCII, Windows-reserved (CON/PRN/AUX/NUL/COM1-9/LPT1-9) - Wired into all 5 agent_id→path sinks: load_task, resolve_agent_id, prepare, simulated_merge, verify_task - autogen_agent_id moved to validate.rs with slugify_role helper — output passes validator by construction (100-draw property test) - 33 new tests in agent_id_validator.rs ## safe_join symlink escape (MEDIUM) - Base must canonicalize (nonexistent → Canonicalize error) - Joined must start_with base_canon OR joined.parent() must start_with base_canon - Blocks symlink-to-outside-base with non-existent tail file - walk.rs refactored into 5 ≤17-LOC helpers - 7 new tests in safe_join_hardening.rs ## entity-store 4 MEDIUM fixes - ddl.rs: panic on unsupported FieldKind → typed DdlError::UnsupportedExtraColumn propagated through Store::open as VerbError::InvalidInput (exit 2). Extracted ddl_edge.rs + ddl_error.rs modules. Backward-compat shim preserved. - search.rs: FTS5 empty-tokenization → typed InvalidInput on queries with no alphanumeric tokens (was opaque rusqlite error). Unicode-aware via char::is_alphanumeric. - engine.rs: WAL pragma failure now logged to stderr with path + rusqlite source; fallback to rollback journal preserved (exit-code contract intact). - bug_fixes_smoke: added fts5_phrase_quoting_preserves_legitimate_queries — catches over-broad sanitizer that passes injection test alone. ## Verified - cargo check --workspace clean (both with and without http-driver feature) - cargo test --workspace: 668 tests green (up from 620) - substrate_integration.sh ✓, hook_wiring_integration.sh ✓ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.3 KiB
Rust
91 lines
3.3 KiB
Rust
//! DDL-string generators split out of `engine.rs` to keep that file
|
|
//! under the Constructor-Pattern 200-LOC cap. One function per emitted
|
|
//! `CREATE` statement; the engine's `run_migrations` orchestrates the
|
|
//! calls and stamps `user_version`.
|
|
//!
|
|
//! Edge-table DDL lives in `ddl_edge.rs` and is re-exported below;
|
|
//! `DdlError` lives in `ddl_error.rs`. Split preserves the 200-LOC cap
|
|
//! per Constructor Pattern.
|
|
|
|
pub use crate::ddl_edge::{edge_table_for, try_edge_table_for};
|
|
pub use crate::ddl_error::DdlError;
|
|
use crate::schema::{EntitySchema, FieldDef, FieldKind};
|
|
|
|
pub fn primary_table(schema: &EntitySchema) -> String {
|
|
let cols: Vec<String> = schema.fields.iter().map(column).collect();
|
|
format!(
|
|
"CREATE TABLE IF NOT EXISTS {} (\n {}\n);",
|
|
schema.table,
|
|
cols.join(",\n ")
|
|
)
|
|
}
|
|
|
|
fn column(f: &FieldDef) -> String {
|
|
match f.kind {
|
|
FieldKind::IntegerPk => format!("{} INTEGER PRIMARY KEY", f.name),
|
|
FieldKind::TextPk => format!("{} TEXT PRIMARY KEY", f.name),
|
|
FieldKind::IntegerNotNull => format!("{} INTEGER NOT NULL", f.name),
|
|
FieldKind::Integer => format!("{} INTEGER DEFAULT 0", f.name),
|
|
FieldKind::TextNotNull => format!("{} TEXT NOT NULL", f.name),
|
|
FieldKind::Text => format!("{} TEXT DEFAULT ''", f.name),
|
|
FieldKind::TextDefault => text_default_column(f),
|
|
FieldKind::TextArchiveEnum => archive_enum_column(f),
|
|
FieldKind::Real => format!("{} REAL NOT NULL DEFAULT 0.0", f.name),
|
|
FieldKind::RealDefault => real_default_column(f),
|
|
FieldKind::TimestampCreated => format!("{} INTEGER NOT NULL", f.name),
|
|
FieldKind::TimestampUpdated => format!("{} INTEGER NOT NULL", f.name),
|
|
}
|
|
}
|
|
|
|
fn text_default_column(f: &FieldDef) -> String {
|
|
let d = f.default.unwrap_or("");
|
|
// SQL-escape embedded single quotes (per SQL standard: `'` → `''`)
|
|
// so `text_default("status", "don't know")` does not inject.
|
|
let escaped = d.replace('\'', "''");
|
|
format!("{} TEXT NOT NULL DEFAULT '{}'", f.name, escaped)
|
|
}
|
|
|
|
fn archive_enum_column(f: &FieldDef) -> String {
|
|
let (active, _archived) = f.archive_enum.unwrap_or(("active", "archived"));
|
|
let escaped = active.replace('\'', "''");
|
|
format!("{} TEXT NOT NULL DEFAULT '{}'", f.name, escaped)
|
|
}
|
|
|
|
fn real_default_column(f: &FieldDef) -> String {
|
|
let d = f.real_default.unwrap_or(0.0);
|
|
format!("{} REAL NOT NULL DEFAULT {}", f.name, format_real(d))
|
|
}
|
|
|
|
/// Deterministic SQL literal for an f64 — always has a decimal point,
|
|
/// no exponent for finite values. Non-finite values fall back to 0.0.
|
|
fn format_real(v: f64) -> String {
|
|
if !v.is_finite() {
|
|
return "0.0".to_string();
|
|
}
|
|
if v.fract() == 0.0 {
|
|
format!("{:.1}", v)
|
|
} else {
|
|
format!("{}", v)
|
|
}
|
|
}
|
|
|
|
pub fn indexes(schema: &EntitySchema) -> String {
|
|
let mut out = String::new();
|
|
for f in schema.fields.iter().filter(|f| f.indexed) {
|
|
out.push_str(&format!(
|
|
"CREATE INDEX IF NOT EXISTS idx_{t}_{c} ON {t}({c});\n",
|
|
t = schema.table,
|
|
c = f.name
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn fts_table(table: &str, cols: &[&str]) -> String {
|
|
let col_list = cols.join(", ");
|
|
format!(
|
|
"CREATE VIRTUAL TABLE IF NOT EXISTS fts_{table} \
|
|
USING fts5({table}_id UNINDEXED, {col_list}, tokenize='porter unicode61');"
|
|
)
|
|
}
|
|
|