|
1 | | -#![cfg(all( |
| 1 | +use std::any::Any; |
| 2 | +use std::collections::BTreeSet; |
| 3 | + |
| 4 | +#[cfg(all( |
| 5 | + feature = "runtime", |
2 | 6 | feature = "http-client", |
3 | | - feature = "sqlite", |
4 | 7 | not(target_family = "wasm") |
5 | 8 | ))] |
6 | | - |
7 | | -use std::collections::BTreeSet; |
8 | | - |
9 | | -use vm::{ |
10 | | - HostApiCatalog, HostStructField, HostTypeSchema, http_host_catalog, jit_host_catalog, |
11 | | - sqlite_host_catalog, standard_host_catalog, |
12 | | -}; |
| 9 | +use vm::http_host_catalog; |
| 10 | +#[cfg(all(feature = "runtime", not(target_arch = "wasm32")))] |
| 11 | +use vm::sqlite_host_catalog; |
| 12 | +#[cfg(feature = "runtime")] |
| 13 | +use vm::{HostApiCatalog, jit_host_catalog, standard_host_catalog}; |
| 14 | +use vm::{HostStructField, HostTypeSchema}; |
13 | 15 |
|
14 | 16 | fn assert_no_public_dynamic_root(path: &str, schema: &HostTypeSchema) { |
15 | 17 | fn visit(path: &str, schema: &HostTypeSchema, seen: &mut BTreeSet<String>) { |
16 | 18 | match schema { |
17 | 19 | HostTypeSchema::Map(_) | HostTypeSchema::Unknown => { |
18 | 20 | panic!("public host schema {path} exposes {schema:?}") |
19 | 21 | } |
20 | | - HostTypeSchema::Array(inner) | HostTypeSchema::Optional(inner) => { |
21 | | - visit(path, inner, seen) |
22 | | - } |
| 22 | + HostTypeSchema::Array(inner) => visit(&format!("{path}[]"), inner, seen), |
| 23 | + HostTypeSchema::Optional(inner) => visit(&format!("{path}?"), inner, seen), |
23 | 24 | HostTypeSchema::Named { name, fields } => { |
24 | 25 | if !seen.insert(name.clone()) { |
25 | 26 | return; |
@@ -48,6 +49,88 @@ fn assert_no_public_dynamic_root(path: &str, schema: &HostTypeSchema) { |
48 | 49 | visit(path, schema, &mut BTreeSet::new()); |
49 | 50 | } |
50 | 51 |
|
| 52 | +fn panic_text(payload: Box<dyn Any + Send>) -> String { |
| 53 | + if let Some(message) = payload.downcast_ref::<String>() { |
| 54 | + return message.clone(); |
| 55 | + } |
| 56 | + if let Some(message) = payload.downcast_ref::<&str>() { |
| 57 | + return (*message).to_string(); |
| 58 | + } |
| 59 | + "non-string panic payload".to_string() |
| 60 | +} |
| 61 | + |
| 62 | +fn assert_rejects_dynamic_schema( |
| 63 | + path: &str, |
| 64 | + schema: HostTypeSchema, |
| 65 | + expected_path: &str, |
| 66 | + expected_kind: &str, |
| 67 | +) { |
| 68 | + let payload = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { |
| 69 | + assert_no_public_dynamic_root(path, &schema); |
| 70 | + })) |
| 71 | + .expect_err("dynamic schema must be rejected"); |
| 72 | + let message = panic_text(payload); |
| 73 | + assert!( |
| 74 | + message.contains(expected_path), |
| 75 | + "diagnostic should identify {expected_path}, got {message}" |
| 76 | + ); |
| 77 | + assert!( |
| 78 | + message.contains(expected_kind), |
| 79 | + "diagnostic should identify {expected_kind}, got {message}" |
| 80 | + ); |
| 81 | +} |
| 82 | + |
| 83 | +type SchemaWrapper = fn(HostTypeSchema) -> HostTypeSchema; |
| 84 | + |
| 85 | +fn array_of(inner: HostTypeSchema) -> HostTypeSchema { |
| 86 | + HostTypeSchema::Array(Box::new(inner)) |
| 87 | +} |
| 88 | + |
| 89 | +fn optional_of(inner: HostTypeSchema) -> HostTypeSchema { |
| 90 | + HostTypeSchema::Optional(Box::new(inner)) |
| 91 | +} |
| 92 | + |
| 93 | +fn named_field_of(inner: HostTypeSchema) -> HostTypeSchema { |
| 94 | + HostTypeSchema::named_struct("Envelope", vec![HostStructField::new("payload", inner)]) |
| 95 | +} |
| 96 | + |
| 97 | +fn callable_param_of(inner: HostTypeSchema) -> HostTypeSchema { |
| 98 | + HostTypeSchema::Callable { |
| 99 | + params: vec![inner], |
| 100 | + result: Box::new(HostTypeSchema::Int), |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +fn callable_result_of(inner: HostTypeSchema) -> HostTypeSchema { |
| 105 | + HostTypeSchema::Callable { |
| 106 | + params: vec![HostTypeSchema::Int], |
| 107 | + result: Box::new(inner), |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +const NESTED_DYNAMIC_CASES: &[(&str, SchemaWrapper, &str)] = &[ |
| 112 | + ("array", array_of, "[]"), |
| 113 | + ("optional", optional_of, "?"), |
| 114 | + ("named field", named_field_of, ".Envelope.payload"), |
| 115 | + ("callable param", callable_param_of, ".callback_param[0]"), |
| 116 | + ("callable result", callable_result_of, ".callback_result"), |
| 117 | +]; |
| 118 | + |
| 119 | +#[test] |
| 120 | +fn recursive_walker_rejects_nested_dynamic_schemas_with_paths() { |
| 121 | + for (kind, dynamic) in [ |
| 122 | + ("Map", HostTypeSchema::Map(Box::new(HostTypeSchema::Int))), |
| 123 | + ("Unknown", HostTypeSchema::Unknown), |
| 124 | + ] { |
| 125 | + for (case, wrap, suffix) in NESTED_DYNAMIC_CASES { |
| 126 | + let root = format!("nested::{kind}::{case}"); |
| 127 | + let expected_path = format!("{root}{suffix}"); |
| 128 | + assert_rejects_dynamic_schema(&root, wrap(dynamic.clone()), &expected_path, kind); |
| 129 | + } |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +#[cfg(feature = "runtime")] |
51 | 134 | fn assert_no_public_dynamic_schema(catalog_name: &str, catalog: &HostApiCatalog) { |
52 | 135 | for schema in catalog.structs() { |
53 | 136 | for field in &schema.fields { |
@@ -92,10 +175,51 @@ fn recursive_named_struct_walk_stops_at_repeated_named_type() { |
92 | 175 | assert_no_public_dynamic_root("recursive::node", &recursive_named_schema()); |
93 | 176 | } |
94 | 177 |
|
| 178 | +#[cfg(feature = "runtime")] |
95 | 179 | #[test] |
96 | 180 | fn affected_public_host_catalogs_have_no_reachable_map_or_unknown() { |
97 | | - assert_no_public_dynamic_schema("http", &http_host_catalog()); |
98 | | - assert_no_public_dynamic_schema("sqlite", &sqlite_host_catalog()); |
99 | 181 | assert_no_public_dynamic_schema("jit", &jit_host_catalog()); |
100 | 182 | assert_no_public_dynamic_schema("standard", &standard_host_catalog()); |
| 183 | + #[cfg(all(feature = "http-client", not(target_family = "wasm")))] |
| 184 | + assert_no_public_dynamic_schema("http", &http_host_catalog()); |
| 185 | + #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] |
| 186 | + assert_no_public_dynamic_schema("sqlite", &sqlite_host_catalog()); |
| 187 | +} |
| 188 | + |
| 189 | +#[cfg(all(feature = "runtime", not(feature = "sqlite")))] |
| 190 | +#[test] |
| 191 | +fn standard_catalog_keeps_sqlite_editor_schema_without_sqlite_runtime() { |
| 192 | + let sqlite = sqlite_host_catalog(); |
| 193 | + assert!( |
| 194 | + sqlite.function("sqlite::query").is_some(), |
| 195 | + "the standalone editor/compiler catalog must retain SQLite declarations" |
| 196 | + ); |
| 197 | + let query = sqlite |
| 198 | + .function("sqlite::query") |
| 199 | + .expect("SQLite query declaration"); |
| 200 | + let value = sqlite |
| 201 | + .struct_named("SqliteValue") |
| 202 | + .expect("SQLite value named struct"); |
| 203 | + assert_eq!( |
| 204 | + query.params[2].ty, |
| 205 | + HostTypeSchema::Array(Box::new(value.as_type())), |
| 206 | + "SQLite query params must stay typed without the runtime feature" |
| 207 | + ); |
| 208 | + assert_no_public_dynamic_schema("sqlite", &sqlite); |
| 209 | + |
| 210 | + let catalog = standard_host_catalog(); |
| 211 | + assert!( |
| 212 | + catalog.function("sqlite::open").is_some(), |
| 213 | + "the editor/compiler catalog must retain SQLite schema declarations" |
| 214 | + ); |
| 215 | + assert!( |
| 216 | + catalog.struct_named("SqliteOpenOptions").is_some(), |
| 217 | + "the editor/compiler catalog must retain SQLite named structs" |
| 218 | + ); |
| 219 | + assert!( |
| 220 | + vm::default_host_callables() |
| 221 | + .iter() |
| 222 | + .all(|callable| !callable.name.starts_with("sqlite::")), |
| 223 | + "the executable default host surface must remain feature-gated" |
| 224 | + ); |
101 | 225 | } |
0 commit comments