diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 197b937..50f0e8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,28 +17,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - path: pd-controller - - uses: actions/checkout@v4 - with: - repository: rustscript-lang/pd-edge - path: pd-edge - - uses: actions/checkout@v4 - with: - repository: rustscript-lang/rustscript - path: rustscript - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - name: Format check - working-directory: pd-controller run: cargo fmt --all -- --check - name: Clippy - working-directory: pd-controller - run: cargo clippy --workspace --all-targets --all-features + run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Tests - working-directory: pd-controller - run: cargo test --workspace + run: cargo test --workspace -- --test-threads=1 + - name: Tests with mqtt + run: cargo test --workspace --features mqtt -- --test-threads=1 controller: name: pd-controller binary (${{ matrix.os }}) @@ -51,13 +40,10 @@ jobs: - uses: actions/checkout@v4 with: path: pd-controller - - uses: actions/checkout@v4 - with: - repository: rustscript-lang/pd-edge - path: pd-edge - uses: actions/checkout@v4 with: repository: rustscript-lang/rustscript + ref: b1d6cffede77f49410bf63525f30b9a46b02dc01 path: rustscript - uses: dtolnay/rust-toolchain@stable - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 0360fda..76389c0 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -45,11 +45,13 @@ jobs: uses: actions/checkout@v4 with: repository: rustscript-lang/rustscript + ref: b1d6cffede77f49410bf63525f30b9a46b02dc01 path: rustscript - name: Checkout pd-edge dependency uses: actions/checkout@v4 with: repository: rustscript-lang/pd-edge + ref: 5f4f889e349bdfbd5534deb42bd13b616a6114f5 path: pd-edge - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -105,7 +107,7 @@ jobs: if section in ('[workspace.package]', '[package]') and stripped.startswith('version = '): line = re.sub(r'version\s*=\s*"[^"]*"', f'version = "{manifest_version}"', line) match = dep_re.match(line) - if match and 'path' in match.group(3): + if match and ('path' in match.group(3) or 'git' in match.group(3)): prefix, key, body, suffix = match.groups() package_match = re.search(r'package\s*=\s*"([^"]+)"', body) dep_name = package_match.group(1) if package_match else key @@ -118,6 +120,10 @@ jobs: if body and not body.endswith(','): body += ',' body += f' version = "{dep_version}"' + body = re.sub(r'\s*,?\s*path\s*=\s*"[^"]*"', '', body) + body = re.sub(r'\s*,?\s*git\s*=\s*"[^"]*"', '', body) + body = re.sub(r'\s*,?\s*rev\s*=\s*"[^"]*"', '', body) + body = body.strip().strip(',') line = prefix + body + suffix out.append(line) path.write_text('\n'.join(out) + '\n') diff --git a/Cargo.lock b/Cargo.lock index f09ce6b..4efce01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1214,6 +1214,7 @@ dependencies = [ [[package]] name = "pd-edge" version = "0.1.0" +source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=5f4f889e349bdfbd5534deb42bd13b616a6114f5#5f4f889e349bdfbd5534deb42bd13b616a6114f5" dependencies = [ "arc-swap", "axum", @@ -1255,6 +1256,7 @@ dependencies = [ [[package]] name = "pd-edge-abi" version = "0.1.0" +source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=5f4f889e349bdfbd5534deb42bd13b616a6114f5#5f4f889e349bdfbd5534deb42bd13b616a6114f5" dependencies = [ "pd-host-function", "syn", @@ -1263,6 +1265,7 @@ dependencies = [ [[package]] name = "pd-edge-host-function" version = "0.1.0" +source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=5f4f889e349bdfbd5534deb42bd13b616a6114f5#5f4f889e349bdfbd5534deb42bd13b616a6114f5" dependencies = [ "proc-macro2", "quote", @@ -1272,15 +1275,27 @@ dependencies = [ [[package]] name = "pd-host-function" version = "0.1.0" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b1d6cffede77f49410bf63525f30b9a46b02dc01#b1d6cffede77f49410bf63525f30b9a46b02dc01" dependencies = [ + "pd-host-schema", "proc-macro2", "quote", "syn", ] +[[package]] +name = "pd-host-schema" +version = "0.1.0" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b1d6cffede77f49410bf63525f30b9a46b02dc01#b1d6cffede77f49410bf63525f30b9a46b02dc01" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "pd-vm" version = "0.1.0" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b1d6cffede77f49410bf63525f30b9a46b02dc01#b1d6cffede77f49410bf63525f30b9a46b02dc01" dependencies = [ "base64", "cranelift-codegen", @@ -1291,11 +1306,12 @@ dependencies = [ "futures-channel", "libc", "paste", - "pd-edge-abi", "pd-host-function", + "pd-host-schema", "regex", "rt-format", "rustyline", + "self_cell", "serde", "serde_json", "syn", @@ -1800,6 +1816,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.27" diff --git a/Cargo.toml b/Cargo.toml index 593a392..5c49cec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,17 +18,20 @@ homepage = "https://rustscript.org/" repository = "https://github.com/rustscript-lang/pd-controller" build = "build.rs" +[features] +mqtt = ["edge/mqtt"] + [dependencies] axum = { version = "0.8", features = ["ws"] } base64 = "0.22" -edge = { package = "pd-edge", path = "../pd-edge", version = "0.1.0" } +edge = { package = "pd-edge", git = "https://github.com/rustscript-lang/pd-edge.git", rev = "5f4f889e349bdfbd5534deb42bd13b616a6114f5", version = "0.1.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } uuid = { version = "1", features = ["v4"] } -vm = { package = "pd-vm", path = "../rustscript", version = "0.1.0" } +vm = { package = "pd-vm", git = "https://github.com/rustscript-lang/rustscript.git", rev = "b1d6cffede77f49410bf63525f30b9a46b02dc01", version = "0.1.0" } [dev-dependencies] reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/src/server.rs b/src/server.rs index ae6c18b..e4285f1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -620,7 +620,7 @@ impl ControllerState { .and_then(|session_id| guard.get(session_id).map(DebugSessionRecord::to_detail)); (sessions, selected_session) }; - sessions.sort_by(|lhs, rhs| rhs.updated_unix_ms.cmp(&lhs.updated_unix_ms)); + sessions.sort_by_key(|lhs| std::cmp::Reverse(lhs.updated_unix_ms)); DebugSessionsStreamSnapshot { kind: "snapshot", sessions, diff --git a/src/server/handlers.rs b/src/server/handlers.rs index c23de1c..456a883 100644 --- a/src/server/handlers.rs +++ b/src/server/handlers.rs @@ -730,7 +730,7 @@ pub(super) async fn list_debug_sessions_handler( .map(DebugSessionRecord::to_summary) .collect::>() }; - sessions.sort_by(|lhs, rhs| rhs.updated_unix_ms.cmp(&lhs.updated_unix_ms)); + sessions.sort_by_key(|lhs| std::cmp::Reverse(lhs.updated_unix_ms)); Json(DebugSessionListResponse { sessions }) } @@ -1065,10 +1065,10 @@ pub(super) async fn run_debug_command_handler( debug_sessions_changed = true; } } - DebugCommandRequest::ClearLine { line } => { - if session.breakpoints.remove(&line) { - debug_sessions_changed = true; - } + DebugCommandRequest::ClearLine { line } + if session.breakpoints.remove(&line) => + { + debug_sessions_changed = true; } _ => {} } diff --git a/tests/controller_tests/support.rs b/tests/controller_tests/support.rs index c4ff681..8f57992 100644 --- a/tests/controller_tests/support.rs +++ b/tests/controller_tests/support.rs @@ -16,7 +16,7 @@ pub(crate) use pd_controller::{ }; pub(crate) use tokio::task::JoinHandle; pub(crate) use uuid::Uuid; -pub(crate) use vm::{SourceFlavor, compile_source_with_flavor, decode_program}; +pub(crate) use vm::{SourceFlavor, decode_program}; static TEST_STATE_PATH_SEQUENCE: AtomicU64 = AtomicU64::new(0); diff --git a/tests/controller_tests/ui.rs b/tests/controller_tests/ui.rs index 9d2a5c2..f6494b8 100644 --- a/tests/controller_tests/ui.rs +++ b/tests/controller_tests/ui.rs @@ -1,5 +1,51 @@ use super::support::*; +fn protocol_import_matches(name: &str, root: &str) -> bool { + name.starts_with(&format!("{root}::")) || name.contains(&format!("::{root}::")) +} + +fn assert_default_off_protocol_unbound_or_fail_closed(source: &str, root: &str) { + match edge::compile_edge_source_with_flavor(source, SourceFlavor::RustScript) { + Err(err) => { + let message = err.to_string(); + assert!( + message.contains("unknown host function") || message.contains(root), + "fail-closed {root} compile error must identify the omitted host, got: {message}" + ); + } + Ok(compiled) => { + let schemas = compiled.program.host_import_schemas(); + assert_eq!( + schemas.len(), + compiled.program.imports.len(), + "host import schemas must stay aligned with imports" + ); + let mut seen = 0usize; + for (import, schema) in compiled.program.imports.iter().zip(schemas.iter()) { + if !protocol_import_matches(&import.name, root) { + continue; + } + seen += 1; + assert!( + schema.is_none(), + "{} must remain unbound (None schema) when {root} is omitted, got {schema:?}", + import.name + ); + } + assert!( + seen > 0, + "compiled source must include {root} imports to prove unbound None schemas; imports={:?}", + compiled + .program + .imports + .iter() + .map(|import| import.name.as_str()) + .collect::>() + ); + } + } +} + #[tokio::test] async fn ui_blocks_and_deploy_endpoints_work() { let (addr, handle, _state) = spawn_controller(ControllerConfig::default()).await; @@ -705,7 +751,7 @@ async fn ui_render_extended_value_blocks_work_with_flow_graph() { rustscript.contains("vm::http::response::set_body(status_plus_len);"), "expected data edge into flow action, got: {rustscript}" ); - if let Err(err) = compile_source_with_flavor(rustscript, SourceFlavor::RustScript) { + if let Err(err) = edge::compile_edge_source_with_flavor(rustscript, SourceFlavor::RustScript) { panic!("expected generated rustscript to compile, got: {err}\nsource:\n{rustscript}"); } @@ -1135,6 +1181,10 @@ async fn ui_render_extended_abi_blocks_generate_expected_calls() { assert!( rustscript.contains("vm::websocket::connection::set_target(ws, \"ws://127.0.0.1:8081\");") ); + assert!( + rustscript.lines().any(|line| line.trim() == "use vm;"), + "UI rustscript must include production `use vm;`, got:\n{rustscript}" + ); assert!(rustscript.contains("let rtc: int = vm::webrtc::connection::new();")); assert!(rustscript.contains("let udp: int = vm::udp::socket::new();")); assert!( @@ -1142,9 +1192,30 @@ async fn ui_render_extended_abi_blocks_generate_expected_calls() { ); assert!(rustscript.contains("let upstream_proxy: int = upstream::as_stream();")); assert!(rustscript.contains("let upstream_all = upstream_response::read_all();")); - if let Err(err) = edge::compile_edge_source_with_flavor(rustscript, SourceFlavor::RustScript) { - panic!("expected rustscript ABI render to compile, got: {err}\nsource:\n{rustscript}"); + let without_webrtc = rustscript + .lines() + .filter(|line| !line.contains("webrtc")) + .collect::>() + .join("\n"); + if let Err(err) = + edge::compile_edge_source_with_flavor(&without_webrtc, SourceFlavor::RustScript) + { + panic!( + "expected rustscript ABI render without webrtc to compile, got: {err}\nsource:\n{without_webrtc}" + ); } + let mut webrtc_ui_source = String::from("use vm;\n"); + for line in rustscript.lines() { + if line.contains("webrtc") { + webrtc_ui_source.push_str(line.trim()); + webrtc_ui_source.push('\n'); + } + } + assert!( + webrtc_ui_source.contains("let rtc: int = vm::webrtc::connection::new();"), + "UI rustscript must emit production webrtc shape with `use vm;`, got:\n{rustscript}" + ); + assert_default_off_protocol_unbound_or_fail_closed(&webrtc_ui_source, "webrtc"); let javascript = render_json["source"]["javascript"] .as_str() @@ -1487,7 +1558,7 @@ async fn ui_render_plain_if_and_loop_flow() { rustscript.contains("vm::http::response::set_status(403);"), "expected if false branch action in rustscript, got: {rustscript}" ); - if let Err(err) = compile_source_with_flavor(rustscript, SourceFlavor::RustScript) { + if let Err(err) = edge::compile_edge_source_with_flavor(rustscript, SourceFlavor::RustScript) { panic!("expected flow rustscript to compile, got: {err}\nsource:\n{rustscript}"); } diff --git a/tests/dependency_pin_tests.rs b/tests/dependency_pin_tests.rs new file mode 100644 index 0000000..f00d7b9 --- /dev/null +++ b/tests/dependency_pin_tests.rs @@ -0,0 +1,229 @@ +//! Per-package source proof for frozen pd-edge and RustScript pins. +//! +//! Uses only `std` so a broken git pin can still be diagnosed without compiling +//! the workspace graph. + +use std::path::PathBuf; + +const PD_EDGE_GIT: &str = "https://github.com/rustscript-lang/pd-edge.git"; +const PD_EDGE_REV: &str = "5f4f889e349bdfbd5534deb42bd13b616a6114f5"; +const RUSTSCRIPT_GIT: &str = "https://github.com/rustscript-lang/rustscript.git"; +const RUSTSCRIPT_REV: &str = "b1d6cffede77f49410bf63525f30b9a46b02dc01"; +const ABBREVIATED_EDGE_REV: &str = "5f4f889"; +const ABBREVIATED_RUSTSCRIPT_REV: &str = "b1d6cff"; + +fn manifest() -> String { + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .expect("read Cargo.toml") +} + +fn lockfile() -> String { + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock")) + .expect("read Cargo.lock") +} + +fn dependency_line<'a>(manifest: &'a str, key: &str) -> &'a str { + let prefix = format!("{key} = {{"); + manifest + .lines() + .find(|line| line.trim_start().starts_with(&prefix)) + .unwrap_or_else(|| panic!("Cargo.toml must declare {key}")) +} + +fn quoted_rev<'a>(dependency: &'a str, key: &str) -> Option<&'a str> { + let needle = format!("{key} = \""); + let start = dependency.find(&needle)? + needle.len(); + let end = start + dependency[start..].find('"')?; + Some(&dependency[start..end]) +} + +fn assert_full_sha_pin(dependency: &str, crate_name: &str, expected: &str, abbreviated: &str) { + let rev = + quoted_rev(dependency, "rev").unwrap_or_else(|| panic!("{crate_name} must declare rev")); + assert_eq!( + rev.len(), + 40, + "{crate_name} rev must be the full 40-character SHA, not an abbreviation: {rev}" + ); + assert!( + rev.chars().all(|ch| ch.is_ascii_hexdigit()), + "{crate_name} rev must be hexadecimal: {rev}" + ); + assert_eq!( + rev, expected, + "{crate_name} must pin the frozen full SHA {expected}, got {rev}" + ); + assert_ne!( + rev, abbreviated, + "{crate_name} must not pin the abbreviated SHA {abbreviated}" + ); + assert!( + !dependency.contains("path ="), + "{crate_name} must not depend on sibling checkout state: {dependency}" + ); + assert!( + !dependency.contains("branch ="), + "{crate_name} must not pin a git branch: {dependency}" + ); +} + +#[derive(Debug)] +struct LockPackage { + name: String, + version: String, + source: Option, +} + +fn lock_packages(lock: &str) -> Vec { + let mut packages = Vec::new(); + let mut name = None; + let mut version = None; + let mut source = None; + for line in lock.lines() { + let line = line.trim(); + if line == "[[package]]" { + if let (Some(name), Some(version)) = (name.take(), version.take()) { + packages.push(LockPackage { + name, + version, + source: source.take(), + }); + } + continue; + } + if let Some(value) = line + .strip_prefix("name = \"") + .and_then(|rest| rest.strip_suffix('"')) + { + name = Some(value.to_string()); + continue; + } + if let Some(value) = line + .strip_prefix("version = \"") + .and_then(|rest| rest.strip_suffix('"')) + { + version = Some(value.to_string()); + continue; + } + if let Some(value) = line + .strip_prefix("source = \"") + .and_then(|rest| rest.strip_suffix('"')) + { + source = Some(value.to_string()); + } + } + if let (Some(name), Some(version)) = (name, version) { + packages.push(LockPackage { + name, + version, + source, + }); + } + packages +} + +fn git_source(git: &str, rev: &str) -> String { + format!("git+{git}?rev={rev}#{rev}") +} + +fn assert_lock_source(packages: &[LockPackage], name: &str, version: &str, expected: &str) { + let matches: Vec<&LockPackage> = packages + .iter() + .filter(|package| package.name == name && package.version == version) + .collect(); + assert_eq!( + matches.len(), + 1, + "Cargo.lock must declare exactly one {name} {version}, found {}", + matches.len() + ); + let source = matches[0] + .source + .as_deref() + .unwrap_or_else(|| panic!("Cargo.lock {name} {version} must declare a source")); + assert_eq!( + source, expected, + "Cargo.lock {name} {version} must use the frozen git SHA source" + ); +} + +#[test] +fn pd_edge_uses_the_frozen_full_git_sha() { + let manifest = manifest(); + let dependency = dependency_line(&manifest, "edge"); + assert!( + dependency.contains(&format!("git = \"{PD_EDGE_GIT}\"")), + "pd-edge must use the canonical HTTPS Git remote: {dependency}" + ); + assert_full_sha_pin(dependency, "pd-edge", PD_EDGE_REV, ABBREVIATED_EDGE_REV); +} + +#[test] +fn pd_vm_uses_the_frozen_full_git_sha() { + let manifest = manifest(); + let dependency = dependency_line(&manifest, "vm"); + assert!( + dependency.contains(&format!("git = \"{RUSTSCRIPT_GIT}\"")), + "pd-vm must use the canonical HTTPS Git remote: {dependency}" + ); + assert_full_sha_pin( + dependency, + "pd-vm", + RUSTSCRIPT_REV, + ABBREVIATED_RUSTSCRIPT_REV, + ); +} + +#[test] +fn lockfile_pins_pd_edge_and_rustscript_crates_per_package() { + let packages = lock_packages(&lockfile()); + let edge_source = git_source(PD_EDGE_GIT, PD_EDGE_REV); + let rustscript_source = git_source(RUSTSCRIPT_GIT, RUSTSCRIPT_REV); + + assert_lock_source(&packages, "pd-edge", "0.1.0", &edge_source); + assert_lock_source(&packages, "pd-edge-abi", "0.1.0", &edge_source); + assert_lock_source(&packages, "pd-edge-host-function", "0.1.0", &edge_source); + assert_lock_source(&packages, "pd-vm", "0.1.0", &rustscript_source); + assert_lock_source(&packages, "pd-host-function", "0.1.0", &rustscript_source); + assert_lock_source(&packages, "pd-host-schema", "0.1.0", &rustscript_source); + + let registry_family: Vec<&LockPackage> = packages + .iter() + .filter(|package| { + package + .source + .as_deref() + .is_some_and(|source| source.starts_with("registry+")) + && (package.name.starts_with("pd-edge") + || package.name.starts_with("pd-host-") + || package.name.starts_with("pd-vm")) + }) + .collect(); + assert!( + registry_family.is_empty(), + "lockfile must not resolve registry pd-edge*/pd-host-*/pd-vm* packages, found {registry_family:?}" + ); + + for package in &packages { + if let Some(source) = &package.source { + assert!( + !source.contains(&format!("rev={ABBREVIATED_EDGE_REV}\"")), + "{} {} must not resolve an abbreviated pd-edge SHA: {source}", + package.name, + package.version + ); + assert!( + !source.contains(&format!("rev={ABBREVIATED_RUSTSCRIPT_REV}\"")), + "{} {} must not resolve an abbreviated RustScript SHA: {source}", + package.name, + package.version + ); + assert!( + !source.starts_with("path+") && !source.starts_with("file+"), + "{} {} must not resolve a path/file source: {source}", + package.name, + package.version + ); + } + } +} diff --git a/tests/e2e_demo_tests.rs b/tests/e2e_demo_tests.rs index 3bbb8ad..223f2ef 100644 --- a/tests/e2e_demo_tests.rs +++ b/tests/e2e_demo_tests.rs @@ -8,7 +8,7 @@ use pd_controller::{ ControllerConfig, ControllerState, EnqueueCommandResponse, build_controller_app, }; use tokio::task::JoinHandle; -use vm::{Program, compile_source, encode_program}; +use vm::{Program, SourceFlavor, encode_program}; #[derive(serde::Deserialize)] struct ResultsResponse { @@ -27,9 +27,12 @@ async fn spawn_server(app: axum::Router) -> (SocketAddr, JoinHandle<()>) { } fn build_short_circuit_program(body: &str) -> Program { - compile_source(&format!("use http;\nhttp::response::set_body({body:?});\n")) - .expect("short-circuit e2e source should compile") - .program + edge::compile_edge_source_with_flavor( + &format!("use http;\nhttp::response::set_body({body:?});\n"), + SourceFlavor::RustScript, + ) + .expect("short-circuit e2e source should compile through the edge catalog") + .program } #[tokio::test] diff --git a/tests/edge_catalog_contract.rs b/tests/edge_catalog_contract.rs new file mode 100644 index 0000000..39191a2 --- /dev/null +++ b/tests/edge_catalog_contract.rs @@ -0,0 +1,329 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use edge::{ABI_VERSION, HOST_FUNCTION_COUNT, compile_edge_source_with_flavor, function_by_name}; +#[cfg(feature = "mqtt")] +use vm::HostTypeSchema; +use vm::SourceFlavor; + +/// Exact hex form of the compile catalog fingerprint for the active feature set. +/// Default (mqtt-off) stays on the frozen ABI25 HTTP/TLS/WS catalog. Enabling +/// `mqtt` joins MQTT hosts, so the catalog identity changes with that feature. +#[cfg(not(feature = "mqtt"))] +const EDGE_CATALOG_FINGERPRINT_HEX: &str = "ff4fc9ca114a15c1"; +#[cfg(not(feature = "mqtt"))] +const EDGE_CATALOG_FINGERPRINT_U64: u64 = 0xff4fc9ca114a15c1; +#[cfg(feature = "mqtt")] +const EDGE_CATALOG_FINGERPRINT_HEX: &str = "b5660b84004c864c"; +#[cfg(feature = "mqtt")] +const EDGE_CATALOG_FINGERPRINT_U64: u64 = 0xb5660b84004c864c; + +const HTTP_SHORT_CIRCUIT: &str = "use http;\nhttp::response::set_body(\"catalog-contract\");\n"; +const HTTP_SET_STATUS: &str = "use http;\nhttp::response::set_status(204);\n"; +const TCP_NEW: &str = "use tcp;\nlet stream = tcp::stream::new();\n"; +const TLS_FROM_SOCKET: &str = "use tcp;\nuse tls;\nlet stream = tcp::stream::new();\nlet session = tls::session::from_socket(stream);\n"; +const WEBSOCKET_NEW: &str = "use websocket;\nlet ws = websocket::connection::new();\n"; +const UDP_NEW: &str = "use udp;\nlet socket = udp::socket::new();\n"; +const PROXY_DOWNSTREAM: &str = "use proxy;\nlet downstream = proxy::stream::downstream();\n"; +#[cfg(feature = "mqtt")] +const MQTT_READ_EVENT: &str = r#" +use mqtt; +let connection = mqtt::connection::new(); +let event = mqtt::connection::read_event(connection); +"#; +#[cfg(not(feature = "mqtt"))] +const MQTT_UNBOUND_SOURCE: &str = + "use mqtt;\nmqtt::connection::new();\nmqtt::connection::read_event(0);\n"; +const WEBRTC_UI_SOURCE: &str = "use vm;\nlet rtc: int = vm::webrtc::connection::new();\n"; + +fn compile_rss(source: &str) -> vm::CompiledProgram { + compile_edge_source_with_flavor(source, SourceFlavor::RustScript).unwrap_or_else(|err| { + panic!("source must compile through the migrated edge catalog: {err}\n{source}") + }) +} + +fn assert_exact_catalog_schema(schema: &vm::HostImportSchema, expected_name: &str) { + assert_eq!(schema.name, expected_name); + assert_eq!( + format!("{}", schema.fingerprint), + EDGE_CATALOG_FINGERPRINT_HEX + ); + assert_eq!(schema.fingerprint.as_u64(), EDGE_CATALOG_FINGERPRINT_U64); +} + +fn assert_exact_catalog_imports(program: &vm::Program, expected_names: &[&str]) { + let schemas = program.host_import_schemas(); + assert_eq!( + schemas.len(), + program.imports.len(), + "host import schemas must stay aligned with imports" + ); + for (import, schema) in program.imports.iter().zip(schemas.iter()) { + let schema = schema.as_ref().unwrap_or_else(|| { + panic!( + "{} must carry an exact ABI25 catalog schema, not a missing/stale fallback", + import.name + ) + }); + assert_exact_catalog_schema(schema, &import.name); + } + for name in expected_names { + let (index, _) = program + .imports + .iter() + .enumerate() + .find(|(_, import)| import.name == *name) + .unwrap_or_else(|| panic!("fixture must import {name}")); + let schema = schemas[index] + .as_ref() + .unwrap_or_else(|| panic!("{name} must have Some(schema) with the ABI25 fingerprint")); + assert_exact_catalog_schema(schema, name); + } +} + +fn protocol_import_matches(name: &str, root: &str) -> bool { + name.starts_with(&format!("{root}::")) || name.contains(&format!("::{root}::")) +} + +fn assert_unbound_protocol_imports(program: &vm::Program, root: &str) { + let schemas = program.host_import_schemas(); + assert_eq!( + schemas.len(), + program.imports.len(), + "host import schemas must stay aligned with imports" + ); + let mut seen = 0usize; + for (import, schema) in program.imports.iter().zip(schemas.iter()) { + if !protocol_import_matches(&import.name, root) { + continue; + } + seen += 1; + assert!( + schema.is_none(), + "{} must remain unbound (None schema) when {root} is omitted from the catalog, got {schema:?}", + import.name + ); + } + assert!( + seen > 0, + "compiled source must include {root} imports to prove unbound None schemas; imports={:?}", + program + .imports + .iter() + .map(|import| import.name.as_str()) + .collect::>() + ); +} + +fn assert_fail_closed_protocol_error(err: impl std::fmt::Display, root: &str) { + let message = err.to_string(); + assert!( + message.contains("unknown host function") || message.contains(root), + "fail-closed {root} compile error must identify the omitted host, got: {message}" + ); +} + +fn assert_default_off_protocol_compile(source: &str, root: &str) { + match compile_edge_source_with_flavor(source, SourceFlavor::RustScript) { + Err(err) => assert_fail_closed_protocol_error(err, root), + Ok(compiled) => assert_unbound_protocol_imports(&compiled.program, root), + } +} + +fn catalog_fingerprint(program: &vm::Program) -> vm::HostApiFingerprint { + let schemas = program.host_import_schemas(); + assert_eq!(schemas.len(), program.imports.len()); + let mut fingerprint = None; + for (import, schema) in program.imports.iter().zip(schemas.iter()) { + let schema = schema + .as_ref() + .unwrap_or_else(|| panic!("{} must carry an exact catalog schema", import.name)); + match fingerprint { + None => fingerprint = Some(schema.fingerprint), + Some(existing) => assert_eq!( + existing, schema.fingerprint, + "every exact catalog import must share one fingerprint" + ), + } + } + fingerprint.expect("compiled edge program must carry catalog import fingerprints") +} + +fn walk_rss_files(root: &Path, files: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == ".git" + || name == "target" + || name == "node_modules" + || name == "webui" + || name == "tmp" + { + continue; + } + if path.is_dir() { + walk_rss_files(&path, files); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) == Some("rss") { + files.push(path); + } + } +} + +#[cfg(feature = "mqtt")] +fn assert_mqtt_event_fields(schema: &HostTypeSchema) { + match schema { + HostTypeSchema::Named { name, fields } => { + assert_eq!(name, "MqttEvent"); + let expected = [ + ("kind", HostTypeSchema::String), + ( + "topic", + HostTypeSchema::Optional(Box::new(HostTypeSchema::String)), + ), + ( + "payload_text", + HostTypeSchema::Optional(Box::new(HostTypeSchema::String)), + ), + ( + "payload_base64", + HostTypeSchema::Optional(Box::new(HostTypeSchema::String)), + ), + ( + "qos", + HostTypeSchema::Optional(Box::new(HostTypeSchema::Int)), + ), + ( + "retain", + HostTypeSchema::Optional(Box::new(HostTypeSchema::Bool)), + ), + ( + "dup", + HostTypeSchema::Optional(Box::new(HostTypeSchema::Bool)), + ), + ( + "reason", + HostTypeSchema::Optional(Box::new(HostTypeSchema::String)), + ), + ]; + assert_eq!(fields.len(), expected.len(), "MqttEvent field count"); + for (field, (expected_name, expected_ty)) in fields.iter().zip(expected) { + assert_eq!(field.name, expected_name); + assert_eq!(field.ty, expected_ty); + } + } + other => panic!("expected Named MqttEvent, got {other:?}"), + } +} + +#[test] +fn published_abi_is_version_25() { + assert_eq!(ABI_VERSION, 25); + for index in 0..HOST_FUNCTION_COUNT { + let function = edge::function_by_index(index) + .unwrap_or_else(|| panic!("ABI function {index} must exist")); + assert_eq!(function.index, index); + assert_eq!( + function_by_name(function.name).map(|item| item.index), + Some(index) + ); + } + assert!(function_by_name("http::response::set_body").is_some()); +} + +#[test] +fn edge_catalog_fingerprint_is_golden_and_stable() { + let first = compile_rss(HTTP_SHORT_CIRCUIT); + let second = compile_rss(HTTP_SHORT_CIRCUIT); + let fingerprint = catalog_fingerprint(&first.program); + assert_eq!(fingerprint, catalog_fingerprint(&second.program)); + assert_eq!(format!("{fingerprint}"), EDGE_CATALOG_FINGERPRINT_HEX); + assert_eq!(fingerprint.as_u64(), EDGE_CATALOG_FINGERPRINT_U64); +} + +#[cfg(not(feature = "mqtt"))] +#[test] +fn mqtt_is_absent_from_the_default_production_catalog() { + assert!(function_by_name("mqtt::connection::read_event").is_none()); + assert!(function_by_name("mqtt::connection::new").is_none()); + assert_default_off_protocol_compile(MQTT_UNBOUND_SOURCE, "mqtt"); +} + +#[test] +fn webrtc_ui_source_is_unbound_when_default_off() { + assert!(function_by_name("webrtc::connection::new").is_none()); + assert_default_off_protocol_compile(WEBRTC_UI_SOURCE, "webrtc"); +} + +#[cfg(feature = "mqtt")] +#[test] +fn named_mqtt_event_is_the_exact_catalog_binding() { + let spec = function_by_name("mqtt::connection::read_event") + .expect("canonical mqtt::connection::read_event must be published when mqtt is enabled"); + assert_eq!(spec.name, "mqtt::connection::read_event"); + assert!(function_by_name("mqtt::connection::new").is_some()); + + let compiled = compile_rss(MQTT_READ_EVENT); + assert_exact_catalog_imports( + &compiled.program, + &["mqtt::connection::new", "mqtt::connection::read_event"], + ); + + let schema = compiled + .program + .host_import_schemas() + .iter() + .flatten() + .find(|schema| schema.name == "mqtt::connection::read_event") + .expect("mqtt::connection::read_event must have Some(schema)"); + assert_mqtt_event_fields(&schema.return_type); + assert_eq!( + format!("{}", schema.fingerprint), + EDGE_CATALOG_FINGERPRINT_HEX + ); +} + +#[test] +fn checked_in_rss_and_controller_fixtures_compile_through_the_edge_catalog() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut files = Vec::new(); + walk_rss_files(&root, &mut files); + files.sort(); + assert!( + files.is_empty(), + "pd-controller has no checked-in RSS fixtures; found {files:?}" + ); + + let fixtures: &[(&str, &[&str])] = &[ + (HTTP_SHORT_CIRCUIT, &["http::response::set_body"]), + (HTTP_SET_STATUS, &["http::response::set_status"]), + (TCP_NEW, &["tcp::stream::new"]), + ( + TLS_FROM_SOCKET, + &["tcp::stream::new", "tls::session::from_socket"], + ), + (WEBSOCKET_NEW, &["websocket::connection::new"]), + (UDP_NEW, &["udp::socket::new"]), + (PROXY_DOWNSTREAM, &["proxy::stream::downstream"]), + ]; + for (source, expected) in fixtures { + let compiled = compile_rss(source); + assert_exact_catalog_imports(&compiled.program, expected); + } + + #[cfg(feature = "mqtt")] + { + let compiled = compile_rss(MQTT_READ_EVENT); + assert_exact_catalog_imports( + &compiled.program, + &["mqtt::connection::new", "mqtt::connection::read_event"], + ); + } +}