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>
132 lines
5 KiB
Rust
132 lines
5 KiB
Rust
//! Edge-table DDL generators. Split out of `ddl.rs` to keep each file
|
|
//! inside the Constructor Pattern 200-LOC cap. `ddl.rs` retains the
|
|
//! entity-table, index, and FTS DDL; this module owns edge-table DDL
|
|
//! in all three variants (`IntegerPair`, `TextPair`,
|
|
//! `TextPairWithMetadata`).
|
|
|
|
use crate::ddl_error::DdlError;
|
|
use crate::schema::{EdgeKeyKind, FieldKind};
|
|
|
|
/// Dispatcher — picks edge-table DDL for a given `EdgeKeyKind`. Added
|
|
/// for kei-sage migration; `IntegerPair` branch preserves legacy body.
|
|
///
|
|
/// Backward-compat shim — prefer `try_edge_table_for` from new code.
|
|
/// This variant panics on unsupported `extra_columns` FieldKinds; the
|
|
/// engine's migration path uses the fallible variant to surface typed
|
|
/// errors without panicking.
|
|
pub fn edge_table_for(edge: &str, kind: EdgeKeyKind) -> String {
|
|
try_edge_table_for(edge, kind).expect("edge_table_for: unsupported extra_column FieldKind")
|
|
}
|
|
|
|
/// Fallible dispatcher — same as `edge_table_for` but returns
|
|
/// `DdlError::UnsupportedExtraColumn` instead of panicking when an
|
|
/// `extra_columns` entry carries a FieldKind outside the supported
|
|
/// subset. This is the path `Store::open` takes.
|
|
pub fn try_edge_table_for(edge: &str, kind: EdgeKeyKind) -> Result<String, DdlError> {
|
|
match kind {
|
|
EdgeKeyKind::IntegerPair => Ok(edge_integer(edge)),
|
|
EdgeKeyKind::TextPair => Ok(edge_text(edge)),
|
|
EdgeKeyKind::TextPairWithMetadata {
|
|
from_col,
|
|
to_col,
|
|
has_id,
|
|
has_weight,
|
|
has_created_at,
|
|
extra_columns,
|
|
} => edge_text_meta(
|
|
edge,
|
|
from_col,
|
|
to_col,
|
|
has_id,
|
|
has_weight,
|
|
has_created_at,
|
|
extra_columns,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn edge_integer(edge: &str) -> String {
|
|
format!(
|
|
"CREATE TABLE IF NOT EXISTS {edge} (\n \
|
|
from_id INTEGER NOT NULL,\n \
|
|
to_id INTEGER NOT NULL,\n \
|
|
edge_type TEXT NOT NULL DEFAULT 'links',\n \
|
|
PRIMARY KEY(from_id, to_id, edge_type)\n\
|
|
);\n\
|
|
CREATE INDEX IF NOT EXISTS idx_{edge}_to ON {edge}(to_id);"
|
|
)
|
|
}
|
|
|
|
/// Text-keyed edge DDL: `(src_path TEXT, dst_path TEXT, edge_type TEXT)`.
|
|
fn edge_text(edge: &str) -> String {
|
|
format!(
|
|
"CREATE TABLE IF NOT EXISTS {edge} (\n \
|
|
src_path TEXT NOT NULL,\n \
|
|
dst_path TEXT NOT NULL,\n \
|
|
edge_type TEXT NOT NULL DEFAULT 'links',\n \
|
|
PRIMARY KEY(src_path, dst_path, edge_type)\n\
|
|
);\n\
|
|
CREATE INDEX IF NOT EXISTS idx_{edge}_dst ON {edge}(dst_path);"
|
|
)
|
|
}
|
|
|
|
/// Text-keyed edge DDL with optional metadata columns + caller-chosen
|
|
/// key column names + arbitrary extra columns. Fallible — returns
|
|
/// `DdlError::UnsupportedExtraColumn` if any `extras` entry uses a
|
|
/// disallowed `FieldKind`.
|
|
fn edge_text_meta(
|
|
edge: &str,
|
|
from_col: &str,
|
|
to_col: &str,
|
|
has_id: bool,
|
|
has_weight: bool,
|
|
has_created_at: bool,
|
|
extras: &[(&str, FieldKind)],
|
|
) -> Result<String, DdlError> {
|
|
let mut cols: Vec<String> = Vec::new();
|
|
if has_id {
|
|
cols.push("edge_id INTEGER PRIMARY KEY AUTOINCREMENT".to_string());
|
|
}
|
|
cols.push(format!("{from_col} TEXT NOT NULL"));
|
|
cols.push(format!("{to_col} TEXT NOT NULL"));
|
|
cols.push("edge_type TEXT NOT NULL DEFAULT 'links'".to_string());
|
|
if has_weight {
|
|
cols.push("weight REAL NOT NULL DEFAULT 1.0".to_string());
|
|
}
|
|
for (name, kind) in extras {
|
|
cols.push(try_extra_column(name, *kind)?);
|
|
}
|
|
if has_created_at {
|
|
cols.push("created_at INTEGER NOT NULL".to_string());
|
|
}
|
|
// Without an autoincrement PK we still want `INSERT OR IGNORE`
|
|
// idempotent over the triple; with one we emit a UNIQUE instead.
|
|
if has_id {
|
|
cols.push(format!("UNIQUE({from_col}, {to_col}, edge_type)"));
|
|
} else {
|
|
cols.push(format!("PRIMARY KEY({from_col}, {to_col}, edge_type)"));
|
|
}
|
|
let body = cols.join(",\n ");
|
|
Ok(format!(
|
|
"CREATE TABLE IF NOT EXISTS {edge} (\n {body}\n);\n\
|
|
CREATE INDEX IF NOT EXISTS idx_{edge}_dst ON {edge}({to_col});"
|
|
))
|
|
}
|
|
|
|
/// DDL for one extra edge column. Limited subset of `FieldKind` — edge
|
|
/// extras can't be PKs, archive enums, or auto-stamped timestamps.
|
|
/// Fallible — returns `DdlError::UnsupportedExtraColumn` outside the
|
|
/// supported set instead of panicking.
|
|
fn try_extra_column(name: &str, kind: FieldKind) -> Result<String, DdlError> {
|
|
match kind {
|
|
FieldKind::Text => Ok(format!("{name} TEXT DEFAULT ''")),
|
|
FieldKind::TextNotNull => Ok(format!("{name} TEXT NOT NULL")),
|
|
FieldKind::Integer => Ok(format!("{name} INTEGER DEFAULT 0")),
|
|
FieldKind::IntegerNotNull => Ok(format!("{name} INTEGER NOT NULL")),
|
|
FieldKind::Real => Ok(format!("{name} REAL NOT NULL DEFAULT 0.0")),
|
|
other => Err(DdlError::UnsupportedExtraColumn {
|
|
kind_debug: format!("{other:?}"),
|
|
column_name: name.to_string(),
|
|
}),
|
|
}
|
|
}
|