diff --git a/crates/app-hub/src/client.rs b/crates/app-hub/src/client.rs index 499dbaf..149bba0 100644 --- a/crates/app-hub/src/client.rs +++ b/crates/app-hub/src/client.rs @@ -465,13 +465,13 @@ impl Store { // And against the key the catalog records, whatever the caller passed. let staged_json = serde_json::to_string(&staged_manifest).map_err(|e| e.to_string())?; let policy = octosense_app_policy::admit_and_resolve_dir(&staged_json, &digest, &self.limits, &self.publisher_keys())?; + entry.manifest.check_host_apis(&self.host_api_versions)?; // Its shared components resolve from this catalog and are already // in the store, verified: the installer keeps them first // ([`Store::install_components`]), so an app never lands without them. self.verified_components(&entry.manifest)?; let target = self.install_dir(app_id); - entry.manifest.check_host_apis(&self.host_api_versions)?; if target.exists() { std::fs::remove_dir_all(&target).map_err(|e| format!("cannot replace the installed app: {e}"))?; } @@ -513,7 +513,7 @@ impl Store { /// Resolve `manifest`'s components against the verified catalog: each /// must be there at its exact version, offered, with the pinned digest, - /// and the app must grant what it imports. + /// and the app must declare each imported host ABI. pub fn resolve_components(&self, manifest: &octosense_app_policy::AppManifest) -> Result, String> { if manifest.components.is_empty() { return Ok(Vec::new()); @@ -524,7 +524,7 @@ impl Store { .iter() .map(|dependency| { let entry = crate::components::resolve(catalog, dependency)?; - if let Some(missing) = crate::components::missing_grant(manifest, &crate::components::info_of(&entry.component)) { + if let Some(missing) = crate::components::info_of(&entry.component).missing_host_api(manifest) { return Err(format!("component {} {missing}", dependency.describe())); } Ok(entry) @@ -543,6 +543,9 @@ impl Store { manifest: &octosense_app_policy::AppManifest, fetch: &mut dyn FnMut(&ComponentEntry) -> Result, String>, ) -> Result, String> { + // Check before downloading: a catalog can contain apps requiring a + // newer network ABI while remaining readable by older hosts. + manifest.check_host_apis(&self.host_api_versions)?; for entry in self.resolve_components(manifest)? { let digest = &entry.component.wasm_blake3; if crate::components::verify_file(&crate::components::stored_path(&self.components_root, digest), digest).is_ok() { diff --git a/crates/app-hub/src/functions.rs b/crates/app-hub/src/functions.rs index 0fa6a0b..7baa671 100644 --- a/crates/app-hub/src/functions.rs +++ b/crates/app-hub/src/functions.rs @@ -5,8 +5,8 @@ //! and exports when it loads it. A component is validated here, and its //! imports must all come from the WASI packages a host scopes to the app //! ([`ALLOWED_COMPONENT_IMPORTS`]). Whether the manifest may carry one -//! (`requires: ["wasm-components-v1"]`, `storage` for files, `net` for -//! HTTP) is the gate's check ([`crate::gate`]), which +//! (`requires: ["wasm-components-v1"]`, plus `wasm.sockets@1` for sockets) +//! is the gate's check ([`crate::gate`]), which //! also tells reviewers what each component reaches //! ([`ComponentInfo::reach`]). //! @@ -19,15 +19,17 @@ use wasmparser::component_types::{ComponentDefinedType, ComponentEntityType, Com use wasmparser::types::TypesRef; use wasmparser::{ComponentTypeRef, Parser, Payload, PrimitiveValType, TypeBounds, Validator, WasmFeatures}; -/// The packages a component may import, every interface of these. The host -/// gives `wasi:filesystem` only the app's storage folder and -/// `octosense:host` only the host services the app is granted, as its -/// script has them (OctoSense ADR 0014 phase 3). `wasi:http` reaches any -/// host: an app's network declarations are shown at install and not -/// enforced (OctoSense's ruling of 8 October 2026). Nothing else -/// (`wasi:sockets`, other packages) is admitted. +/// The packages a component may import. Files stay in the app's jail; +/// host-service calls retain consent and account/sharing checks. HTTP and +/// ordinary TCP/UDP/DNS use the host's network under its resource limits. +/// Network declarations describe usage; they are not destination allowlists. pub const ALLOWED_COMPONENT_IMPORTS: &[&str] = - &["wasi:cli/", "wasi:clocks/", "wasi:filesystem/", "wasi:http/", "wasi:io/", "wasi:random/", "octosense:host/"]; + &["wasi:cli/", "wasi:clocks/", "wasi:filesystem/", "wasi:http/", "wasi:io/", "wasi:random/", "wasi:sockets/", "octosense:host/"]; + +/// An extensible host API requirement, not a new manifest `requires` feature: +/// older catalogs must remain readable even when an app needs sockets. +pub const SOCKETS_HOST_API: &str = "wasm.sockets"; +pub const SOCKETS_HOST_API_VERSION: u32 = 1; /// The allowed packages in words: "wasi:cli, wasi:clocks, … and /// octosense:host". @@ -147,8 +149,8 @@ impl ComponentInfo { .collect() } - /// Whether it imports `wasi:filesystem`: the app's own files, which the - /// host gives it only with the `storage` capability. + /// Whether it imports `wasi:filesystem`: the app's own jailed files; + /// `storage` describes this usage. pub fn uses_files(&self) -> bool { self.imports.iter().any(|name| name.starts_with("wasi:filesystem/")) } @@ -159,8 +161,26 @@ impl ComponentInfo { self.imports.iter().any(|name| name.starts_with("wasi:http/")) } - /// Whether it imports `octosense:host`: the host services its app is - /// granted, which it calls as the app's script does. + /// Whether it imports ordinary WASI TCP/UDP/DNS interfaces. Even a + /// network/types-only import requires the sockets host ABI. + pub fn uses_sockets(&self) -> bool { + self.imports.iter().any(|name| name.starts_with("wasi:sockets/")) + } + + /// Socket imports require an explicit host ABI so older hosts can read + /// the catalog, but refuse this app before installing or running it. + pub fn missing_host_api(&self, manifest: &octosense_app_policy::AppManifest) -> Option<&'static str> { + if self.uses_sockets() + && manifest.host_api.as_ref().and_then(|api| api.required.get(SOCKETS_HOST_API)) != Some(&SOCKETS_HOST_API_VERSION) + { + Some("requires host_api.required wasm.sockets@1 (and requires host-api-v1)") + } else { + None + } + } + + /// Whether it imports `octosense:host`: public host services called with + /// the same app identity and actual authorization as its script. pub fn uses_host_services(&self) -> bool { self.imports.iter().any(|name| name.starts_with("octosense:host/")) } @@ -179,7 +199,7 @@ impl ComponentInfo { if self.uses_files() { reaches.push("files in its app folder".to_string()); } - let network = self.uses_http(); + let network = self.uses_http() || self.uses_sockets(); if network { reaches.push("the network".to_string()); } @@ -443,6 +463,18 @@ mod tests { ComponentInfo { imports: imports.iter().map(|i| i.to_string()).collect(), exports: Vec::new(), crates: None } } + #[test] + fn tcp_udp_and_dns_imports_are_allowed_and_disclosed_as_network() { + for interface in ["tcp", "tcp-create-socket", "udp", "udp-create-socket", "ip-name-lookup", "network", "instance-network"] { + let name = format!("wasi:sockets/{interface}@0.2.9"); + let component = info(&[&name]); + assert!(component.refused_imports().is_empty(), "{interface}"); + assert!(component.uses_sockets(), "{interface}"); + assert_eq!(component.reach(), "the network, but no files or other app"); + } + assert_eq!(info(&["wasi:sockets-extra/network@0.2.9"]).refused_imports(), ["wasi:sockets-extra/network@0.2.9"]); + } + #[test] fn the_reach_line_names_what_a_component_imports() { assert_eq!(info(&["wasi:cli/stdout@0.2.9"]).reach(), "nothing but its input"); @@ -473,11 +505,11 @@ mod tests { #[test] fn only_the_scoped_wasi_packages_are_allowed() { let component = info(&["wasi:io/poll@0.2.9", "wasi:sockets/network@0.2.9", "wasi:http/types@0.2.9", "my:pkg/host", "octosense:host/services@0.1.0", "octosense:hostile/x"]); - assert_eq!(component.refused_imports(), ["wasi:sockets/network@0.2.9", "my:pkg/host", "octosense:hostile/x"]); + assert_eq!(component.refused_imports(), ["my:pkg/host", "octosense:hostile/x"]); assert!(component.uses_http()); assert_eq!( allowed_import_packages(), - "wasi:cli, wasi:clocks, wasi:filesystem, wasi:http, wasi:io, wasi:random and octosense:host" + "wasi:cli, wasi:clocks, wasi:filesystem, wasi:http, wasi:io, wasi:random, wasi:sockets and octosense:host" ); // A package name is matched whole: wasi:clocksmith is not wasi:clocks. assert_eq!(info(&["wasi:clocksmith/x"]).refused_imports(), ["wasi:clocksmith/x"]); diff --git a/crates/app-hub/src/gate.rs b/crates/app-hub/src/gate.rs index dbbfd91..27e4f64 100644 --- a/crates/app-hub/src/gate.rs +++ b/crates/app-hub/src/gate.rs @@ -252,12 +252,9 @@ fn check_bundle_for( if declares_wasm && modules == 0 && manifest.components.is_empty() { findings.push(Finding::warn("functions", "the bundle declares the wasm capability but carries no fns/*.wasm")); } - // A component (OctoSense ADR 0014) also reaches the clock, random numbers - // and, with `storage`, the app's own files. Admission has validated it - // and its imports; the manifest must require the feature, so a host whose - // `wasm` service loads only modules refuses the app instead of failing at - // its first call, and must grant storage for files. Reviewers see what - // each component reaches. + // A component also reaches supported WASI interfaces. Admission validated + // its imports; required ABIs keep unsupported hosts from installing it. + // Reviewers see its reach even when usage disclosures are omitted. let requires_components = manifest.requires.iter().any(|f| f == crate::functions::COMPONENTS_FEATURE); let declares_storage = manifest.capabilities.iter().any(|c| c == "storage"); let declares_net = manifest.capabilities.iter().any(|c| c == "net"); @@ -281,6 +278,12 @@ fn check_bundle_for( format!("{name} is a WebAssembly component; the manifest must require {}", crate::functions::COMPONENTS_FEATURE), )); } + if let Some(missing) = info.missing_host_api(&manifest) { + findings.push(Finding::refuse("functions", format!("{name} {missing}"))); + } + if info.uses_sockets() && !declares_net { + findings.push(Finding::warn("functions", format!("{name} imports wasi:sockets; disclose net usage"))); + } if info.uses_files() && !declares_storage { findings.push(Finding::warn( "functions", @@ -315,7 +318,7 @@ fn check_bundle_for( // Each component the manifest names must resolve: from the catalog for a // store app (the exact version, offered, hashing to the pinned digest), // from the bundle's components/.wasm for a system app. The app - // must grant what each imports, as for its own components, and + // must require each imported ABI, as for its own components, and // reviewers see what each reaches. shared_component_findings(bundle, &manifest, &bundled_components, previous, system_development, &mut findings); @@ -446,7 +449,7 @@ fn check_bundle_for( /// The `components` findings for an app's shared components (App Hub ADR /// 0003): each resolved from `previous` (a store app) or from the bundle's -/// `components/` (a system app), held to the app's grants, and told to +/// `components/` (a system app), checked against required ABIs, and told to /// reviewers. fn shared_component_findings( bundle: &Path, @@ -512,9 +515,12 @@ fn shared_component_findings( )); continue; }; - if let Some(missing) = crate::components::missing_grant(manifest, &info) { + if let Some(missing) = info.missing_host_api(manifest) { findings.push(Finding::refuse("components", format!("component {} {missing}", dependency.describe()))); } + if info.uses_sockets() && !manifest.capabilities.iter().any(|capability| capability == "net") { + findings.push(Finding::warn("components", format!("component {} imports wasi:sockets; disclose net usage", dependency.describe()))); + } findings.push(Finding::warn("components", format!("component {} reaches {}", dependency.describe(), info.reach()))); } } diff --git a/crates/app-hub/tests/bundle_admission.rs b/crates/app-hub/tests/bundle_admission.rs index 6e32203..985edb3 100644 --- a/crates/app-hub/tests/bundle_admission.rs +++ b/crates/app-hub/tests/bundle_admission.rs @@ -551,18 +551,36 @@ fn a_component_may_call_its_apps_host_services_with_no_grant_of_its_own() { } #[test] -fn a_component_that_imports_sockets_or_does_not_validate_is_refused() { +fn socket_components_require_the_host_abi_but_not_network_disclosures() { let report = with_component(true, &["wasm", "storage"], NETPROBE); - let finding = refusal(&report, "contents-invalid").unwrap_or_else(|| panic!("{}", report.render())); - assert!(finding.detail.contains("imports wasi:sockets/"), "{}", finding.detail); - assert_eq!(finding.path.as_deref(), Some("fns/notes.wasm")); - // No reach line claims "no network" for it, and it still counts as a - // component. - assert!( - !report.findings.iter().any(|f| f.detail.contains("is a component that reaches") || f.detail.contains("holds no component")), - "{}", - report.render() - ); + let finding = refusal(&report, "functions").unwrap_or_else(|| panic!("{}", report.render())); + assert!(finding.detail.contains("requires host_api.required wasm.sockets@1"), "{}", finding.detail); + assert!(refusal(&report, "contents-invalid").is_none(), "{}", report.render()); + + let mut f = Fixture::new(); + fs::create_dir_all(f.bundle.join("fns")).unwrap(); + fs::write(f.bundle.join("fns/network.wasm"), NETPROBE).unwrap(); + f.manifest.requires.push("wasm-components-v1".into()); + f.socket_api(); + let report = f.report(None); + assert!(report.passed(), "{}", report.render()); + assert!(report.findings.iter().any(|f| f.detail.contains("imports wasi:sockets; disclose net usage"))); + assert!(report.findings.iter().any(|f| f.detail.contains("is a component that reaches") && f.detail.contains("the network"))); + assert!(f.manifest.capabilities.is_empty(), "usage declarations are not grants"); + + // Optional or another ABI version cannot disguise a required socket import. + f.manifest.host_api.as_mut().unwrap().required.insert("wasm.sockets".into(), 2); + f.sign(); + assert!(!f.report(None).passed()); + let api = f.manifest.host_api.as_mut().unwrap(); + api.required.clear(); + api.optional.insert("wasm.sockets".into(), 1); + f.sign(); + assert!(!f.report(None).passed()); +} + +#[test] +fn a_component_that_does_not_validate_is_refused() { let truncated = &NOTES[..NOTES.len() / 2]; let report = with_component(true, &["wasm", "storage"], truncated); let finding = refusal(&report, "contents-invalid").unwrap_or_else(|| panic!("{}", report.render())); diff --git a/crates/app-hub/tests/common/mod.rs b/crates/app-hub/tests/common/mod.rs index f0458ee..5a5e9d5 100644 --- a/crates/app-hub/tests/common/mod.rs +++ b/crates/app-hub/tests/common/mod.rs @@ -36,6 +36,15 @@ impl Fixture { fixture } + /// The extensible socket ABI requirement: old hosts still read the catalog. + pub fn socket_api(&mut self) { + if !self.manifest.requires.iter().any(|f| f == "host-api-v1") { + self.manifest.requires.push("host-api-v1".into()); + } + self.manifest.host_api.get_or_insert_with(Default::default).required.insert("wasm.sockets".into(), 1); + self.sign(); + } + /// The same app as an L0 card (`page.card`, its data and its kit). pub fn card(&mut self) { fs::remove_file(self.bundle.join("main.splash")).unwrap(); diff --git a/crates/app-hub/tests/component_store.rs b/crates/app-hub/tests/component_store.rs index 382e6fa..5637560 100644 --- a/crates/app-hub/tests/component_store.rs +++ b/crates/app-hub/tests/component_store.rs @@ -258,3 +258,48 @@ fn a_system_app_carries_its_components_in_its_bundle() { format!("component markdown (org.example.markdown 1.0.0) is missing from os.notesdemo's bundle (components/{}.wasm)", blake3_hex(NOTES)) ); } + +#[test] +fn socket_apps_keep_catalog_readable_but_need_a_compatible_host_before_download() { + let mut f = Fixture::new(); + let sockets = component_entry("org.example.netprobe", "1.0.0", NETPROBE); + let mut device = Device::new(&f); + let component_catalog = device.hub.catalog(1, vec![], vec![sockets.clone()]); + f.pin(&[dependency("network", &sockets)], &[], &[]); + f.socket_api(); + let entry = f.entry_with(Some(&component_catalog)); + // These are only RC5-known manifest features. Host API names are an + // extensible map, so a missing implementation refuses this app, not the + // signed catalog and its unrelated apps. + assert!(entry.manifest.requires.iter().all(|feature| ["host-api-v1", "wasm-shared-components-v1"].contains(&feature.as_str()))); + device.offer(1, vec![entry.clone()], vec![sockets.clone()]); + assert!(device.store.resolve_components(&entry.manifest).is_ok()); + let err = device.store.install_components(&entry.manifest, &mut |_| panic!("unsupported hosts must not download")).unwrap_err(); + assert_eq!(err, "this host does not implement required APIs: wasm.sockets@1"); + assert_eq!(device.store.install_staged("example-app", &f.bundle, &device.store.publisher_keys(), &today()).unwrap_err(), err); + assert!(stored_files(&device.root).is_empty()); + + device.store = device.store.with_host_api_versions(std::collections::BTreeMap::from([ + ("app_policy.device_consent".into(), 1), ("wasm.sockets".into(), 1), + ])); + device.store.install_components(&entry.manifest, &mut |_| Ok(NETPROBE.to_vec())).unwrap(); + device.store.install_staged("example-app", &f.bundle, &device.store.publisher_keys(), &today()).unwrap(); + assert_eq!(device.store.resolved_components("example-app").unwrap().len(), 1); + device.store.may_run("example-app").unwrap(); + // Availability is checked again at launch, independently of installation. + device.store = device.store.with_host_api_versions(std::collections::BTreeMap::new()); + assert_eq!(device.store.may_run("example-app").unwrap_err(), err); +} + +#[test] +fn shared_socket_imports_cannot_skip_the_app_requirement_even_if_already_cached() { + let mut f = Fixture::new(); + let sockets = component_entry("org.example.netprobe", "1.0.0", NETPROBE); + let mut device = Device::new(&f); + device.offer(1, vec![], vec![sockets.clone()]); + f.pin(&[dependency("network", &sockets)], &[], &[]); + components::keep(&device.root, &blake3_hex(NETPROBE), NETPROBE).unwrap(); + let err = device.store.resolve_components(&f.manifest).unwrap_err(); + assert!(err.contains("requires host_api.required wasm.sockets@1"), "{err}"); + assert_eq!(device.store.install_components(&f.manifest, &mut |_| panic!("no download on missing ABI")).unwrap_err(), err); +} diff --git a/crates/app-hub/tests/shared_components.rs b/crates/app-hub/tests/shared_components.rs index ea63912..a89c856 100644 --- a/crates/app-hub/tests/shared_components.rs +++ b/crates/app-hub/tests/shared_components.rs @@ -73,14 +73,10 @@ fn the_gate_refuses_a_file_that_is_not_the_release() { edited.component.imports.pop(); let report = check_component(&edited, NOTES, false, None).unwrap(); assert_eq!(refusals(&report.findings, "component"), ["the release's imports or exports are not the file's; prepare the release again from this file"]); - // A truncated file, and one that imports sockets. + // A truncated file. let truncated = &NOTES[..NOTES.len() / 2]; let report = check_component(¬es, truncated, false, None).unwrap(); assert!(refusals(&report.findings, "contents-invalid")[0].contains("not a valid WebAssembly component"), "{}", report.render()); - let draft: components::ComponentDraft = serde_json::from_value(draft_json("org.example.netprobe", "1.0.0")).unwrap(); - let err = components::ComponentRelease::from_draft(draft, NETPROBE).unwrap_err(); - assert!(err.starts_with("the component imports wasi:sockets/"), "{err}"); - assert!(err.ends_with("may import only wasi:cli, wasi:clocks, wasi:filesystem, wasi:http, wasi:io, wasi:random and octosense:host"), "{err}"); // A core module is no component. let module: &[u8] = b"\0asm\x01\0\0\0"; let draft: components::ComponentDraft = serde_json::from_value(draft_json("org.example.m", "1.0.0")).unwrap(); @@ -369,3 +365,39 @@ fn a_signed_catalog_records_components_beside_apps_and_refuses_contradictions() assert!(verify_catalog(&catalog, &hub.anchor.public_hex()).is_err()); let _ = fs::remove_dir_all(root); } + +#[test] +fn a_shared_socket_component_propagates_its_abi_to_the_app() { + let hub = Hub::new(); + let sockets = component_entry("org.example.netprobe", "1.0.0", NETPROBE); + let catalog = resolving(&hub, vec![sockets.clone()]); + let mut f = Fixture::new(); + f.pin(&[dependency("network", &sockets)], &[], &[]); + let report = f.report(Some(&catalog)); + assert!(refusals(&report.findings, "components").iter().any(|detail| detail.contains("requires host_api.required wasm.sockets@1")), "{}", report.render()); + f.socket_api(); + let report = f.report(Some(&catalog)); + assert!(report.passed(), "{}", report.render()); + assert!(warnings(&report.findings, "components").iter().any(|detail| detail.contains("imports wasi:sockets; disclose net usage"))); + assert!(warnings(&report.findings, "components").iter().any(|detail| detail.contains("reaches") && detail.contains("the network"))); +} + +#[test] +fn a_system_bundle_socket_component_requires_the_same_host_abi() { + let sockets = component_entry("org.example.netprobe", "1.0.0", NETPROBE); + let mut f = Fixture::new(); + f.manifest.id = "os.networkdemo".into(); + bundled_file(&f, NETPROBE); + f.pin(&[dependency("network", &sockets)], &[], &[]); + let system = |f: &mut Fixture| { + f.manifest.integrity.signature = None; + f.manifest.integrity.bundle_blake3 = octosense_app_policy::digest_dir(&f.bundle).unwrap(); + f.write_manifest(); + gate::check_system_bundle(&f.bundle, &RefuseAllSignatures).unwrap() + }; + let report = system(&mut f); + assert!(refusals(&report.findings, "components").iter().any(|detail| detail.contains("requires host_api.required wasm.sockets@1")), "{}", report.render()); + f.socket_api(); + let report = system(&mut f); + assert!(report.passed(), "{}", report.render()); +} diff --git a/crates/appstore/src/services.rs b/crates/appstore/src/services.rs index fb1fad9..bbc438b 100644 --- a/crates/appstore/src/services.rs +++ b/crates/appstore/src/services.rs @@ -46,7 +46,7 @@ use makepad_widgets::{Cx, SplashRef}; use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; pub use octosense_app_policy::contract::host_api::{AgentAccess, HostApiMethod}; @@ -96,6 +96,8 @@ impl ServiceCall { pub struct Replier { heap_key: usize, req_id: u64, + /// A request's identity outlives reuse of either its heap or request id. + generation: Arc<()>, } /// An answer waiting for its isolate: heap, request, result, and when it @@ -106,6 +108,7 @@ static REPLIES: Mutex> = Mutex::new(Vec::new()); /// A request its service has not answered yet. struct Pending { + generation: Arc<()>, app_id: String, timeout: Duration, /// None while a sheet the service raised for it is up. @@ -139,12 +142,13 @@ impl Replier { /// after the owning isolate closed. It is not permission to perform a write. pub fn is_pending(&self) -> bool { with_pending(|pending| pending.get(&(self.heap_key, self.req_id)) - .is_some_and(|entry| entry.deadline.is_none_or(|deadline| deadline > Instant::now()))) + .is_some_and(|entry| Arc::ptr_eq(&entry.generation, &self.generation) + && entry.deadline.is_none_or(|deadline| deadline > Instant::now()))) } /// Answer the request. An answer after the request timed out, or after /// its isolate closed, or a second answer, goes nowhere. pub fn send(self, result: Result) { - if with_pending(|pending| pending.remove(&(self.heap_key, self.req_id))).is_none() { + if !self.is_pending() { return; } let result = result.map(|value| value.to_string()).and_then(|json| { @@ -154,41 +158,49 @@ impl Replier { Ok(json) } }); - queue_reply(self.heap_key, self.req_id, result); + with_pending(|pending| { + let key = (self.heap_key, self.req_id); + if !pending.get(&key).is_some_and(|entry| Arc::ptr_eq(&entry.generation, &self.generation) + && entry.deadline.is_none_or(|deadline| deadline > Instant::now())) { + return; + } + pending.remove(&key); + // Queue under the same lock as completion. Otherwise cancel_heap + // can clear both queues between removal and this late answer. + queue_reply(self.heap_key, self.req_id, result); + }); } } /// Start waiting for one request's answer, or refuse it when too many wait. -fn track(heap_key: usize, req_id: u64, app_id: &str, timeout: Duration) -> Result<(), String> { +fn track(heap_key: usize, req_id: u64, app_id: &str, timeout: Duration) -> Result { start_sweeper(); with_pending(|pending| { let mine = pending.keys().filter(|(heap, _)| *heap == heap_key).count(); if mine >= MAX_PENDING_PER_ISOLATE || pending.len() >= MAX_PENDING { return Err("too many host requests are waiting; try again when some have answered".into()); } + let generation = Arc::new(()); let deadline = Some(Instant::now() + timeout); - pending.insert((heap_key, req_id), Pending { app_id: app_id.to_string(), timeout, deadline }); - Ok(()) + pending.insert((heap_key, req_id), Pending { generation: generation.clone(), app_id: app_id.to_string(), timeout, deadline }); + Ok(Replier { heap_key, req_id, generation }) }) } /// Answer, with a timeout, every request of the matching isolates whose /// deadline passed by `now`. fn expire_pending(now: Instant, heaps: impl Fn(usize) -> bool) { - let expired: Vec<(usize, u64)> = with_pending(|pending| { + with_pending(|pending| { let keys: Vec<_> = pending .iter() .filter(|((heap, _), entry)| heaps(*heap) && entry.deadline.is_some_and(|deadline| deadline <= now)) .map(|(key, _)| *key) .collect(); - for key in &keys { - pending.remove(key); + for (heap_key, req_id) in keys { + pending.remove(&(heap_key, req_id)); + queue_reply(heap_key, req_id, Err("the host service timed out".into())); } - keys }); - for (heap_key, req_id) in expired { - queue_reply(heap_key, req_id, Err("the host service timed out".into())); - } } /// Drop answers of the matching isolates that nobody took within [`REPLY_TTL`]. @@ -331,12 +343,14 @@ pub fn dispatch(call: ServiceCall, heap_key: usize, req_id: u64, host: &mut dyn let mut services = SERVICES.lock().unwrap(); let service = services.iter().position(|s| s.family() == family); let timeout = service.map(|i| services[i].timeout(&call)).unwrap_or(DEFAULT_TIMEOUT); - if let Err(refusal) = track(heap_key, req_id, &call.app_id, timeout) { - drop(services); - queue_reply(heap_key, req_id, Err(refusal)); - return; - } - let reply = Replier { heap_key, req_id }; + let reply = match track(heap_key, req_id, &call.app_id, timeout) { + Ok(reply) => reply, + Err(refusal) => { + drop(services); + queue_reply(heap_key, req_id, Err(refusal)); + return; + } + }; if family == "runtime" { drop(services); crate::host_api::dispatch(call, reply); @@ -390,8 +404,11 @@ struct SheetOps { impl ServiceHost for SheetOps { fn open_sheet(&mut self, body: String) { if !self.may_prompt { - Replier { heap_key: self.heap_key, req_id: self.req_id } - .send(Err("this surface cannot raise a prompt; open the app to continue".into())); + let reply = with_pending(|pending| pending.get(&(self.heap_key, self.req_id)) + .map(|entry| Replier { heap_key: self.heap_key, req_id: self.req_id, generation: entry.generation.clone() })); + if let Some(reply) = reply { + reply.send(Err("this surface cannot raise a prompt; open the app to continue".into())); + } return; } hold_for_sheet(self.heap_key, self.req_id, &self.app_id); @@ -743,6 +760,37 @@ mod tests { assert!(take_replies_for(&[7104]).is_empty(), "nothing reaches a replacement isolate"); } + #[test] + fn a_reused_heap_and_request_id_never_revive_an_old_replier() { + let held = holds("reused_request", None, false); + for (heap, ending) in [(7180, "close"), (7181, "timeout"), (7182, "answered")] { + dispatch(call("reused_request.read"), heap, 1, &mut Host::default()); + let old = held.lock().unwrap().pop().unwrap(); + assert!(old.is_pending()); + match ending { + "close" => cancel_heap(heap), + "timeout" => expire_pending(later(DEFAULT_TIMEOUT + Duration::from_secs(1)), |h| h == heap), + _ => old.clone().send(Ok(Value::String("original".into()))), + } + take_replies_for(&[heap]); + assert!(!old.is_pending()); + + // A replacement isolate can reuse an address and restart request + // ids. A timed-out request id can also be retried in the same VM. + dispatch(call("reused_request.read"), heap, 1, &mut Host::default()); + let current = held.lock().unwrap().pop().unwrap(); + assert!(current.is_pending()); + assert!(!old.is_pending(), "{ending}: the old invocation stays cancelled"); + old.send(Ok(Value::String("stale".into()))); + assert!(current.is_pending(), "an old response cannot remove the replacement's request"); + assert!(take_replies_for(&[heap]).is_empty(), "no stale answer reaches the replacement"); + current.send(Ok(Value::String("replacement".into()))); + let answer = take_replies_for(&[heap]); + assert_eq!(answer.len(), 1); + assert_eq!(answer[0].2.as_ref().unwrap(), "\"replacement\""); + } + } + /// A service that keeps state per isolate hears each closing isolate's /// heap key once, however often it registered. #[test] diff --git a/docs/HOST-API.md b/docs/HOST-API.md index da8fb57..09e9b9d 100644 --- a/docs/HOST-API.md +++ b/docs/HOST-API.md @@ -97,6 +97,18 @@ Plain Makepad still refuses apps that require the unsupported device broker. ## Discover what the host implements +### WASI sockets (unreleased) + +Components importing `wasi:sockets` require `wasm.sockets@1` in +`host_api.required`, with the existing `host-api-v1` feature in `requires`. +This is a runtime ABI marker, not a `host.request` method; TCP, UDP and DNS +operations use standard WASI interfaces. RC5 can read this requirement but +cannot satisfy it, so installation is refused without invalidating the catalog. +The same requirement applies when an app pins a shared socket component. +See [WebAssembly components](PUBLISHING.md#webassembly-components) for admission +and declaration rules. Confirm runtime/platform support in the actual host; +this source change does not update a released binary. + ### Native device calendars (contract 1.10) The new `device_calendar` capability is separate from `calendar` (the system diff --git a/docs/HOST-API.zh-CN.md b/docs/HOST-API.zh-CN.md index ec2c479..faf2239 100644 --- a/docs/HOST-API.zh-CN.md +++ b/docs/HOST-API.zh-CN.md @@ -67,6 +67,10 @@ ABI 与导入验证、确切摘要、发布者证明、目录撤回、配额和 ## 查询宿主实现了什么 +### WASI 套接字(尚未发布) + +导入 `wasi:sockets` 的组件要求在 `host_api.required` 中声明 `wasm.sockets@1`,并在 `requires` 中使用已有的 `host-api-v1` 特性。这是运行时 ABI 标记,不是 `host.request` 方法;TCP、UDP 和 DNS 操作使用标准 WASI 接口。RC5 能读取此要求但不能满足,因此只拒绝安装这个应用,不会使整个目录失效。应用固定共享套接字组件时也有相同要求。准入和声明规则见 [WebAssembly 组件](PUBLISHING.zh-CN.md#webassembly-组件)。请在实际宿主中确认运行时及平台支持;本次源码变更不会更新已发布的二进制。 + ### 原生日历(契约 1.10) 新增 `device_calendar` 能力与 `calendar`(系统日历应用自己的存储)和 `gcalendar`(Google 连接器)互相独立,不会隐含授予它们或操作系统权限。需要原生日程的应用应声明 `device_calendar`、`host-api-v1`,并设置 `host_api.required: {"device_calendar.events.list": 1}`,以及完整流程需要的其他方法。缺少这些方法的宿主会拒绝安装。 diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 54bb577..3fafc7b 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -142,8 +142,8 @@ admission. A warning does not. | `publisher-signature` | The declared GitHub proof fails verification, even with `--allow-unsigned`; the manifest is unsigned and `--allow-unsigned` is absent; or a legacy Ed25519 signature fails verification or names an unknown key (`publisher key "" is not registered with this hub`). | The manifest is unsigned and `--allow-unsigned` is given. | | `identity` | The id starts with `os.`; the id, or its last segment, is a reserved name ([Ids and reserved names](#ids-and-reserved-names)). | | | `contents` | A file's extension is not one of `.card`, `.json`, `.l0`, `.octoscript`, `.splash`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`, `.ttf`, `.otf`, `.txt` or `.md`, and it is not a function module (`.wasm`, see `functions`). Files with no extension, such as `.DS_Store` and `LICENSE`, are refused too. | | -| `functions` | The bundle carries more than 8 `.wasm` files in `fns/`. A `.wasm` file that is not at `fns/.wasm` (the name `[a-z0-9_-]`, at most 64 characters) or `components/.wasm` (named by its own digest, see `components`), or is neither a WebAssembly core module (the 8-byte header of version 1) nor a valid component, is refused as `contents-invalid`, as is a component that imports anything outside `wasi:cli`, `wasi:clocks`, `wasi:filesystem`, `wasi:http`, `wasi:io`, `wasi:random` and `octosense:host`. A component is refused here when the manifest does not require `wasm-components-v1` ([WebAssembly components](#webassembly-components)). The gate checks nothing else inside a core module; the host checks its imports and exports when it loads the module. | The app declares `wasm` but carries no `fns/*.wasm` and names no shared component. The manifest requires `wasm-components-v1` but `fns/` holds no component. Each admitted component gets a line saying what it reaches. Bundled functions without a `wasm` disclosure, or component file/HTTP imports without `storage`/`net` disclosures, produce warnings, not refusals. | -| `components` | A shared component the manifest names is missing from the `--catalog` catalog, withdrawn there or has another digest, or its imports are unsupported. A store app's bundle carries a `.wasm` file in `components/`. A system app's bundle (`--system-app`) lacks `components/.wasm` for a component it pins, or carries one it does not pin ([Shared components](#shared-components)). | Each resolved component gets a line saying what it reaches. Without `--catalog`, each component is reported as not resolved. The manifest requires `wasm-shared-components-v1` but names no components. | +| `functions` | The bundle carries more than 8 `.wasm` files in `fns/`. A `.wasm` file that is not at `fns/.wasm` (the name `[a-z0-9_-]`, at most 64 characters) or `components/.wasm` (named by its own digest, see `components`), or is neither a WebAssembly core module (the 8-byte header of version 1) nor a valid component, is refused as `contents-invalid`, as is a component that imports anything outside `wasi:cli`, `wasi:clocks`, `wasi:filesystem`, `wasi:http`, `wasi:io`, `wasi:random`, `wasi:sockets` and `octosense:host`. A component is refused here when the manifest does not require `wasm-components-v1`, or imports sockets without required `wasm.sockets@1` ([WebAssembly components](#webassembly-components)). The gate checks nothing else inside a core module; the host checks its imports and exports when it loads the module. | The app declares `wasm` but carries no `fns/*.wasm` and names no shared component. The manifest requires `wasm-components-v1` but `fns/` holds no component. Each admitted component gets a line saying what it reaches. Bundled functions without a `wasm` disclosure, or component file/HTTP/socket imports without `storage`/`net` disclosures, produce warnings, not refusals. | +| `components` | A shared component the manifest names is missing from the `--catalog` catalog, withdrawn there or has another digest, its imports are unsupported, or the app omits the required `wasm.sockets@1` ABI for a socket component. A store app's bundle carries a `.wasm` file in `components/`. A system app's bundle (`--system-app`) lacks `components/.wasm` for a component it pins, or carries one it does not pin ([Shared components](#shared-components)). | Each resolved component gets a line saying what it reaches. Without `--catalog`, each component is reported as not resolved. The manifest requires `wasm-shared-components-v1` but names no components. | | `contents-invalid` (text and images) | A text file (`.splash`, `.card`, `.json`, `.l0`, `.octoscript`, `.txt` or `.md`) is over 1 MiB or not UTF-8; JSON does not parse; a PNG, JPEG or WebP does not decode or is over 4096 px a side; the listing icon is not square, or is a bitmap over 1 MiB or 1024 px a side. | | | `contents-invalid` (SVG) | An SVG does not parse; lacks a numeric `width` and `height` or a `viewBox`; is over 4096 px a side; or holds a script, a `foreignObject` or an `on…` handler. Its styling (a `style` attribute or a `