on master help sign in
Validate compiled snippets' relations against the host, before running

A snippet's declared relations were checked only by NAME against the host's registered set, and only AFTER the snippet ran. Two gaps resulted: 1. Shape drift was silent: a snippet declaring `*accounts` as a different record type than the host's compiled fine and decoded the host's rows into the wrong shape at read time. 2. A foreign (host-absent) relation PANICKED: the snippet's `main` executes effects, so `base.count *widgets` hit `SELECT COUNT(*) FROM _knot_widgets` on a nonexistent table and aborted — before the post-hoc name check could reject it. Validate every declared relation BEFORE running, in the JIT: the snippet's declared (name, descriptor) pairs are checked against the host via the new `knot_compile_host_rel_matches` runtime export, which parses the descriptor and compares RecordSchemas structurally. A foreign relation or a shape mismatch is now a clean compile Err; a matching shape compiles and reads the host's rows. The relations wire field now carries `name\tdescriptor` pairs (was bare names); compile_run keeps a name-membership guard as defense-in-depth. Tests (persistence e2e): foreign relation rejected, shape-drifted relation rejected, matching shape accepted.

commitb81982b6d169200aff43935852ad3c2036e0c336 authoriko <ilyakooo0@gmail.com> dateFri 4 Sep 2026, 11:25 (1 month ago) parent553ca19
3 files changed+150−11
crates/knot-jit/src/lib.rs+47−7 crates/knot-jit/tests/persistence.rs+70 crates/knot-runtime/src/lib.rs+33−4
crates/knot-jit/src/lib.rs+47−7
@@ -67,11 +67,12 @@ pub struct CompiledValue {
6767 /// The inferred type of the compiled program's body (for the caller's
6868 /// `Maybe a` unification check). `None` if it couldn't be determined.
6969 pub ty: Option<String>,
70− /// Relations the snippet declares (`*name : T` in a `with` block), for the
71− /// caller's host-relation check: a snippet may only use relations the host
72− /// program defined. The JIT never runs the generated `main`'s source-init
73− /// (its entry is `knot_user_main`), so these are surfaced statically from
74− /// the snippet's type env rather than observed at runtime.
70+ /// Relations the snippet declares (`*name : T` in a `with` block), as
71+ /// `name\tdescriptor` pairs, for the caller's host-relation check: a
72+ /// snippet may only use relations the host program defined, with a matching
73+ /// shape. The JIT never runs the generated `main`'s source-init (its entry
74+ /// is `knot_user_main`), so these are surfaced statically from the snippet's
75+ /// type env rather than observed at runtime.
7576 pub relations: Vec<String>,
7677}
7778
@@ -196,8 +197,47 @@ pub fn compile_and_run(
196197 // subsumption already ran on real `Ty`s inside `check`.
197198 let body_ty = file_body_type;
198199
199− // Relations the snippet declares (for the host-relation check).
200− let relations: Vec<String> = type_env.source_schemas.keys().cloned().collect();
200+ // Relations the snippet declares, as `name\tdescriptor` pairs (for the
201+ // host's relation check). The descriptor is the canonical structural schema
202+ // string, so the runtime can compare the snippet's declared shape against
203+ // the host's registered schema — not just the name.
204+ let relations: Vec<String> = type_env
205+ .source_schemas
206+ .iter()
207+ .map(|(name, desc)| format!("{name}\t{desc}"))
208+ .collect();
209+
210+ // Validate every declared relation against the host BEFORE running the
211+ // snippet: the snippet's `main` executes effects, so a foreign or
212+ // shape-drifted relation would otherwise panic at the SQL layer mid-run.
213+ // The callback resolves the runtime's registered schema for the name.
214+ unsafe extern "C" {
215+ fn knot_compile_host_rel_matches(
216+ name_ptr: *const u8,
217+ name_len: usize,
218+ desc_ptr: *const u8,
219+ desc_len: usize,
220+ ) -> i32;
221+ }
222+ for (name, desc) in &type_env.source_schemas {
223+ let verdict = unsafe {
224+ knot_compile_host_rel_matches(name.as_ptr(), name.len(), desc.as_ptr(), desc.len())
225+ };
226+ match verdict {
227+ 0 => {}
228+ 1 => {
229+ return Err(CompileError(format!(
230+ "snippet's schema for relation `{name}` doesn't match the host's \
231+ (expected a different shape than the host's `{name}`)"
232+ )))
233+ }
234+ _ => {
235+ return Err(CompileError(format!(
236+ "snippet declares a relation `{name}` the host program doesn't have"
237+ )))
238+ }
239+ }
240+ }
201241
202242 // ── Codegen into a JITModule, on a grown stack ──
203243 // JITModule is not Send, so it can't cross stack::grow's thread boundary.
crates/knot-jit/tests/persistence.rs+70
@@ -834,3 +834,73 @@ Rel Flag *flag
834834 "\"compiled reader: set\"\n{}",
835835 );
836836}
837+
838+// ── base.compile validates the snippet's declared relations against the host ─
839+//
840+// A snippet may only use relations the host program has, with a matching
841+// shape. The JIT checks each declared relation (name AND structural schema)
842+// against the host's registered schemas via `knot_compile_host_rel_matches`
843+// BEFORE running the snippet — so a foreign or shape-drifted declaration is a
844+// clean compile error, not a SQL panic when the snippet touches the table.
845+
846+#[test]
847+fn compile_rejects_foreign_relation() {
848+ // The host has `*accounts`; the snippet declares `*widgets` (absent in the
849+ // host). Compile must fail cleanly — previously the snippet ran and panicked
850+ // at `SELECT COUNT(*) FROM _knot_widgets` (no such table).
851+ assert_stdout(
852+ "compile_foreign_rel",
853+ r#"with {
854+Account {name Text balance (Int 1)}
855+Rel Account *accounts
856+}
857+(|
858+ *accounts = [{name "a" balance 1}]
859+ (? (base.compile "with {\nWidget {size (Int 1)}\nRel Widget *widgets\n}\nbase.count *widgets")
860+ Result.Ok {value n} base.println ("ok: " ++ (base.show n))
861+ Result.Err {error e} base.println "err")
862+ yield {})"#,
863+ "\"err\"\n{}",
864+ );
865+}
866+
867+#[test]
868+fn compile_rejects_shape_drifted_relation() {
869+ // The host's `Account` is `{name Text balance Int}`; the snippet declares
870+ // `*accounts` as `{balance Int}` (no `name`). Same relation name, different
871+ // shape — must be rejected rather than silently decoding host rows into the
872+ // wrong record type.
873+ assert_stdout(
874+ "compile_shape_drift",
875+ r#"with {
876+Account {name Text balance (Int 1)}
877+Rel Account *accounts
878+}
879+(|
880+ *accounts = [{name "a" balance 1}]
881+ (? (base.compile "with {\nAccount {balance (Int 1)}\nRel Account *accounts\n}\nbase.count *accounts")
882+ Result.Ok {value n} base.println ("ok: " ++ (base.show n))
883+ Result.Err {error e} base.println "err")
884+ yield {})"#,
885+ "\"err\"\n{}",
886+ );
887+}
888+
889+#[test]
890+fn compile_accepts_matching_relation_shape() {
891+ // Same name AND same shape → compiles and reads the host's rows.
892+ assert_stdout(
893+ "compile_matching_rel",
894+ r#"with {
895+Account {name Text balance (Int 1)}
896+Rel Account *accounts
897+}
898+(|
899+ *accounts = [{name "a" balance 1} {name "b" balance 2}]
900+ (? (base.compile "with {\nAccount {name Text balance (Int 1)}\nRel Account *accounts\n}\nbase.count *accounts")
901+ Result.Ok {value n} base.println ("ok: " ++ (base.show n))
902+ Result.Err {error e} base.println ("err: " ++ e))
903+ yield {})"#,
904+ "\"ok: [2]\"\n{}",
905+ );
906+}
crates/knot-runtime/src/lib.rs+33−4
@@ -2147,6 +2147,32 @@ fn get_source_schema(name: &str) -> Option<Arc<RecordSchema>> {
21472147 .cloned()
21482148}
21492149
2150+/// `base.compile` host-relation check: does the host have relation `name`, and
2151+/// does its registered schema match the snippet's declared `descriptor`?
2152+/// Returns 0 = match, 1 = shape mismatch, 2 = host has no such relation.
2153+/// Called by the JIT *before* it runs the snippet, so a foreign or
2154+/// shape-drifted relation is rejected as a clean compile error rather than a
2155+/// SQL panic when the snippet's `main` touches the (missing/wrong) table.
2156+#[unsafe(no_mangle)]
2157+pub extern "C-unwind" fn knot_compile_host_rel_matches(
2158+ name_ptr: *const u8,
2159+ name_len: usize,
2160+ desc_ptr: *const u8,
2161+ desc_len: usize,
2162+) -> i32 {
2163+ let name = unsafe { str_from_raw(name_ptr, name_len) };
2164+ let desc = unsafe { str_from_raw(desc_ptr, desc_len) };
2165+ let host_rels = SOURCE_SCHEMAS.read().unwrap_or_else(|e| e.into_inner());
2166+ let Some(host_schema) = host_rels.get(name) else {
2167+ return 2;
2168+ };
2169+ if parse_record_schema(desc) == **host_schema {
2170+ 0
2171+ } else {
2172+ 1
2173+ }
2174+}
2175+
21502176/// Registry of columns stored as genuine TEXT (BINARY collation in SQLite) —
21512177/// `ColType::Text`, `Tag`, or `Json` — keyed by `(source, column)`. Int columns
21522178/// are native INTEGERs, so they never appear here.
@@ -11946,12 +11972,15 @@ fn compile_run(
1194611972 free_ty(ty_ptr);
1194711973 return (std::ptr::null_mut(), None, err);
1194811974 }
11949− // Foreign-`*rel` guard.
11975+ // Foreign-`*rel` guard (defense-in-depth — the JIT already validated each
11976+ // relation's name AND shape against the host before running the snippet;
11977+ // see knot_compile_host_rel_matches). Each entry is `name\tdescriptor`.
1195011978 if !rels_csv.is_empty() {
1195111979 let host_rels = SOURCE_SCHEMAS.read().unwrap_or_else(|e| e.into_inner());
11952− let foreign = rels_csv
11953− .split(',')
11954− .any(|r| !r.is_empty() && !host_rels.contains_key(r));
11980+ let foreign = rels_csv.split(',').filter(|e| !e.is_empty()).any(|entry| {
11981+ let name = entry.split_once('\t').map(|(n, _)| n).unwrap_or(entry);
11982+ !host_rels.contains_key(name)
11983+ });
1195511984 drop(host_rels);
1195611985 if foreign {
1195711986 free_ty(ty_ptr);