KeiSeiKit-1.0/_primitives/_rust/kei-entity-store/src/ddl.rs
Parfii-bot c1556f505a fix: Wave 13 cleanup — HttpDriver + agent_id validator + safe_join + 4 MEDIUM
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>
2026-04-23 16:16:24 +08:00

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');"
)
}