From dd0ee331ba13a335a967cfc2e277e767cae3c4cc Mon Sep 17 00:00:00 2001 From: fffonion Date: Thu, 17 Sep 2026 23:55:20 +0800 Subject: [PATCH 1/6] deps: pin pd-edge and pd-vm to frozen host-descriptor SHAs Replace sibling path checkouts with full-SHA git pins for pd-edge 6320847098530ab78b0d3cd438b714e699caa8db and pd-vm b1d6cffede77f49410bf63525f30b9a46b02dc01. Point CI and crates.io publish checkouts at those revisions and rewrite git pins on publish. --- .github/workflows/ci.yml | 22 ++---------- .github/workflows/publish-crates.yml | 8 ++++- Cargo.lock | 52 +++++++++++++++++++++++++--- Cargo.toml | 4 +-- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 197b937..562d05f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,28 +17,15 @@ 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 controller: name: pd-controller binary (${{ matrix.os }}) @@ -51,13 +38,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..bd10e3c 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: 6320847098530ab78b0d3cd438b714e699caa8db 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..eab652d 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=6320847098530ab78b0d3cd438b714e699caa8db#6320847098530ab78b0d3cd438b714e699caa8db" dependencies = [ "arc-swap", "axum", @@ -1229,7 +1230,7 @@ dependencies = [ "mimalloc", "owo-colors", "parking_lot", - "pd-edge-abi", + "pd-edge-abi 0.1.0", "pd-edge-host-function", "pd-vm", "rand 0.8.5", @@ -1255,14 +1256,26 @@ dependencies = [ [[package]] name = "pd-edge-abi" version = "0.1.0" +source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=6320847098530ab78b0d3cd438b714e699caa8db#6320847098530ab78b0d3cd438b714e699caa8db" dependencies = [ - "pd-host-function", + "pd-host-function 0.1.0", + "syn", +] + +[[package]] +name = "pd-edge-abi" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9676d58588b220f7af69d7aa86108042d2acaf21dd24c641a6d9ef3c4e193ba" +dependencies = [ + "pd-host-function 0.22.7", "syn", ] [[package]] name = "pd-edge-host-function" version = "0.1.0" +source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=6320847098530ab78b0d3cd438b714e699caa8db#6320847098530ab78b0d3cd438b714e699caa8db" dependencies = [ "proc-macro2", "quote", @@ -1272,15 +1285,38 @@ 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-function" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c284ea36b653630c2ee4417c518cbd045bd11f564fb7ecc9f65d986118c6" dependencies = [ "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 +1327,13 @@ dependencies = [ "futures-channel", "libc", "paste", - "pd-edge-abi", - "pd-host-function", + "pd-edge-abi 0.1.1", + "pd-host-function 0.1.0", + "pd-host-schema", "regex", "rt-format", "rustyline", + "self_cell", "serde", "serde_json", "syn", @@ -1800,6 +1838,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..c9474b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,14 +21,14 @@ build = "build.rs" [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 = "6320847098530ab78b0d3cd438b714e699caa8db", 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"] } From ef0ba5eed995a23a662a8b2be8eda5413e71d07e Mon Sep 17 00:00:00 2001 From: fffonion Date: Thu, 17 Sep 2026 23:55:28 +0800 Subject: [PATCH 2/6] test: prove ABI 25 catalog fingerprint and edge compile path Compile controller RSS fixtures through compile_edge_source_with_flavor, lock the default catalog fingerprint, and add per-package source proofs for the frozen pd-edge and RustScript pins. --- tests/controller_tests/support.rs | 2 +- tests/controller_tests/ui.rs | 4 +- tests/dependency_pin_tests.rs | 247 ++++++++++++++++++++++++++++++ tests/e2e_demo_tests.rs | 11 +- tests/edge_catalog_contract.rs | 182 ++++++++++++++++++++++ 5 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 tests/dependency_pin_tests.rs create mode 100644 tests/edge_catalog_contract.rs 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..337636d 100644 --- a/tests/controller_tests/ui.rs +++ b/tests/controller_tests/ui.rs @@ -705,7 +705,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}"); } @@ -1487,7 +1487,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..2fa517e --- /dev/null +++ b/tests/dependency_pin_tests.rs @@ -0,0 +1,247 @@ +//! 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 = "6320847098530ab78b0d3cd438b714e699caa8db"; +const RUSTSCRIPT_GIT: &str = "https://github.com/rustscript-lang/rustscript.git"; +const RUSTSCRIPT_REV: &str = "b1d6cffede77f49410bf63525f30b9a46b02dc01"; +const ABBREVIATED_EDGE_REV: &str = "6320847"; +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}" + ); +} + +#[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); + + // Frozen pd-edge `http` enables `vm/edge-abi`, and frozen pd-vm's optional + // `edge-abi` feature still depends on the published ABI crate. Keep that + // crates.io line distinct from the git 0.1.0 catalog used by pd-edge. + assert_lock_source( + &packages, + "pd-edge-abi", + "0.1.1", + "registry+https://github.com/rust-lang/crates.io-index", + ); + assert_lock_source( + &packages, + "pd-host-function", + "0.22.7", + "registry+https://github.com/rust-lang/crates.io-index", + ); + let registry_family: Vec<&LockPackage> = packages + .iter() + .filter(|package| { + package + .source + .as_deref() + .is_some_and(|source| source.starts_with("registry+")) + && matches!( + package.name.as_str(), + "pd-edge" + | "pd-edge-abi" + | "pd-edge-host-function" + | "pd-vm" + | "pd-host-function" + | "pd-host-schema" + ) + }) + .collect(); + assert_eq!( + registry_family.len(), + 2, + "only frozen pd-vm edge-abi published crates may remain on crates.io, 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..cfdf24d --- /dev/null +++ b/tests/edge_catalog_contract.rs @@ -0,0 +1,182 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use edge::{ABI_VERSION, HOST_FUNCTION_COUNT, compile_edge_source_with_flavor, function_by_name}; +use vm::{HostTypeSchema, SourceFlavor}; + +/// Exact hex form of the default pd-edge compile catalog fingerprint. +/// Bump together with [`EDGE_CATALOG_FINGERPRINT_U64`] when the frozen edge +/// ABI surface or pd-vm fingerprint encoding changes. +const EDGE_CATALOG_FINGERPRINT_HEX: &str = "ff4fc9ca114a15c1"; +const EDGE_CATALOG_FINGERPRINT_U64: u64 = 0xff4fc9ca114a15c1; + +const HTTP_SHORT_CIRCUIT: &str = "use http;\nhttp::response::set_body(\"catalog-contract\");\n"; +const MQTT_READ_EVENT: &str = r#" +use mqtt; +let connection = mqtt::connection::new(); +let event = mqtt::connection::read_event(connection); +"#; + +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 catalog_fingerprint(program: &vm::Program) -> vm::HostApiFingerprint { + let schemas = program.host_import_schemas(); + let mut fingerprint = None; + for schema in schemas.iter().flatten() { + 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); + } + } +} + +#[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); +} + +#[test] +fn named_mqtt_event_is_the_exact_catalog_binding_when_present() { + let compiled = compile_rss(MQTT_READ_EVENT); + assert!( + compiled + .program + .imports + .iter() + .any(|import| import.name == "mqtt::connection::read_event"), + "migrated edge compile catalog must bind mqtt::connection::read_event" + ); + + let schema = compiled + .program + .host_import_schemas() + .iter() + .flatten() + .find(|schema| schema.name == "mqtt::connection::read_event"); + + match (function_by_name("mqtt::connection::read_event"), schema) { + (Some(spec), Some(schema)) => { + assert_eq!(spec.name, "mqtt::connection::read_event"); + match &schema.return_type { + HostTypeSchema::Named { name, .. } => { + assert_eq!(name, "MqttEvent"); + } + other => panic!("expected Named MqttEvent, got {other:?}"), + } + assert_eq!( + format!("{}", schema.fingerprint), + EDGE_CATALOG_FINGERPRINT_HEX + ); + } + (None, None) => { + assert_eq!(ABI_VERSION, 25); + assert!( + function_by_name("http::response::set_body").is_some(), + "default controller catalog keeps HTTP hosts when MQTT stays ABI-gated" + ); + } + (abi, exact) => panic!( + "mqtt ABI publication and exact catalog schema must stay aligned; abi={:?} exact={}", + abi.map(|spec| spec.name), + exact.is_some() + ), + } +} + +#[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 = [ + HTTP_SHORT_CIRCUIT, + "use http;\nhttp::response::set_status(204);\n", + "use vm;\nvm::http::response::set_body(\"vm-ns\");\n", + "use tcp;\nlet stream = tcp::stream::new();\n", + "use tcp;\nuse tls;\nlet stream = tcp::stream::new();\nlet session = tls::session::from_socket(stream);\n", + "use websocket;\nlet ws = websocket::connection::new();\n", + "use udp;\nlet socket = udp::socket::new();\n", + "use proxy;\nlet downstream = proxy::stream::downstream();\n", + MQTT_READ_EVENT, + ]; + for source in fixtures { + let compiled = compile_rss(source); + assert!( + !compiled.program.host_import_schemas().is_empty() + || compiled + .program + .imports + .iter() + .any(|import| import.name.contains("http") + || import.name.contains("tcp") + || import.name.contains("tls") + || import.name.contains("websocket") + || import.name.contains("udp") + || import.name.contains("proxy") + || import.name.contains("response")), + "fixture must record host imports:\n{source}" + ); + } +} From e47e56b534630fbc35ce59f5df90a28c2c9d0e58 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 18 Sep 2026 03:07:45 +0800 Subject: [PATCH 3/6] deps: repin pd-edge to ABI25 without registry edge-abi Pin pd-edge to 5f4f889e349bdfbd5534deb42bd13b616a6114f5 and keep pd-vm at b1d6cffede77f49410bf63525f30b9a46b02dc01. Add mqtt = ["edge/mqtt"] and require the lockfile to keep pd-edge*/pd-host-*/pd-vm* off crates.io. --- .github/workflows/publish-crates.yml | 2 +- Cargo.lock | 34 ++++------------------ Cargo.toml | 5 +++- tests/dependency_pin_tests.rs | 42 ++++++++-------------------- 4 files changed, 23 insertions(+), 60 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index bd10e3c..76389c0 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -51,7 +51,7 @@ jobs: uses: actions/checkout@v4 with: repository: rustscript-lang/pd-edge - ref: 6320847098530ab78b0d3cd438b714e699caa8db + ref: 5f4f889e349bdfbd5534deb42bd13b616a6114f5 path: pd-edge - name: Setup Rust uses: dtolnay/rust-toolchain@stable diff --git a/Cargo.lock b/Cargo.lock index eab652d..4efce01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "pd-edge" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=6320847098530ab78b0d3cd438b714e699caa8db#6320847098530ab78b0d3cd438b714e699caa8db" +source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=5f4f889e349bdfbd5534deb42bd13b616a6114f5#5f4f889e349bdfbd5534deb42bd13b616a6114f5" dependencies = [ "arc-swap", "axum", @@ -1230,7 +1230,7 @@ dependencies = [ "mimalloc", "owo-colors", "parking_lot", - "pd-edge-abi 0.1.0", + "pd-edge-abi", "pd-edge-host-function", "pd-vm", "rand 0.8.5", @@ -1256,26 +1256,16 @@ dependencies = [ [[package]] name = "pd-edge-abi" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=6320847098530ab78b0d3cd438b714e699caa8db#6320847098530ab78b0d3cd438b714e699caa8db" +source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=5f4f889e349bdfbd5534deb42bd13b616a6114f5#5f4f889e349bdfbd5534deb42bd13b616a6114f5" dependencies = [ - "pd-host-function 0.1.0", - "syn", -] - -[[package]] -name = "pd-edge-abi" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9676d58588b220f7af69d7aa86108042d2acaf21dd24c641a6d9ef3c4e193ba" -dependencies = [ - "pd-host-function 0.22.7", + "pd-host-function", "syn", ] [[package]] name = "pd-edge-host-function" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=6320847098530ab78b0d3cd438b714e699caa8db#6320847098530ab78b0d3cd438b714e699caa8db" +source = "git+https://github.com/rustscript-lang/pd-edge.git?rev=5f4f889e349bdfbd5534deb42bd13b616a6114f5#5f4f889e349bdfbd5534deb42bd13b616a6114f5" dependencies = [ "proc-macro2", "quote", @@ -1293,17 +1283,6 @@ dependencies = [ "syn", ] -[[package]] -name = "pd-host-function" -version = "0.22.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c284ea36b653630c2ee4417c518cbd045bd11f564fb7ecc9f65d986118c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pd-host-schema" version = "0.1.0" @@ -1327,8 +1306,7 @@ dependencies = [ "futures-channel", "libc", "paste", - "pd-edge-abi 0.1.1", - "pd-host-function 0.1.0", + "pd-host-function", "pd-host-schema", "regex", "rt-format", diff --git a/Cargo.toml b/Cargo.toml index c9474b6..5c49cec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,10 +18,13 @@ 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", git = "https://github.com/rustscript-lang/pd-edge.git", rev = "6320847098530ab78b0d3cd438b714e699caa8db", 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"] } diff --git a/tests/dependency_pin_tests.rs b/tests/dependency_pin_tests.rs index 2fa517e..f00d7b9 100644 --- a/tests/dependency_pin_tests.rs +++ b/tests/dependency_pin_tests.rs @@ -6,10 +6,10 @@ use std::path::PathBuf; const PD_EDGE_GIT: &str = "https://github.com/rustscript-lang/pd-edge.git"; -const PD_EDGE_REV: &str = "6320847098530ab78b0d3cd438b714e699caa8db"; +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 = "6320847"; +const ABBREVIATED_EDGE_REV: &str = "5f4f889"; const ABBREVIATED_RUSTSCRIPT_REV: &str = "b1d6cff"; fn manifest() -> String { @@ -61,6 +61,10 @@ fn assert_full_sha_pin(dependency: &str, crate_name: &str, expected: &str, abbre !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)] @@ -183,21 +187,6 @@ fn lockfile_pins_pd_edge_and_rustscript_crates_per_package() { assert_lock_source(&packages, "pd-host-function", "0.1.0", &rustscript_source); assert_lock_source(&packages, "pd-host-schema", "0.1.0", &rustscript_source); - // Frozen pd-edge `http` enables `vm/edge-abi`, and frozen pd-vm's optional - // `edge-abi` feature still depends on the published ABI crate. Keep that - // crates.io line distinct from the git 0.1.0 catalog used by pd-edge. - assert_lock_source( - &packages, - "pd-edge-abi", - "0.1.1", - "registry+https://github.com/rust-lang/crates.io-index", - ); - assert_lock_source( - &packages, - "pd-host-function", - "0.22.7", - "registry+https://github.com/rust-lang/crates.io-index", - ); let registry_family: Vec<&LockPackage> = packages .iter() .filter(|package| { @@ -205,21 +194,14 @@ fn lockfile_pins_pd_edge_and_rustscript_crates_per_package() { .source .as_deref() .is_some_and(|source| source.starts_with("registry+")) - && matches!( - package.name.as_str(), - "pd-edge" - | "pd-edge-abi" - | "pd-edge-host-function" - | "pd-vm" - | "pd-host-function" - | "pd-host-schema" - ) + && (package.name.starts_with("pd-edge") + || package.name.starts_with("pd-host-") + || package.name.starts_with("pd-vm")) }) .collect(); - assert_eq!( - registry_family.len(), - 2, - "only frozen pd-vm edge-abi published crates may remain on crates.io, found {registry_family:?}" + assert!( + registry_family.is_empty(), + "lockfile must not resolve registry pd-edge*/pd-host-*/pd-vm* packages, found {registry_family:?}" ); for package in &packages { From b6939beb3c611a0526b522cc0306919b6d5cee9e Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 18 Sep 2026 03:07:55 +0800 Subject: [PATCH 4/6] test: close mqtt schema contract and webrtc default-off Require Some(schema) plus the ABI25 catalog fingerprint for HTTP/TCP/TLS/ WS/UDP/proxy imports. Gate MQTT on --features mqtt with Named MqttEvent fields, and fail closed when MQTT or WebRTC bind on the default catalog. --- tests/controller_tests/ui.rs | 30 +++- tests/edge_catalog_contract.rs | 268 ++++++++++++++++++++++++--------- 2 files changed, 229 insertions(+), 69 deletions(-) diff --git a/tests/controller_tests/ui.rs b/tests/controller_tests/ui.rs index 337636d..2969ff1 100644 --- a/tests/controller_tests/ui.rs +++ b/tests/controller_tests/ui.rs @@ -1142,8 +1142,34 @@ 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}" + ); + } + match edge::compile_edge_source_with_flavor( + "let rtc: int = vm::webrtc::connection::new();\n", + SourceFlavor::RustScript, + ) { + Err(_) => {} + Ok(compiled) => { + let webrtc_bound = compiled + .program + .imports + .iter() + .zip(compiled.program.host_import_schemas().iter()) + .any(|(import, schema)| import.name.contains("webrtc") && schema.is_some()); + panic!( + "default-off webrtc UI source must not compile or catalog-bind; bound={webrtc_bound}" + ); + } } let javascript = render_json["source"]["javascript"] diff --git a/tests/edge_catalog_contract.rs b/tests/edge_catalog_contract.rs index cfdf24d..89e4936 100644 --- a/tests/edge_catalog_contract.rs +++ b/tests/edge_catalog_contract.rs @@ -4,20 +4,35 @@ use std::{ }; use edge::{ABI_VERSION, HOST_FUNCTION_COUNT, compile_edge_source_with_flavor, function_by_name}; -use vm::{HostTypeSchema, SourceFlavor}; +#[cfg(feature = "mqtt")] +use vm::HostTypeSchema; +use vm::SourceFlavor; -/// Exact hex form of the default pd-edge compile catalog fingerprint. -/// Bump together with [`EDGE_CATALOG_FINGERPRINT_U64`] when the frozen edge -/// ABI surface or pd-vm fingerprint encoding changes. +/// 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"; const MQTT_READ_EVENT: &str = r#" use mqtt; let connection = mqtt::connection::new(); let event = mqtt::connection::read_event(connection); "#; +const WEBRTC_UI_SOURCE: &str = "let 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| { @@ -25,10 +40,53 @@ fn compile_rss(source: &str) -> vm::CompiledProgram { }) } +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 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 schema in schemas.iter().flatten() { + 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!( @@ -66,6 +124,52 @@ fn walk_rss_files(root: &Path, files: &mut Vec) { } } +#[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); @@ -91,16 +195,72 @@ fn edge_catalog_fingerprint_is_golden_and_stable() { 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()); + match compile_edge_source_with_flavor(MQTT_READ_EVENT, SourceFlavor::RustScript) { + Err(_) => {} + Ok(compiled) => { + let schemas = compiled.program.host_import_schemas(); + let mqtt_bound = compiled + .program + .imports + .iter() + .zip(schemas.iter()) + .any(|(import, schema)| import.name.starts_with("mqtt::") && schema.is_some()); + panic!( + "default mqtt-off catalog must not compile or catalog-bind MQTT; bound={mqtt_bound}; imports={:?}", + compiled + .program + .imports + .iter() + .map(|import| import.name.as_str()) + .collect::>() + ); + } + } +} + +#[test] +fn webrtc_ui_source_cannot_compile_or_catalog_bind_when_default_off() { + assert!(function_by_name("webrtc::connection::new").is_none()); + match compile_edge_source_with_flavor(WEBRTC_UI_SOURCE, SourceFlavor::RustScript) { + Err(_) => {} + Ok(compiled) => { + let schemas = compiled.program.host_import_schemas(); + let webrtc_bound = compiled + .program + .imports + .iter() + .zip(schemas.iter()) + .any(|(import, schema)| import.name.contains("webrtc") && schema.is_some()); + panic!( + "default-off webrtc UI source must not compile or catalog-bind through stale ABI24; bound={webrtc_bound}; imports={:?}", + compiled + .program + .imports + .iter() + .map(|import| import.name.as_str()) + .collect::>() + ); + } + } +} + +#[cfg(feature = "mqtt")] #[test] -fn named_mqtt_event_is_the_exact_catalog_binding_when_present() { +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!( - compiled - .program - .imports - .iter() - .any(|import| import.name == "mqtt::connection::read_event"), - "migrated edge compile catalog must bind mqtt::connection::read_event" + assert_exact_catalog_imports( + &compiled.program, + &["mqtt::connection::new", "mqtt::connection::read_event"], ); let schema = compiled @@ -108,35 +268,13 @@ fn named_mqtt_event_is_the_exact_catalog_binding_when_present() { .host_import_schemas() .iter() .flatten() - .find(|schema| schema.name == "mqtt::connection::read_event"); - - match (function_by_name("mqtt::connection::read_event"), schema) { - (Some(spec), Some(schema)) => { - assert_eq!(spec.name, "mqtt::connection::read_event"); - match &schema.return_type { - HostTypeSchema::Named { name, .. } => { - assert_eq!(name, "MqttEvent"); - } - other => panic!("expected Named MqttEvent, got {other:?}"), - } - assert_eq!( - format!("{}", schema.fingerprint), - EDGE_CATALOG_FINGERPRINT_HEX - ); - } - (None, None) => { - assert_eq!(ABI_VERSION, 25); - assert!( - function_by_name("http::response::set_body").is_some(), - "default controller catalog keeps HTTP hosts when MQTT stays ABI-gated" - ); - } - (abi, exact) => panic!( - "mqtt ABI publication and exact catalog schema must stay aligned; abi={:?} exact={}", - abi.map(|spec| spec.name), - exact.is_some() - ), - } + .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] @@ -150,33 +288,29 @@ fn checked_in_rss_and_controller_fixtures_compile_through_the_edge_catalog() { "pd-controller has no checked-in RSS fixtures; found {files:?}" ); - let fixtures = [ - HTTP_SHORT_CIRCUIT, - "use http;\nhttp::response::set_status(204);\n", - "use vm;\nvm::http::response::set_body(\"vm-ns\");\n", - "use tcp;\nlet stream = tcp::stream::new();\n", - "use tcp;\nuse tls;\nlet stream = tcp::stream::new();\nlet session = tls::session::from_socket(stream);\n", - "use websocket;\nlet ws = websocket::connection::new();\n", - "use udp;\nlet socket = udp::socket::new();\n", - "use proxy;\nlet downstream = proxy::stream::downstream();\n", - MQTT_READ_EVENT, + 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 in fixtures { + for (source, expected) in fixtures { let compiled = compile_rss(source); - assert!( - !compiled.program.host_import_schemas().is_empty() - || compiled - .program - .imports - .iter() - .any(|import| import.name.contains("http") - || import.name.contains("tcp") - || import.name.contains("tls") - || import.name.contains("websocket") - || import.name.contains("udp") - || import.name.contains("proxy") - || import.name.contains("response")), - "fixture must record host imports:\n{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"], ); } } From b884a4e1ce0a45d93e482174373346c5563e70a0 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 18 Sep 2026 03:49:18 +0800 Subject: [PATCH 5/6] test: accept unbound None schemas for default-off mqtt/webrtc Pinned pd-edge compiles omitted-protocol host calls without catalog backing. Assert Ok only when mqtt/webrtc imports stay None, keep a fail-closed Err path valid, and use the production `use vm;` WebRTC UI shape. Run Named MqttEvent tests in CI via --features mqtt. --- .github/workflows/ci.yml | 2 + tests/controller_tests/ui.rs | 75 +++++++++++++++++++----- tests/edge_catalog_contract.rs | 101 +++++++++++++++++++-------------- 3 files changed, 119 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 562d05f..50f0e8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Tests 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 }}) diff --git a/tests/controller_tests/ui.rs b/tests/controller_tests/ui.rs index 2969ff1..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; @@ -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!( @@ -1154,23 +1204,18 @@ async fn ui_render_extended_abi_blocks_generate_expected_calls() { "expected rustscript ABI render without webrtc to compile, got: {err}\nsource:\n{without_webrtc}" ); } - match edge::compile_edge_source_with_flavor( - "let rtc: int = vm::webrtc::connection::new();\n", - SourceFlavor::RustScript, - ) { - Err(_) => {} - Ok(compiled) => { - let webrtc_bound = compiled - .program - .imports - .iter() - .zip(compiled.program.host_import_schemas().iter()) - .any(|(import, schema)| import.name.contains("webrtc") && schema.is_some()); - panic!( - "default-off webrtc UI source must not compile or catalog-bind; bound={webrtc_bound}" - ); + 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() diff --git a/tests/edge_catalog_contract.rs b/tests/edge_catalog_contract.rs index 89e4936..39191a2 100644 --- a/tests/edge_catalog_contract.rs +++ b/tests/edge_catalog_contract.rs @@ -27,12 +27,16 @@ const TLS_FROM_SOCKET: &str = "use tcp;\nuse tls;\nlet stream = tcp::stream::new 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); "#; -const WEBRTC_UI_SOURCE: &str = "let rtc: int = vm::webrtc::connection::new();\n"; +#[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| { @@ -79,6 +83,55 @@ fn assert_exact_catalog_imports(program: &vm::Program, expected_names: &[&str]) } } +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()); @@ -200,53 +253,13 @@ fn edge_catalog_fingerprint_is_golden_and_stable() { 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()); - match compile_edge_source_with_flavor(MQTT_READ_EVENT, SourceFlavor::RustScript) { - Err(_) => {} - Ok(compiled) => { - let schemas = compiled.program.host_import_schemas(); - let mqtt_bound = compiled - .program - .imports - .iter() - .zip(schemas.iter()) - .any(|(import, schema)| import.name.starts_with("mqtt::") && schema.is_some()); - panic!( - "default mqtt-off catalog must not compile or catalog-bind MQTT; bound={mqtt_bound}; imports={:?}", - compiled - .program - .imports - .iter() - .map(|import| import.name.as_str()) - .collect::>() - ); - } - } + assert_default_off_protocol_compile(MQTT_UNBOUND_SOURCE, "mqtt"); } #[test] -fn webrtc_ui_source_cannot_compile_or_catalog_bind_when_default_off() { +fn webrtc_ui_source_is_unbound_when_default_off() { assert!(function_by_name("webrtc::connection::new").is_none()); - match compile_edge_source_with_flavor(WEBRTC_UI_SOURCE, SourceFlavor::RustScript) { - Err(_) => {} - Ok(compiled) => { - let schemas = compiled.program.host_import_schemas(); - let webrtc_bound = compiled - .program - .imports - .iter() - .zip(schemas.iter()) - .any(|(import, schema)| import.name.contains("webrtc") && schema.is_some()); - panic!( - "default-off webrtc UI source must not compile or catalog-bind through stale ABI24; bound={webrtc_bound}; imports={:?}", - compiled - .program - .imports - .iter() - .map(|import| import.name.as_str()) - .collect::>() - ); - } - } + assert_default_off_protocol_compile(WEBRTC_UI_SOURCE, "webrtc"); } #[cfg(feature = "mqtt")] From d5150673593bde04ac0eccb4efacf0595852bff0 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 18 Sep 2026 14:55:31 +0800 Subject: [PATCH 6/6] fix: satisfy rust 1.98 clippy on debug session sorting and match Use sort_by_key with Reverse for updated_unix_ms ordering, and collapse ClearLine breakpoint removal into a match guard. --- src/server.rs | 2 +- src/server/handlers.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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; } _ => {} }