From 9b03bec80716a85a5e2e6a7f73303ef1c8430f6b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:31:47 -0700 Subject: [PATCH 1/3] app-contract 1.7.0: the wasm capability KNOWN_CAPABILITIES gains `wasm`: the app's own functions, WebAssembly modules in its bundle's fns/, run by the host's `wasm` service in a sandbox with a deadline and a memory cap. A function gets its input and returns its output; it reaches no file, network, clock or other app, and the grant implies no other capability. The store shows "Run its own sandboxed functions on this device"; the listing's line says what they cannot reach. A growth test pins it as its own exact grant. 1.7.0 is not on crates.io; publishing follows review (publish-app-contract). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 ++- Cargo.lock | 2 +- crates/app-contract/CHANGELOG.md | 11 ++++++++++- crates/app-contract/Cargo.toml | 2 +- crates/app-contract/README.md | 3 ++- crates/app-contract/src/manifest.rs | 5 +++++ crates/app-contract/tests/growth.rs | 15 +++++++++++++++ crates/app-hub/src/index.rs | 1 + crates/app-policy/src/listing.rs | 1 + 9 files changed, 38 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e23a610..b5a3727 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,8 @@ owns those. [`card-host` fails to build](docs/DEVELOPMENT.md#card-host-fails-to-build) lists the builds that need the patches. - [Versions on crates.io](crates/app-contract/README.md#versions-on-cratesio) - lists the `octosense-app-contract` releases; 1.6.0 matches this tree. Do + lists the `octosense-app-contract` releases; this tree is 1.7.0, which is + not on crates.io yet (the newest there is 1.6.0). Do not take a published contract as proof that a released host implements its APIs. This workspace's `[patch.crates-io]` points the crate at `crates/app-contract`. Never tell readers that a lock file holding 1.2.0 or diff --git a/Cargo.lock b/Cargo.lock index af1db3c..06285a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "octosense-app-contract" -version = "1.6.0" +version = "1.7.0" dependencies = [ "blake3", "serde", diff --git a/crates/app-contract/CHANGELOG.md b/crates/app-contract/CHANGELOG.md index 08269da..b0d284d 100644 --- a/crates/app-contract/CHANGELOG.md +++ b/crates/app-contract/CHANGELOG.md @@ -4,10 +4,19 @@ within `1.x` it only grows. crates.io has 1.0.0, 1.1.0, 1.2.0, 1.5.0 and 1.6.0; 1.3.0 and 1.4.0 are -unpublished, and 1.5.0 includes their changes. +unpublished, and 1.5.0 includes their changes. 1.7.0 is not published yet. [README.md](README.md#versions-on-cratesio) shows how to update a lock file that holds 1.2.0. +## 1.7.0 + +- `KNOWN_CAPABILITIES` gains `wasm`: the app's own functions, WebAssembly + modules in its bundle's `fns/`, run by the host's `wasm` service in a + sandbox with a deadline and a memory cap. A function gets its input and + returns its output; it reaches no file, network, clock or other app. The + grant implies no other capability. When 1.7.0 was cut, OctoSense served + `wasm` only in builds with its `wasm-lab` feature, and no release had it. + ## 1.6.0 - Adds signed `host_api` requirements with exact ABI-major versions, implemented diff --git a/crates/app-contract/Cargo.toml b/crates/app-contract/Cargo.toml index f851c05..b93b625 100644 --- a/crates/app-contract/Cargo.toml +++ b/crates/app-contract/Cargo.toml @@ -7,7 +7,7 @@ # and no path dependencies. Within 1.x it only grows (see README.md). [package] name = "octosense-app-contract" -version = "1.6.0" +version = "1.7.0" edition = "2021" license = "Apache-2.0" description = "The OctoSense app contract: app manifests, the policy an app gets, package integrity and running a package" diff --git a/crates/app-contract/README.md b/crates/app-contract/README.md index 78ca709..3b2d79a 100644 --- a/crates/app-contract/README.md +++ b/crates/app-contract/README.md @@ -50,7 +50,8 @@ limits. crates.io has 1.0.0, 1.1.0, 1.2.0, 1.5.0 and 1.6.0. Versions 1.3.0 and 1.4.0 exist only in this repository ([CHANGELOG.md](CHANGELOG.md)); 1.5.0 includes their -changes. A lock file that still holds 1.2.0 refuses every capability added +changes. 1.7.0, which adds `wasm`, is in this repository and not yet on +crates.io. A lock file that still holds 1.2.0 refuses every capability added since, such as `auth`: ```text diff --git a/crates/app-contract/src/manifest.rs b/crates/app-contract/src/manifest.rs index bfb4e02..d1a8623 100644 --- a/crates/app-contract/src/manifest.rs +++ b/crates/app-contract/src/manifest.rs @@ -114,6 +114,11 @@ pub const KNOWN_CAPABILITIES: &[&str] = &[ // inside its domain lists. More reach than `research`, which only reads // search results; neither implies the other. "crawl", + // Run the app's own WebAssembly functions (its bundle's `fns/*.wasm`) + // through the host's `wasm` service: computing only, in a sandbox with a + // deadline and a memory cap. A function reaches no file, network, clock + // or other app; it gets its input and returns its output. + "wasm", // Host services reached by exact name (App Hub's `services`). Each is // a separate consent: a host adapter checks the exact name, the person's // per-instance grant and its own ceilings on every request. A prefix is diff --git a/crates/app-contract/tests/growth.rs b/crates/app-contract/tests/growth.rs index a784de6..733860f 100644 --- a/crates/app-contract/tests/growth.rs +++ b/crates/app-contract/tests/growth.rs @@ -84,6 +84,21 @@ fn media_services_require_exact_separate_grants() { } } +#[test] +fn an_apps_own_functions_are_a_grant_of_their_own() { + let limits = HostLimits::system(); + let manifest = parse(&manifest_with(r#""capabilities":["wasm"]"#)).unwrap(); + let policy = resolve(&manifest, &limits).unwrap(); + assert!(policy.allows("wasm")); + for other in ["net", "storage", "model", "glance", "mail", "runtime"] { + assert!(!policy.allows(other), "wasm must not imply {other}"); + } + for near in ["wasm.*", "wasm.find_slots", "WASM"] { + let manifest = parse(&manifest_with(&format!(r#""capabilities":["{near}"]"#))).unwrap(); + assert!(resolve(&manifest, &limits).is_err(), "{near} must be unknown"); + } +} + #[test] fn an_unknown_required_feature_needs_a_newer_host() { let err = parse(&manifest_with(r#""requires":["storage.encrypted"]"#)).unwrap_err(); diff --git a/crates/app-hub/src/index.rs b/crates/app-hub/src/index.rs index f6517f5..62246d7 100644 --- a/crates/app-hub/src/index.rs +++ b/crates/app-hub/src/index.rs @@ -104,6 +104,7 @@ impl Entry { "news" => "Read news the device collects from its feeds and topics".to_string(), "photos" => "Read Photos's own library and publish collections".to_string(), "youtube" => "Search YouTube and manage music recommendations".to_string(), + "wasm" => "Run its own sandboxed functions on this device".to_string(), "glance" => "Show cards on your glance screen".to_string(), "model" => "Send what you give it to the AI provider you configured, within a daily budget".to_string(), "research" => format!("Search {}", octosense_app_policy::search_words(&self.manifest.shown_research_scope())), diff --git a/crates/app-policy/src/listing.rs b/crates/app-policy/src/listing.rs index d236b99..2880fed 100644 --- a/crates/app-policy/src/listing.rs +++ b/crates/app-policy/src/listing.rs @@ -202,6 +202,7 @@ pub fn privacy_summary_with_tools(manifest: &AppManifest, tools: &[ToolSpec]) -> ("news", "Reads news the device collects from its feeds and topics."), ("photos", "Reads Photos's own library and publishes photo collections."), ("youtube", "Searches YouTube videos and manages music recommendations; playback requires a tap."), + ("wasm", "Runs its own functions in a sandbox on this device; they reach no files, network or other apps."), ("glance", "Shows short cards on your glance screen; each opens only this app."), ("model", "Sends what you give it to the AI provider you configured, for one-off answers within a daily budget; it never sees your API keys."), ] { From 956f4c53b817c50dafe853517f6930fa9c56325e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:32:36 -0700 Subject: [PATCH 2/3] app-policy: an app's own wasm. as a tool's host method A tool may route to the app's own WebAssembly function with host_method "wasm.". The host runs the bundle's module in a sandbox where the function reaches nothing but its input, so it is outside the reviewed shared-service list and has no risk or private_data floor; the manifest must still declare `wasm`. app-policy now asks for contract 1.7, which knows the capability. Co-Authored-By: Claude Opus 5.5 --- crates/app-policy/Cargo.toml | 2 +- crates/app-policy/src/agent.rs | 8 ++++++ crates/app-policy/tests/agent_bundle.rs | 34 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/app-policy/Cargo.toml b/crates/app-policy/Cargo.toml index 7d8f69c..45629d2 100644 --- a/crates/app-policy/Cargo.toml +++ b/crates/app-policy/Cargo.toml @@ -28,7 +28,7 @@ description = "Signed app manifests resolved into splash-isolate settings and oc # consumer that takes this crate from git (the OctoSense shell) must link the # same crates.io copy as every app (Rinx), or Cargo builds two contracts. The # workspace's [patch.crates-io] points it at crates/app-contract here. -octosense-app-contract = "1.6" +octosense-app-contract = "1.7" serde = { version = "1", features = ["derive"] } serde_json = "1" # Only for the splash adapter: the policy core never touches makepad, so the diff --git a/crates/app-policy/src/agent.rs b/crates/app-policy/src/agent.rs index ff2a3d3..8588b88 100644 --- a/crates/app-policy/src/agent.rs +++ b/crates/app-policy/src/agent.rs @@ -484,6 +484,14 @@ fn check_host_method(tool: &ToolSpec) -> Result<(), String> { if method.split('.').any(|part| part == "sheet") { return Err("host_method cannot target a host sheet or approve an action".into()); } + // The app's own WebAssembly function (`wasm.`, contract 1.7): + // the host runs the bundle's module in a sandbox where a function + // reaches nothing but its input, so no shared data sets a risk floor or + // calls for `private_data`. `check` still requires the app to declare + // the `wasm` capability. + if method.split('.').count() == 2 && method.starts_with("wasm.") { + return Ok(()); + } let Some((_, minimum)) = SHARED_HOST_METHODS.iter().find(|(name, _)| *name == method) else { return Err(format!( "host_method {method:?} is not in the reviewed shared-service tool contract" diff --git a/crates/app-policy/tests/agent_bundle.rs b/crates/app-policy/tests/agent_bundle.rs index 86edf32..77c21c2 100644 --- a/crates/app-policy/tests/agent_bundle.rs +++ b/crates/app-policy/tests/agent_bundle.rs @@ -415,6 +415,40 @@ fn shared_local_mutations_keep_risk_and_private_data_floors() { .contains("private_data: true")); } +#[test] +fn an_apps_own_wasm_function_is_a_host_method_behind_the_wasm_capability() { + // `wasm.` runs the bundle's own module, so it is in no shared + // list and has no risk or private-data floor. + let make = |method: &str| { + json!({"schema":1,"tools":[{ + "name":"inbox.slots","description":"Find free slots in a day.", + "input_schema":{"type":"object"},"output_schema":{"type":"object"}, + "risk":"read","implemented_by":"host-service","host_method":method + }]}) + .to_string() + }; + let (tools, _) = + ToolManifest::load(&make("wasm.find_slots"), "inbox", ToolHost::Contained, false).unwrap(); + assert_eq!(tools.tools[0].service_method(), "wasm.find_slots"); + for method in ["wasm.sheet", "wasm.find_slots.extra", "wasm.Find", "wasm."] { + assert!( + ToolManifest::load(&make(method), "inbox", ToolHost::Contained, false).is_err(), + "{method}" + ); + } + let dir = scratch("wasm-host-method"); + edit_tools(&dir, |tools| { + tools["tools"][0]["host_method"] = json!("wasm.find_slots"); + }); + let manifest = stamp(&dir, |_| {}); + refused_with(&dir, &manifest, "requires the declared \"wasm\" service capability"); + let manifest = stamp(&dir, |m| { + m["capabilities"].as_array_mut().unwrap().push(json!("wasm")) + }); + let loaded = AgentBundle::load(&dir, &manifest).unwrap().unwrap(); + assert_eq!(loaded.tool("news.list").unwrap().service_method(), "wasm.find_slots"); +} + #[test] fn omitted_host_method_keeps_legacy_serialization_and_dispatch() { let original = read_json(&fixture().join(TOOLS_FILE)); From b7fa3ab243e4a0d11f1b6add43bb47989af76973 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:35:02 -0700 Subject: [PATCH 3/3] app-hub: the store gate admits fns/*.wasm under the wasm capability A WebAssembly module becomes the one executable a store bundle may carry: at fns/.wasm ([a-z0-9_-], at most 64 characters), a core module (magic and version 1), at most 8 per bundle, and only when the manifest declares `wasm`. A module elsewhere, misnamed or not wasm is refused as contents-invalid; one without the capability, or a ninth, as `functions`; the capability without modules is a warning. The host checks a module's imports and exports when it loads it. PUBLISHING and SUBMITTING (English and Chinese) gain the `functions` check, the `wasm` row, and `wasm.` in the host_method rules, and say that no release serves it yet; the READMEs say the same. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + README.zh-CN.md | 1 + crates/app-hub/src/admission.rs | 22 +++++++++ crates/app-hub/src/gate.rs | 33 ++++++++++++- crates/app-hub/tests/bundle_admission.rs | 62 ++++++++++++++++++++++++ docs/PUBLISHING.md | 12 +++-- docs/PUBLISHING.zh-CN.md | 11 +++-- docs/SUBMITTING.md | 4 +- docs/SUBMITTING.zh-CN.md | 4 +- 9 files changed, 137 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9f9a32a..7f24145 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ each one today. - OctoSense serves `calendar`, `llm` and `news` only to its own system apps. For `photos` and `youtube` it serves only a `notify` method, to `os.photos` and `os.youtube`. Not yet: media services for store apps. +- No release serves `wasm` (an app's own `fns/*.wasm` functions, contract + 1.7.0). OctoSense runs them only in builds with its `wasm-lab` feature. - OctoSense runs an app agent's granted `implemented_by: "host-service"` tools, including tools mapped to a reviewed shared service with `host_method`, and loads `AGENT.md` and skills as guidance diff --git a/README.zh-CN.md b/README.zh-CN.md index 98806d7..ad53549 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,6 +86,7 @@ OCTOSENSE_PREVIEW_SIZE=1200x860 OCTOSENSE_APP_DATA="$(mktemp -d)" target/release - 该 PR 还在 macOS 和 Android 上向声明了 `host-api-v1` 和相应能力的应用提供设备权限方法:`camera.permission.*`、`microphone.permission.*` 和 `location.permission.*`,各含 `status`、`request` 和 `revoke`。在 Android 上,它还提供 `location.get`。只有在前台的应用才能请求权限,并由用户在宿主面板上批准,如果操作系统要求,还要在系统提示中确认([宿主 API 兼容性](docs/HOST-API.zh-CN.md))。 - OctoSense `main` 还要求亲手点按才能批准 GitHub 或 Google 日历的保存,拒绝应用 Agent 发布可执行 Splash(`script`)卡片,并且只保留从 30 天前到 366 天后的 Google 日历日程(尚未进入任何发布版本)。OctoSense 桌面版 0.1.0-beta.2 不具备其中任何一项。 - OctoSense 只向自己的系统应用提供 `calendar`、`llm` 和 `news`。对于 `photos` 和 `youtube`,它只向 `os.photos` 和 `os.youtube` 提供一个 `notify` 方法。尚未提供:面向商店应用的媒体服务。 +- 目前没有任何发布版本提供 `wasm`(应用 `fns/*.wasm` 中自带的函数,契约 1.7.0)。OctoSense 只在启用 `wasm-lab` 特性的构建中运行它们。 - OctoSense 运行应用 Agent 已获授权的 `implemented_by: "host-service"` 工具,包括用 `host_method` 映射到已审核共享服务的工具,并把 `AGENT.md` 和技能作为指引加载([应用的 Agent 与工具](docs/PUBLISHING.zh-CN.md#应用的-agent-与工具))。 - 目前还没有任何 OctoSense 发布版本运行 `implemented_by: "app"` 的工具:桌面版 0.1.0-beta.2 会拒绝它们,返回 `app_tool_unavailable`。PR #360 的实现提供 `app_tools.dispatch@1`,会在已打开的应用中运行这类工具,前提是应用声明了 `requires: ["script-tools-v1"]`;应用关闭时返回 `app_not_running`([脚本工具执行](docs/PUBLISHING.zh-CN.md#脚本工具执行script-tools-v1))。 diff --git a/crates/app-hub/src/admission.rs b/crates/app-hub/src/admission.rs index 0f4d8bf..88f8de8 100644 --- a/crates/app-hub/src/admission.rs +++ b/crates/app-hub/src/admission.rs @@ -225,6 +225,7 @@ pub fn validate(root: &Path, files: &[BundleFile]) -> (Vec, Vec validate_bitmap(&path, &extension, is_icon), "svg" => validate_svg(&path, &name, &mut refs, is_icon), + "wasm" => validate_wasm(&path, &name), _ => Ok(()), }; if let Err(error) = result { @@ -424,6 +425,27 @@ fn check_svg_css(css: &str) -> Result<(), String> { Ok(()) } +/// An app's own function module, run by the host's `wasm` service under the +/// `wasm` capability: a WebAssembly core module at `fns/.wasm`. Its +/// imports and exports are the host's to check when it loads the module. +fn validate_wasm(path: &Path, name: &str) -> Result<(), String> { + let stem = name + .strip_prefix("fns/") + .and_then(|rest| rest.strip_suffix(".wasm")) + .ok_or("a WebAssembly module belongs in fns/, as fns/.wasm")?; + if stem.is_empty() + || stem.len() > 64 + || !stem.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') + { + return Err("a function module is named fns/.wasm, the name [a-z0-9_-] and at most 64 characters".into()); + } + let header = read_bounded(path, MAX_BUNDLE_BYTES)?; + if !header.starts_with(b"\0asm\x01\0\0\0") { + return Err("not a WebAssembly core module (magic and version 1)".into()); + } + Ok(()) +} + fn validate_svg(path: &Path, name: &str, refs: &mut Vec, icon: bool) -> Result<(), String> { let text = read_text(path, MAX_TEXT_BYTES)?; let document = roxmltree::Document::parse_with_options(&text, roxmltree::ParsingOptions { nodes_limit: 4096, ..Default::default() }) diff --git a/crates/app-hub/src/gate.rs b/crates/app-hub/src/gate.rs index b52fd08..2fab950 100644 --- a/crates/app-hub/src/gate.rs +++ b/crates/app-hub/src/gate.rs @@ -10,9 +10,13 @@ use std::path::Path; /// Everything a bundle may hold besides its manifest, by extension. A bundle /// is cards, data and artwork; anything else is a refusal, so a publisher -/// cannot smuggle a payload the checks do not understand. +/// cannot smuggle a payload the checks do not understand. The one exception +/// is the app's own functions, `fns/*.wasm` (below). const ALLOWED_EXTENSIONS: &[&str] = &["card", "json", "l0", "octoscript", "splash", "svg", "png", "jpg", "jpeg", "webp", "ttf", "otf", "txt", "md"]; +/// The most WebAssembly modules (`fns/*.wasm`) a bundle may carry. +pub const MAX_FUNCTION_MODULES: usize = 8; + /// Ceiling for a whole bundle. Cards are text and artwork; a bundle bigger /// than this is either shipping something it should not, or should be split. pub const MAX_BUNDLE_BYTES: u64 = 8 * 1024 * 1024; @@ -167,12 +171,15 @@ pub fn check_bundle( // ---- contents ------------------------------------------------------- let mut total = 0u64; + let mut modules = 0usize; for file in list_files(bundle)? { let path = bundle.join(&file); let size = std::fs::metadata(&path).map_err(|e| e.to_string())?.len(); total += size; let extension = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); - if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) { + if extension == "wasm" { + modules += 1; + } else if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) { findings.push(Finding::refuse( "contents", format!("{} has extension {extension:?}, which a bundle may not hold", file.display()), @@ -183,6 +190,28 @@ pub fn check_bundle( findings.push(Finding::refuse("size", format!("the bundle is {total} bytes, over the {MAX_BUNDLE_BYTES} ceiling"))); } + // ---- the app's own functions ---------------------------------------- + // A WebAssembly module is the one executable a bundle may carry: the + // app's own functions in `fns/` (admission checks each name and header), + // run by the host's `wasm` service in a sandbox, under the `wasm` + // capability a store shows the person. + let declares_wasm = manifest.capabilities.iter().any(|c| c == "wasm"); + if modules > 0 && !declares_wasm { + findings.push(Finding::refuse( + "functions", + format!("the bundle carries {modules} WebAssembly module(s) but does not declare the wasm capability"), + )); + } + if modules > MAX_FUNCTION_MODULES { + findings.push(Finding::refuse( + "functions", + format!("the bundle carries {modules} WebAssembly modules, over the {MAX_FUNCTION_MODULES} it may"), + )); + } + if declares_wasm && modules == 0 { + findings.push(Finding::warn("functions", "the bundle declares the wasm capability but carries no fns/*.wasm")); + } + // ---- assets are local ---------------------------------------------- // ADR 0002's prototype found a card with no network grant fetching nine // images over HTTP, because the resource loader is not the network module diff --git a/crates/app-hub/tests/bundle_admission.rs b/crates/app-hub/tests/bundle_admission.rs index d1ffcc7..95dc192 100644 --- a/crates/app-hub/tests/bundle_admission.rs +++ b/crates/app-hub/tests/bundle_admission.rs @@ -348,3 +348,65 @@ fn theme_font_token_is_checked_even_without_a_component_reference() { assert!(!report.passed()); assert!(report.findings.iter().any(|r| r.check == "resource-invalid" && r.path.as_deref() == Some("kit/native/light/kit.json/tokens/typography.body.font_src/value"))); } + +// ---- the app's own functions (fns/*.wasm, the wasm capability) ---------- + +/// The smallest valid WebAssembly core module: magic and version, no sections. +const MODULE: &[u8] = b"\0asm\x01\0\0\0"; + +fn with_functions(capability: bool, files: &[(&str, &[u8])]) -> octosense_app_hub::GateReport { + let mut f = Fixture::new(); + for (name, bytes) in files { + let path = f.bundle.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + } + if capability { + f.manifest.capabilities.push("wasm".into()); + } + f.sign(); + f.report(None) +} + +#[test] +fn an_apps_own_functions_pass_only_under_the_wasm_capability() { + let report = with_functions(true, &[("fns/rank.wasm", MODULE)]); + assert!(report.passed(), "{}", report.render()); + let report = with_functions(false, &[("fns/rank.wasm", MODULE)]); + assert!(!report.passed()); + assert!(report.findings.iter().any(|f| f.check == "functions"), "{}", report.render()); +} + +#[test] +fn a_module_out_of_place_misnamed_or_not_wasm_is_refused() { + for (name, bytes) in [ + ("rank.wasm", MODULE), + ("lib/rank.wasm", MODULE), + ("fns/deep/rank.wasm", MODULE), + ("fns/Rank.wasm", MODULE), + ("fns/.wasm", MODULE), + ("fns/rank.wasm", b"\0asm\x02\0\0\0".as_slice()), + ("fns/rank.wasm", b"\0asm".as_slice()), + ("fns/rank.wasm", b"#!/bin/sh\necho hi\n".as_slice()), + ] { + let report = with_functions(true, &[(name, bytes)]); + assert!(!report.passed(), "{name} {bytes:?} must be refused: {}", report.render()); + } +} + +#[test] +fn a_bundle_carries_at_most_eight_modules_and_hears_about_none() { + let max = octosense_app_hub::gate::MAX_FUNCTION_MODULES; + let names: Vec = (0..=max).map(|i| format!("fns/f{i}.wasm")).collect(); + let files: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), MODULE)).collect(); + let report = with_functions(true, &files[..max]); + assert!(report.passed(), "{}", report.render()); + assert!(!with_functions(true, &files).passed()); + let report = with_functions(true, &[]); + assert!(report.passed(), "{}", report.render()); + assert!( + report.findings.iter().any(|f| f.check == "functions" && f.severity == octosense_app_hub::Severity::Warning), + "{}", + report.render() + ); +} diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 8d1e3a8..9f681aa 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -118,7 +118,8 @@ admission. A warning does not. | `digest` | `integrity.bundle_blake3` does not match the bundle. Run `hub stamp` after every change. | | | `publisher-signature` | The signature does not verify against the key given with `--publisher-key` or recorded in the catalog; no key was given for a signed manifest (`publisher key "" is not registered with this hub`); the manifest is unsigned and `--allow-unsigned` is absent. | 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`. Files with no extension, such as `.DS_Store` and `LICENSE`, are refused too. | | +| `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 a `.wasm` module without the `wasm` capability, or more than 8 modules. A module that is not at `fns/.wasm` (the name `[a-z0-9_-]`, at most 64 characters) or is not a WebAssembly core module is refused as `contents-invalid`. | The app declares `wasm` but carries no `fns/*.wasm`. | | `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 `