diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c79cbc8..2acd4a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,10 @@ jobs: RUSTFLAGS='--cfg fuzzing' cargo +nightly fuzz run --sanitizer none json_line_worker -- -runs=128 -max_len=1048578 working-directory: fuzz + - run: > + RUSTFLAGS='--cfg fuzzing' cargo +nightly fuzz run --sanitizer none catalog_register -- + -runs=128 -max_len=1024 + working-directory: fuzz fuzz-memory-safety: runs-on: ubuntu-latest @@ -191,6 +195,11 @@ jobs: RUSTFLAGS='--cfg fuzzing' cargo +nightly fuzz run capability_lease -- -runs=128 -max_len=8192 working-directory: fuzz + - name: AddressSanitizer catalog register fuzzing + run: > + RUSTFLAGS='--cfg fuzzing' cargo +nightly fuzz run catalog_register -- + -runs=128 -max_len=1024 + working-directory: fuzz miri-vm-regression: runs-on: ubuntu-latest @@ -204,3 +213,8 @@ jobs: run: > cargo +nightly miri test -p makepad-script reentrant_reload_of_the_active_body_does_not_keep_an_opcode_pointer + # catalog_register — layered catalog register under Miri (profile §4.2) + - name: Check layered catalog register under Miri + run: > + cargo +nightly miri test -p octoscript-ui-l0 + register_and_concurrent_lookup_no_ub_under_miri diff --git a/.github/workflows/sustained-fuzz.yml b/.github/workflows/sustained-fuzz.yml index 022d9be..594ae0f 100644 --- a/.github/workflows/sustained-fuzz.yml +++ b/.github/workflows/sustained-fuzz.yml @@ -142,6 +142,11 @@ jobs: RUSTFLAGS='--cfg fuzzing' cargo +nightly fuzz run --sanitizer none json_line_worker -- -max_total_time=180 -timeout=5 -rss_limit_mb=1024 -max_len=1048578 working-directory: fuzz + - name: Catalog register fuzzing + run: > + RUSTFLAGS='--cfg fuzzing' cargo +nightly fuzz run --sanitizer none catalog_register -- + -max_total_time=120 -timeout=5 -rss_limit_mb=1024 -max_len=1024 + working-directory: fuzz - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index f9ccc17..b0cbd64 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,9 @@ /fuzz/target/ *.swp .DS_Store + +# Local coordination docs (not for upstream) +/.todo-*.md +/.todo-*/ +**/.todo-*.md +**/.todo-*/ diff --git a/crates/octoscript-ui-l0/examples/register_layered.rs b/crates/octoscript-ui-l0/examples/register_layered.rs new file mode 100644 index 0000000..848d80a --- /dev/null +++ b/crates/octoscript-ui-l0/examples/register_layered.rs @@ -0,0 +1,132 @@ +//! Register 17 finance-brief-style `sys.*` helpers and check a card that uses them. +//! +//! Usage: cargo run -p octoscript-ui-l0 --example register_layered -- + +use octoscript_ui_l0::{catalog, catalog::SysContract, check_ui_l0_named}; + +fn finance_brief_capabilities() -> Vec { + vec![ + SysContract { + name: "sys.news_sina".into(), + fields: &["id", "title", "source", "ts", "summary"], + schema: None, + }, + SysContract { + name: "sys.quote_tencent".into(), + fields: &["ticker", "name", "last", "change", "pct", "open"], + schema: None, + }, + SysContract { + name: "sys.quote_stooq".into(), + fields: &["ticker", "last", "open", "high", "low", "volume"], + schema: None, + }, + SysContract { + name: "sys.quote_hyperliquid".into(), + fields: &["ticker", "last", "change", "pct", "volume"], + schema: None, + }, + SysContract { + name: "sys.quote_frankfurter".into(), + fields: &["ticker", "last"], + schema: None, + }, + SysContract { + name: "sys.synth_candles".into(), + fields: &["ts", "open", "high", "low", "close", "volume"], + schema: None, + }, + SysContract { + name: "sys.research_list".into(), + fields: &["title", "summary", "coverage", "as_of"], + schema: None, + }, + SysContract { + name: "sys.research_detail".into(), + fields: &["title", "summary", "evidence_title", "evidence_body"], + schema: None, + }, + SysContract { + name: "sys.fav_list".into(), + fields: &["id", "kind", "ref", "ts"], + schema: None, + }, + SysContract { + name: "sys.fav_toggle".into(), + fields: &["id", "kind", "ref"], + schema: None, + }, + SysContract { + name: "sys.settings_load".into(), + fields: &["freq", "lang", "theme"], + schema: None, + }, + SysContract { + name: "sys.settings_save".into(), + fields: &["freq", "lang", "theme"], + schema: None, + }, + SysContract { + name: "sys.stream_subscribe".into(), + fields: &["topic", "interval_ms"], + schema: None, + }, + SysContract { + name: "sys.stream_unsubscribe".into(), + fields: &["topic"], + schema: None, + }, + SysContract { + name: "sys.stream_freq".into(), + fields: &["topic", "interval_ms"], + schema: None, + }, + SysContract { + name: "sys.stream_tick".into(), + fields: &["topic", "ts", "data"], + schema: None, + }, + SysContract { + name: "sys.datasource_status".into(), + fields: &[ + "name", + "kind", + "status", + "last_ok", + "latency_ms", + "last_err", + ], + schema: None, + }, + ] +} + +fn main() { + let mut ok_count = 0; + let mut err_count = 0; + for cap in finance_brief_capabilities() { + match catalog::register(cap) { + Ok(_) => ok_count += 1, + Err(e) => { + eprintln!("register failed: {e}"); + err_count += 1; + } + } + } + eprintln!( + "registered: {ok_count} ok, {err_count} err; names = {:?}", + catalog::registered_names() + ); + let path = std::env::args() + .nth(1) + .expect("usage: register_layered "); + let card = std::fs::read_to_string(&path).expect("read"); + let r = check_ui_l0_named("register_layered_example", &card); + println!(" valid = {} level = {:?}", r.valid, r.level); + for d in &r.diagnostics { + println!(" {}:{} {}", d.line, d.column, d.message); + } + if err_count > 0 { + std::process::exit(1); + } +} diff --git a/crates/octoscript-ui-l0/src/lib.rs b/crates/octoscript-ui-l0/src/lib.rs index 3af812f..9b78fa7 100644 --- a/crates/octoscript-ui-l0/src/lib.rs +++ b/crates/octoscript-ui-l0/src/lib.rs @@ -3052,6 +3052,8 @@ fn collect_constructors(e: &Element, out: &mut Vec) { /// The argument contract. Mirrors `docs/ui-l0-constructors.toml`, which is the /// human-readable spec; a test asserts the two agree. pub mod catalog { + use std::sync::{OnceLock, RwLock}; + /// The themes a card may declare, as MOODS rather than looks. /// /// Closed, and closed for the same reason the role list is: a theme name is @@ -3785,7 +3787,21 @@ pub mod catalog { ]; pub fn source(name: &str) -> Option<&'static [&'static str]> { - SOURCES.iter().find(|(n, _)| *n == name).map(|(_, a)| *a) + if let Some((_, fields)) = SOURCES.iter().find(|(n, _)| *n == name) { + return Some(*fields); + } + if let Some(table) = REGISTERED.get() { + if let Ok(guard) = table.read() { + if guard.iter().any(|c| c.name == name) { + // Host-registered helpers always accept the `fields:` + // argument; the per-helper field vocabulary lives in + // [`answers`]. Other arguments would need explicit + // registration support. + return Some(&["fields"]); + } + } + } + None } /// What each capability can ANSWER: the field names a card may request from @@ -4091,7 +4107,17 @@ pub mod catalog { } pub fn answers(name: &str) -> Option<&'static [&'static str]> { - ANSWERS.iter().find(|(n, _)| *n == name).map(|(_, a)| *a) + if let Some((_, fields)) = ANSWERS.iter().find(|(n, _)| *n == name) { + return Some(*fields); + } + if let Some(table) = REGISTERED.get() { + if let Ok(guard) = table.read() { + if let Some(c) = guard.iter().find(|c| c.name == name) { + return Some(c.fields); + } + } + } + None } pub fn aggregates(name: &str) -> &'static [&'static str] { @@ -4100,6 +4126,87 @@ pub mod catalog { .find(|(n, _)| *n == name) .map_or(&[], |(_, a)| *a) } + + /// Runtime catalog entry for a host-registered `sys.` helper. + /// + /// Mirrors the (name, fields) shape of the build-time [`ANSWERS`] table, + /// but is supplied at runtime by a host that owns the matching adapter. + /// `unsafe_code = "forbid"` means the `Box::leak` below is the safe API, + /// not a raw pointer. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct SysContract { + pub name: String, + pub fields: &'static [&'static str], + pub schema: Option, + } + + static REGISTERED: OnceLock>> = OnceLock::new(); + + fn registered_table() -> &'static RwLock> { + REGISTERED.get_or_init(|| RwLock::new(Vec::new())) + } + + /// Register a host-side `sys.*` helper at runtime. + /// + /// Returns `Err` if `name` does not start with `sys.`, collides with any + /// build-time catalog entry, or has already been registered. Boot-time + /// callers should `?` on the result. + pub fn register(contract: SysContract) -> Result<&'static SysContract, String> { + if !contract.name.starts_with("sys.") { + return Err(format!("name {:?} must start with \"sys.\"", contract.name)); + } + if AGGREGATES.iter().any(|(n, _)| *n == contract.name) { + return Err(format!( + "name {:?} collides with build-time AGGREGATES", + contract.name + )); + } + if MUTABLE.iter().any(|(n, _)| *n == contract.name) { + return Err(format!( + "name {:?} collides with build-time MUTABLE", + contract.name + )); + } + if ANSWERS.iter().any(|(n, _)| *n == contract.name) { + return Err(format!( + "name {:?} collides with build-time ANSWERS", + contract.name + )); + } + let table = registered_table(); + { + let guard = table + .read() + .map_err(|e| format!("catalog lock poisoned: {e}"))?; + if guard.iter().any(|c| c.name == contract.name) { + return Err(format!("name {:?} is already registered", contract.name)); + } + } + let leaked: &'static SysContract = Box::leak(Box::new(contract)); + let mut guard = table + .write() + .map_err(|e| format!("catalog lock poisoned: {e}"))?; + guard.push(leaked); + Ok(leaked) + } + + /// All runtime-registered helper names. The build-time catalog is not listed. + pub fn registered_names() -> Vec { + registered_table() + .read() + .map(|g| g.iter().map(|c| c.name.clone()).collect()) + .unwrap_or_default() + } + + /// Drop every runtime-registered helper. Test-only; hosts should register + /// once at boot and never touch this. + pub fn clear_registered_for_tests() { + if let Some(table) = REGISTERED.get() { + if let Ok(mut g) = table.write() { + g.clear(); + } + } + } } // ──────────────────────────────────────────────────────────────────── validation ── @@ -4788,13 +4895,15 @@ fn validate_sources(card: &Card, sink: &mut Diagnostics) { for source in &card.sources { let Some(accepted) = catalog::source(&source.helper) else { let mut known: Vec<&str> = catalog::SOURCES.iter().map(|(n, _)| *n).collect(); + let registered = catalog::registered_names(); + known.extend(registered.iter().map(String::as_str)); known.sort(); sink.push( source.line, 1, format!( "{:?} is not a source capability L0 admits; a card may only name a \ - catalogued helper (profile §4). Known: {}", + catalogued helper (profile §4 §4.2). Known: {}", source.helper, known.join(", ") ), diff --git a/crates/octoscript-ui-l0/tests/catalog_register.rs b/crates/octoscript-ui-l0/tests/catalog_register.rs new file mode 100644 index 0000000..02e7be4 --- /dev/null +++ b/crates/octoscript-ui-l0/tests/catalog_register.rs @@ -0,0 +1,143 @@ +//! Tests for the runtime catalog registration API added to `catalog`. +//! +//! Every test uses a name that is unique to itself, so parallel test +//! execution cannot cause one test's `register` to collide with another's. + +use octoscript_ui_l0::{catalog::*, check_ui_l0}; + +#[test] +fn register_then_lookup_returns_fields() { + let contract = SysContract { + name: "sys.fake_a".into(), + fields: &["a", "b"], + schema: None, + }; + register(contract.clone()).expect("register ok"); + assert_eq!(answers("sys.fake_a"), Some(&["a", "b"][..])); +} + +#[test] +fn register_rejects_collision_with_answers() { + let contract = SysContract { + name: "sys.geocode".into(), + fields: &["x"], + schema: None, + }; + let err = register(contract).unwrap_err(); + assert!(err.contains("ANSWERS"), "got: {err}"); +} + +#[test] +fn register_rejects_collision_with_aggregates() { + let contract = SysContract { + name: "sys.series".into(), + fields: &["x"], + schema: None, + }; + let err = register(contract).unwrap_err(); + assert!(err.contains("AGGREGATES"), "got: {err}"); +} + +#[test] +fn register_rejects_collision_with_mutable() { + let contract = SysContract { + name: "sys.cities".into(), + fields: &["x"], + schema: None, + }; + let err = register(contract).unwrap_err(); + assert!(err.contains("MUTABLE"), "got: {err}"); +} + +#[test] +fn register_rejects_duplicate_name() { + let contract = SysContract { + name: "sys.fake_b".into(), + fields: &["a"], + schema: None, + }; + // The first register may already have been done by an earlier parallel + // run of this same test (e.g. `cargo test` re-runs after a fix). Either + // outcome is fine: what we are asserting is that the SECOND register + // returns the duplicate-name error. + let _ = register(contract.clone()); + let err = register(contract).unwrap_err(); + assert!(err.contains("already"), "got: {err}"); +} + +#[test] +fn register_rejects_non_sys_prefix() { + let contract = SysContract { + name: "foo.bar".into(), + fields: &["a"], + schema: None, + }; + let err = register(contract).unwrap_err(); + assert!(err.contains("sys."), "got: {err}"); +} + +#[test] +fn register_accepts_multiple_distinct_names() { + for name in ["sys.fake_c1", "sys.fake_c2", "sys.fake_c3"] { + let _ = register(SysContract { + name: name.into(), + fields: &["a"], + schema: None, + }); + } + let listed = registered_names(); + assert!(listed.iter().any(|n| n == "sys.fake_c1")); + assert!(listed.iter().any(|n| n == "sys.fake_c2")); + assert!(listed.iter().any(|n| n == "sys.fake_c3")); +} + +#[test] +fn validate_sources_accepts_registered_helper() { + let _ = register(SysContract { + name: "sys.fake_c_accept".into(), + fields: &["x", "y"], + schema: None, + }); + let card = "source n sys.fake_c_accept(fields: [x])\nview root Surface { for v in n key v.x { TextRow(text: v.x) } }"; + let r = check_ui_l0(card); + assert!(r.valid, "diagnostics: {:?}", r.diagnostics); +} + +#[test] +fn validate_sources_rejects_unknown_field_for_registered() { + let _ = register(SysContract { + name: "sys.fake_c_reject".into(), + fields: &["x", "y"], + schema: None, + }); + let card = "source n sys.fake_c_reject(fields: [bogus])\nview root Surface { for v in n key v.bogus { TextRow(text: v.bogus) } }"; + let r = check_ui_l0(card); + assert!( + !r.valid, + "expected invalid, diagnostics: {:?}", + r.diagnostics + ); +} + +#[test] +fn register_and_concurrent_lookup_no_ub_under_miri() { + use std::thread; + let _ = register(SysContract { + name: "sys.miri_test".into(), + fields: &["a", "b", "c"], + schema: None, + }); + let handles: Vec<_> = (0..4) + .map(|_| { + thread::spawn(|| { + for _ in 0..1000 { + let _ = answers("sys.miri_test"); + let _ = registered_names(); + } + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } +} diff --git a/docs/ui-profile-l0.md b/docs/ui-profile-l0.md index 6410439..fbe3e9b 100644 --- a/docs/ui-profile-l0.md +++ b/docs/ui-profile-l0.md @@ -667,6 +667,40 @@ by the typed text encoded at runtime as one JSON string (`sys.json_string(t)`), a live tap payload already used. `kit::lower` still passes the `$$` form to `l0_field`, whose consumers rebuild the target from its routing keys the same way and never splice. +### 4.3 Layered catalog — host-registered `sys.*` helpers + +The build-time [`catalog::ANSWERS`] table is closed (32 entries). Apps that own their own data plane (HTTP fetches, schemas, capability list) may expose additional helpers at runtime via [`catalog::register`]. The build-time catalog stays canonical; registered helpers are consulted only when the build-time one does not admit the name. + +Rules: + +- The contract's `name` MUST start with `sys.` and MUST NOT collide with any of the 32 build-time entries (`ANSWERS`, `AGGREGATES`, or `MUTABLE`). +- A duplicate registration of the same name is refused. +- `register` returns `Result<&'static SysContract, String>` so a host can fail loudly at boot rather than silently shadow a fixture. +- `registered_names()` lists every runtime helper (for `check_card` diagnostics and tooling). +- `validate_sources` (profile §4.3 checker) accepts a registered helper the same way it accepts a build-time one; the error message lists both sets when a card names an unknown one. + +What is deliberately NOT changed: + +- `catalog::ANSWERS` stays 32 entries. New apps must opt in. +- `catalog::MUTABLE` (the writable sources table) is NOT extended by runtime registration; writes still go through the build-time list. This is the safe default — a card asking to mutate a runtime helper would be a much larger surface to audit. +- `catalog::AGGREGATES` is NOT extended; the no-facts rule remains unchanged. + +Example (finance-brief-style 17-capability registration): + +```rust +use octoscript_ui_l0::catalog::{register, SysContract}; + +register(SysContract { + name: "sys.news_sina".into(), + fields: &["id", "title", "source", "ts", "summary"], + schema: None, +})?; +``` + +A card `source n sys.news_sina(fields: [title, source, ts])` then checks and realizes the same way it would for a build-time helper. + +See `examples/register_layered.rs` for a full 17-capability registration, `tests/catalog_register.rs` for the unit-test coverage, and `fuzz/fuzz_targets/catalog_register.rs` for the fuzz harness. + --- ## 5. Components and instance identity diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 389dc31..e15dde5 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -450,6 +450,7 @@ dependencies = [ "octoscript-sandbox", "octoscript-schema", "octoscript-storage", + "octoscript-ui-l0", "octoscript-worker", "octoscript-workflow", "serde_json", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 6774049..da170ef 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -22,6 +22,7 @@ octoscript-protocol = { version = "0.1.0", path = "../crates/octoscript-protocol octoscript-sandbox = { version = "0.1.0", path = "../crates/octoscript-sandbox" } octoscript-schema = { version = "0.1.0", path = "../crates/octoscript-schema", features = ["exact-numbers"] } octoscript-storage = { version = "0.1.0", path = "../crates/octoscript-storage" } +octoscript-ui-l0 = { version = "0.1.0", path = "../crates/octoscript-ui-l0" } octoscript-worker = { version = "0.1.0", path = "../crates/octoscript-worker" } octoscript-workflow = { version = "0.1.0", path = "../crates/octoscript-workflow" } @@ -141,3 +142,9 @@ name = "rollback_anchor_service" path = "fuzz_targets/rollback_anchor_service.rs" test = false doc = false + +[[bin]] +name = "catalog_register" +path = "fuzz_targets/catalog_register.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/catalog_register.rs b/fuzz/fuzz_targets/catalog_register.rs new file mode 100644 index 0000000..0aa4df5 --- /dev/null +++ b/fuzz/fuzz_targets/catalog_register.rs @@ -0,0 +1,51 @@ +#![no_main] + +//! Fuzz the runtime-registered catalog layer. +//! +//! The build-time `catalog::ANSWERS` table is closed; `catalog::register` +//! layers host-owned helpers on top. This target exercises the boundary: +//! every iteration picks a (name, fields) pair from a small fixed pool and +//! calls `register` (allowing `Err` from duplicate registrations) and the +//! three lookup functions. The expectation is "never panic, never UB". + +use libfuzzer_sys::fuzz_target; +use octoscript_ui_l0::catalog::{self, SysContract}; + +const FAKE_NAMES: &[&str] = &[ + "sys.fuzz_a", + "sys.fuzz_b", + "sys.fuzz_c", + "sys.fuzz_d", + "sys.fuzz_e", + "sys.fuzz_f", + "sys.fuzz_g", + "sys.fuzz_h", +]; + +const FIELD_SETS: &[&[&str]] = &[ + &[], + &["a"], + &["a", "b"], + &["a", "b", "c"], + &["id", "title", "ts", "key", "summary"], +]; + +fuzz_target!(|data: &[u8]| { + if data.is_empty() { + return; + } + let name = FAKE_NAMES[(data[0] as usize) % FAKE_NAMES.len()]; + let fields = FIELD_SETS[((data[0] as usize) >> 4) % FIELD_SETS.len()]; + + // Register: Ok on first attempt, Err on duplicate. Both are valid. + let _ = catalog::register(SysContract { + name: name.to_string(), + fields, + schema: None, + }); + + // Lookups must not panic regardless of whether `name` is registered. + let _ = catalog::answers(name); + let _ = catalog::registered_names(); + let _ = catalog::aggregates(name); +});