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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions crates/app-hub/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))?;
}
Expand Down Expand Up @@ -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<Vec<&ComponentEntry>, String> {
if manifest.components.is_empty() {
return Ok(Vec::new());
Expand All @@ -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)
Expand All @@ -543,6 +543,9 @@ impl Store {
manifest: &octosense_app_policy::AppManifest,
fetch: &mut dyn FnMut(&ComponentEntry) -> Result<Vec<u8>, String>,
) -> Result<Vec<Resolved>, 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() {
Expand Down
66 changes: 49 additions & 17 deletions crates/app-hub/src/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`]).
//!
Expand All @@ -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".
Expand Down Expand Up @@ -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/"))
}
Expand All @@ -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/"))
}
Expand All @@ -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());
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"]);
Expand Down
24 changes: 15 additions & 9 deletions crates/app-hub/src/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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",
Expand Down Expand Up @@ -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/<blake3>.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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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())));
}
}
Expand Down
40 changes: 29 additions & 11 deletions crates/app-hub/tests/bundle_admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
9 changes: 9 additions & 0 deletions crates/app-hub/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
45 changes: 45 additions & 0 deletions crates/app-hub/tests/component_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading