Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
5 changes: 5 additions & 0 deletions .github/workflows/sustained-fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@
/fuzz/target/
*.swp
.DS_Store

# Local coordination docs (not for upstream)
/.todo-*.md
/.todo-*/
**/.todo-*.md
**/.todo-*/
132 changes: 132 additions & 0 deletions crates/octoscript-ui-l0/examples/register_layered.rs
Original file line number Diff line number Diff line change
@@ -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 -- <card>

use octoscript_ui_l0::{catalog, catalog::SysContract, check_ui_l0_named};

fn finance_brief_capabilities() -> Vec<SysContract> {
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 <card>");
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);
}
}
115 changes: 112 additions & 3 deletions crates/octoscript-ui-l0/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3052,6 +3052,8 @@ fn collect_constructors(e: &Element, out: &mut Vec<String>) {
/// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand All @@ -4100,6 +4126,87 @@ pub mod catalog {
.find(|(n, _)| *n == name)
.map_or(&[], |(_, a)| *a)
}

/// Runtime catalog entry for a host-registered `sys.<name>` 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<String>,
}

static REGISTERED: OnceLock<RwLock<Vec<&'static SysContract>>> = OnceLock::new();

fn registered_table() -> &'static RwLock<Vec<&'static SysContract>> {
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<String> {
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 ──
Expand Down Expand Up @@ -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(", ")
),
Expand Down
Loading