use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppType {
Native,
Bun,
Standalone,
#[serde(rename = "platform-runtime")]
PlatformRuntime,
}
impl AppType {
pub fn as_str(self) -> &'static str {
match self {
AppType::Native => "native",
AppType::Bun => "bun",
AppType::Standalone => "standalone",
AppType::PlatformRuntime => "platform-runtime",
}
}
}
impl std::fmt::Display for AppType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppTier {
FirstParty,
Optional,
Development,
}
impl AppTier {
pub fn as_str(self) -> &'static str {
match self {
AppTier::FirstParty => "first_party",
AppTier::Optional => "optional",
AppTier::Development => "development",
}
}
}
impl std::fmt::Display for AppTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AbiVersion {
V1,
}
impl AbiVersion {
pub fn as_str(&self) -> &'static str {
match self {
AbiVersion::V1 => "v1",
}
}
pub fn is_supported(&self) -> bool {
matches!(self, AbiVersion::V1)
}
}
impl std::fmt::Display for AbiVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HotReloadKind {
Supported,
Experimental,
Unsupported,
}
impl HotReloadKind {
pub fn default_for(app_type: AppType) -> Self {
match app_type {
AppType::Native => HotReloadKind::Experimental,
AppType::Bun => HotReloadKind::Supported,
AppType::Standalone => HotReloadKind::Unsupported,
AppType::PlatformRuntime => HotReloadKind::Unsupported,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ManifestCapabilities {
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProvidedScope {
pub scope: String,
pub description: String,
pub resource_pattern: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointPolicy {
pub method: String,
pub path: String,
pub required_permissions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidedCapability {
#[serde(default)]
pub description: String,
#[serde(default)]
pub schema: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneConfig {
pub socket_path: std::path::PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppUiKind {
Stage,
Widget,
}
fn default_app_ui_kind() -> AppUiKind {
AppUiKind::Stage
}
fn default_nav_section() -> String {
"default".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppUiNav {
#[serde(default = "default_nav_section")]
pub section: String,
#[serde(default)]
pub order: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppUiManifest {
#[serde(default = "default_app_ui_kind")]
pub kind: AppUiKind,
pub entry: String,
pub title: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub nav: Option<AppUiNav>,
#[serde(default)]
pub composes: Vec<String>,
pub ui_api: u8,
#[serde(default)]
pub integrity: BTreeMap<String, String>,
}
pub fn validate_manifest_path(path: &str) -> Result<(), String> {
if path.is_empty() {
return Err("path must not be empty".to_string());
}
if path.starts_with('/') {
return Err(format!(
"path '{}' must not be absolute (starts with /)",
path
));
}
let first = path.chars().next().unwrap();
if !first.is_ascii_alphanumeric() && first != '_' {
return Err(format!(
"path '{}' must begin with an alphanumeric character or underscore",
path
));
}
for ch in path.chars().skip(1) {
if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
return Err(format!(
"path '{}' contains disallowed character '{}'",
path, ch
));
}
}
for segment in path.split('/') {
if segment == ".." {
return Err(format!(
"path '{}' contains a '..' segment (path traversal rejected)",
path
));
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppManifest {
#[serde(default = "default_manifest_version", rename = "manifest_version")]
pub manifest_version: u8,
pub name: String,
pub version: String,
#[serde(default = "default_app_type_native")]
pub app_type: AppType,
#[serde(default)]
pub description: String,
pub abi: Option<AbiVersion>,
pub entrypoint: Option<String>,
pub hot_reload: Option<HotReloadKind>,
#[serde(default)]
pub critical: bool,
#[serde(
default = "default_auto_start",
deserialize_with = "deserialize_auto_start"
)]
pub auto_start: bool,
#[serde(default)]
pub has_ui: bool,
#[serde(default = "default_ui_path")]
pub ui_path: String,
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub optional_permissions: Vec<String>,
#[serde(default)]
pub provides_scopes: Vec<ProvidedScope>,
#[serde(default)]
pub endpoint_policies: Vec<EndpointPolicy>,
#[serde(default)]
pub capability_scopes: HashMap<String, String>,
#[serde(default)]
pub provides: HashMap<String, ProvidedCapability>,
#[serde(default)]
pub capabilities: ManifestCapabilities,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<AppUiManifest>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub depends_on: Option<Vec<String>>,
#[serde(default)]
pub boot_priority: Option<u32>,
#[serde(default)]
pub governor: Option<GovernorManifest>,
#[serde(default)]
pub subscribes: Vec<String>,
#[serde(default)]
pub standalone: Option<StandaloneConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tcp: Option<TcpManifest>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct GovernorManifest {
#[serde(default)]
pub terminable: Option<bool>,
#[serde(default)]
pub min_idle_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TcpManifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direct_bind: Option<bool>,
}
fn default_manifest_version() -> u8 {
1
}
fn default_auto_start() -> bool {
true
}
fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum BoolOrStr {
Bool(bool),
Str(String),
}
Ok(match BoolOrStr::deserialize(deserializer)? {
BoolOrStr::Bool(b) => b,
BoolOrStr::Str(s) => matches!(
s.trim().to_ascii_lowercase().as_str(),
"true" | "eager" | "active" | "auto" | "on" | "1"
),
})
}
fn default_ui_path() -> String {
"dist".to_string()
}
fn default_app_type_native() -> AppType {
AppType::Native
}
impl AppManifest {
pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
let top = normalized_requirements(&self.requires)?;
let nested = normalized_requirements(&self.capabilities.requires)?;
if !top.is_empty()
&& !nested.is_empty()
&& top.iter().cloned().collect::<BTreeSet<_>>()
!= nested.iter().cloned().collect::<BTreeSet<_>>()
{
return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
}
Ok(if !top.is_empty() { top } else { nested })
}
pub fn effective_hot_reload(&self) -> HotReloadKind {
self.hot_reload
.unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
}
pub fn effective_entrypoint(&self) -> &str {
if let Some(ref ep) = self.entrypoint {
ep.as_str()
} else {
match self.app_type {
AppType::Native => "app.so",
AppType::Bun => "dist/index.js",
AppType::Standalone => "",
AppType::PlatformRuntime => "bun",
}
}
}
pub fn has_capability_providers(&self) -> bool {
!self.provides.is_empty() || !self.capabilities.provides.is_empty()
}
pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
for raw in &self.capabilities.provides {
let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
if name.is_empty() {
continue;
}
out.entry(name).or_insert(ProvidedCapability {
description: String::new(),
schema: None,
});
}
out
}
pub fn validate(&self) -> Result<(), String> {
self.validate_with_socket_path_policy(false)
}
pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
if self.manifest_version == 2 && self.abi.is_none() {
return Err("manifest_version 2 requires an 'abi' field".to_string());
}
validate_app_name(&self.name)?;
self.resolved_requires()?;
if let Some(ref ep) = self.entrypoint {
validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
}
if !self.ui_path.is_empty() && self.ui_path != "dist" {
validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
}
if let Some(ui) = &self.ui {
validate_app_ui(&self.name, ui)?;
}
if let Some(ref hp) = self.homepage {
if !hp.starts_with("https://") && !hp.starts_with("http://") {
return Err(format!(
"homepage '{}' must use https:// or http:// scheme",
hp
));
}
}
match self.app_type {
AppType::Standalone => {
if self.has_capability_providers() {
let cfg = self.standalone.as_ref().ok_or_else(|| {
"standalone apps that declare 'provides' require a \
'standalone.socket_path' field"
.to_string()
})?;
validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
}
}
AppType::Native | AppType::Bun | AppType::PlatformRuntime => {
if self.standalone.is_some() {
return Err(format!(
"'standalone' block is only valid when app_type == 'standalone' \
(found app_type='{}')",
self.app_type
));
}
}
}
if self.app_type == AppType::PlatformRuntime
&& !self.resolved_capability_provides().is_empty()
{
return Err(
"platform-runtime packages cannot provide runtime capabilities".to_string(),
);
}
Ok(())
}
pub fn from_json(json: &str) -> Result<Self, String> {
Self::from_json_with_socket_path_policy(json, false)
}
pub fn from_json_with_socket_path_policy(
json: &str,
allow_non_run: bool,
) -> Result<Self, String> {
let mut manifest: Self =
serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
if manifest.ui.is_some() {
manifest.has_ui = true;
}
manifest.validate_with_socket_path_policy(allow_non_run)?;
Ok(manifest)
}
}
fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
let mut seen = HashSet::new();
let mut resolved = Vec::new();
for value in values {
let requirement = value.trim();
if requirement.is_empty() {
return Err("capability requirements must not be blank".to_string());
}
if seen.insert(requirement.to_string()) {
resolved.push(requirement.to_string());
}
}
Ok(resolved)
}
fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
if ui.ui_api != 1 {
return Err(format!(
"ui.ui_api {} is unsupported; only version 1 is accepted",
ui.ui_api
));
}
if ui.title.trim().is_empty() {
return Err("ui.title must not be blank".to_string());
}
validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
if let Some(icon) = &ui.icon {
validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
}
if let Some(nav) = &ui.nav {
if ui.kind == AppUiKind::Widget {
return Err("widget ui must omit nav metadata".to_string());
}
if nav.section.trim().is_empty() {
return Err("ui.nav.section must not be blank".to_string());
}
}
let mut composed = HashSet::new();
for name in &ui.composes {
validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
if name == app_name {
return Err("ui.composes must not contain the app itself".to_string());
}
if !composed.insert(name) {
return Err(format!("ui.composes contains duplicate app '{name}'"));
}
}
for (path, digest) in &ui.integrity {
validate_manifest_path(path)
.map_err(|error| format!("ui.integrity path invalid: {error}"))?;
if !is_lowercase_sha256(digest) {
return Err(format!(
"ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
));
}
}
if !ui.integrity.contains_key(&ui.entry) {
return Err("ui.integrity must include the declared entry".to_string());
}
if let Some(icon) = &ui.icon {
if !ui.integrity.contains_key(icon) {
return Err("ui.integrity must include the declared icon".to_string());
}
}
Ok(())
}
fn is_lowercase_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
validate_standalone_socket_path_with_policy(path, false)
}
pub fn validate_standalone_socket_path_with_policy(
path: &std::path::Path,
allow_non_run: bool,
) -> Result<(), String> {
if !path.is_absolute() {
return Err(format!(
"standalone.socket_path '{}' must be absolute",
path.display()
));
}
if !allow_non_run && !path.starts_with("/run/") {
return Err(format!(
"standalone.socket_path '{}' must live under /run/",
path.display()
));
}
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(format!(
"standalone.socket_path '{}' must not contain '..' segments",
path.display()
));
}
Ok(())
}
fn validate_app_name(name: &str) -> Result<(), String> {
let (publisher, app) = if let Some(slash) = name.find('/') {
let (p, rest) = name.split_at(slash);
(Some(p), &rest[1..])
} else {
(None, name)
};
let valid_segment = |s: &str| -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
};
if let Some(pub_name) = publisher {
if !valid_segment(pub_name) {
return Err(format!(
"publisher segment '{}' must match [a-z][a-z0-9-]*",
pub_name
));
}
}
if !valid_segment(app) {
return Err(format!(
"app name segment '{}' must match [a-z][a-z0-9-]*",
app
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_ok(json: &str) -> AppManifest {
AppManifest::from_json(json).expect("should parse")
}
fn parse_err(json: &str) -> String {
AppManifest::from_json(json).expect_err("should fail")
}
#[test]
fn v1_minimal_native() {
let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
assert_eq!(m.manifest_version, 1);
assert_eq!(m.app_type, AppType::Native);
assert!(m.abi.is_none());
}
#[test]
fn v1_minimal_bun() {
let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
assert_eq!(m.app_type, AppType::Bun);
assert_eq!(m.effective_entrypoint(), "dist/index.js");
}
#[test]
fn v1_no_manifest_version_field_defaults_to_1() {
let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
assert_eq!(m.manifest_version, 1);
}
#[test]
fn v1_all_optional_fields_missing() {
let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
assert!(!m.critical);
assert!(m.auto_start);
assert!(!m.has_ui);
assert_eq!(m.ui_path, "dist");
assert!(m.permissions.is_empty());
assert!(m.optional_permissions.is_empty());
assert!(m.governor.is_none());
assert!(m.subscribes.is_empty());
}
#[test]
fn v1_with_permissions_and_provides() {
let json = r#"{
"name": "example",
"version": "1.0.0",
"app_type": "bun",
"permissions": ["core.storage.kv"],
"optional_permissions": ["core.notifications.create"],
"provides": {
"core.example.run": { "description": "Run example job" }
}
}"#;
let m = parse_ok(json);
assert_eq!(m.permissions, vec!["core.storage.kv"]);
assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
assert!(m.provides.contains_key("core.example.run"));
}
#[test]
fn v2_minimal_native() {
let json = r#"{
"manifest_version": 2,
"name": "cron",
"version": "1.0.0",
"app_type": "native",
"abi": "v1",
"entrypoint": "app.so",
"hot_reload": "experimental"
}"#;
let m = parse_ok(json);
assert_eq!(m.manifest_version, 2);
assert_eq!(m.abi, Some(AbiVersion::V1));
assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
}
#[test]
fn v2_minimal_bun_with_capabilities() {
let json = r#"{
"manifest_version": 2,
"name": "example-fullstack",
"version": "1.0.0",
"app_type": "bun",
"abi": "v1",
"entrypoint": "dist/index.js",
"hot_reload": "supported",
"has_ui": true,
"ui_path": "ui/dist",
"capabilities": {
"requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
"provides": []
},
"governor": { "terminable": false, "min_idle_secs": 300 },
"subscribes": ["core.chat.message.received"]
}"#;
let m = parse_ok(json);
assert_eq!(m.manifest_version, 2);
assert_eq!(m.capabilities.requires.len(), 2);
let governor = m.governor.expect("governor block should parse");
assert_eq!(governor.terminable, Some(false));
assert_eq!(governor.min_idle_secs, Some(300));
assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
}
#[test]
fn stage_contract_fixture_parses_with_normalized_requirements() {
let json = include_str!(
"../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
);
let m = parse_ok(json);
assert!(m.has_ui);
assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
let ui = m.ui.expect("fixture should declare ui");
assert_eq!(ui.kind, AppUiKind::Stage);
assert_eq!(ui.entry, "ui/main.js");
assert_eq!(ui.nav.unwrap().order, 10);
}
#[test]
fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
let m = parse_ok(
r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
);
assert!(m.has_ui);
assert_eq!(m.ui_path, "ui/dist");
assert!(m.ui.is_none());
}
#[test]
fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
let accepted = parse_ok(
r#"{
"name":"aliases","version":"1.0.0","app_type":"bun",
"requires":["core.chat.read","core.chat.read","core.chat.send"],
"capabilities":{"requires":["core.chat.send","core.chat.read"]}
}"#,
);
assert_eq!(
accepted.resolved_requires().unwrap(),
vec!["core.chat.read", "core.chat.send"]
);
let err = parse_err(
r#"{
"name":"aliases","version":"1.0.0","app_type":"bun",
"requires":["core.chat.read"],
"capabilities":{"requires":["core.wallet.pay"]}
}"#,
);
assert!(err.contains("conflicts"), "unexpected error: {err}");
}
#[test]
fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
let base = |ui: &str| {
format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
};
let widget = base(
r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
);
assert_eq!(parse_ok(&widget).ui.expect("widget ui").kind, AppUiKind::Widget);
let widget_nav = base(
r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
);
assert!(parse_err(&widget_nav).contains("must omit nav"));
let api = base(
r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
);
assert!(parse_err(&api).contains("ui_api"));
let path = base(
r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
);
assert!(parse_err(&path).contains("entry"));
}
#[test]
fn stage_requires_integrity_for_entry_and_icon() {
let missing_entry = r#"{
"name":"stage","version":"1.0.0","app_type":"bun",
"ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
}"#;
assert!(parse_err(missing_entry).contains("entry"));
let missing_icon = r#"{
"name":"stage","version":"1.0.0","app_type":"bun",
"ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
}"#;
assert!(parse_err(missing_icon).contains("icon"));
let uppercase = r#"{
"name":"stage","version":"1.0.0","app_type":"bun",
"ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
"integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
}"#;
assert!(parse_err(uppercase).contains("lowercase"));
}
#[test]
fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
let manifest = |composes: &str| {
format!(
r#"{{
"name":"stage","version":"1.0.0","app_type":"bun",
"ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
"composes":{composes},
"integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
}}"#
)
};
assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
}
#[test]
fn v2_missing_abi_is_error() {
let json = r#"{
"manifest_version": 2,
"name": "example",
"version": "1.0.0",
"app_type": "bun"
}"#;
let err = parse_err(json);
assert!(err.contains("abi"), "expected abi error, got: {}", err);
}
#[test]
fn publisher_prefixed_name_accepted() {
let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
assert_eq!(m.name, "alice/weather");
}
#[test]
fn double_slash_name_rejected() {
let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
assert!(!err.is_empty());
}
#[test]
fn name_starting_with_digit_rejected() {
let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
assert!(!err.is_empty());
}
#[test]
fn name_with_uppercase_rejected() {
let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
assert!(!err.is_empty());
}
#[test]
fn empty_name_rejected() {
let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
assert!(!err.is_empty());
}
#[test]
fn path_traversal_double_dot_rejected() {
let json = r#"{
"manifest_version": 2, "name": "evil", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "../etc/passwd"
}"#;
let err = parse_err(json);
assert!(err.contains(".."), "expected traversal error, got: {}", err);
}
#[test]
fn path_traversal_encoded_dot_not_decoded() {
let json = r#"{
"manifest_version": 2, "name": "evil", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "foo/../bar"
}"#;
let err = parse_err(json);
assert!(!err.is_empty(), "should have failed: {}", err);
}
#[test]
fn absolute_path_rejected() {
let json = r#"{
"manifest_version": 2, "name": "evil", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "/usr/bin/sh"
}"#;
let err = parse_err(json);
assert!(
err.contains("absolute"),
"expected absolute error, got: {}",
err
);
}
#[test]
fn shell_metachar_in_path_rejected() {
let json = r#"{
"manifest_version": 2, "name": "evil", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "dist/index.js;rm -rf /"
}"#;
let err = parse_err(json);
assert!(!err.is_empty());
}
#[test]
fn valid_nested_path_accepted() {
let json = r#"{
"manifest_version": 2, "name": "my-app", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "dist/index.js",
"ui_path": "ui/dist"
}"#;
parse_ok(json);
}
#[test]
fn homepage_https_accepted() {
let json = r#"{
"name": "my-app", "version": "1.0.0", "app_type": "bun",
"homepage": "https://example.com"
}"#;
parse_ok(json);
}
#[test]
fn homepage_javascript_scheme_rejected() {
let json = r#"{
"name": "my-app", "version": "1.0.0", "app_type": "bun",
"homepage": "javascript:alert(1)"
}"#;
let err = parse_err(json);
assert!(
err.contains("scheme"),
"expected scheme error, got: {}",
err
);
}
#[test]
fn homepage_file_scheme_rejected() {
let json = r#"{
"name": "my-app", "version": "1.0.0", "app_type": "bun",
"homepage": "file:///etc/passwd"
}"#;
let err = parse_err(json);
assert!(!err.is_empty());
}
#[test]
fn effective_entrypoint_native_default() {
let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
assert_eq!(m.effective_entrypoint(), "app.so");
}
#[test]
fn effective_entrypoint_bun_default() {
let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
assert_eq!(m.effective_entrypoint(), "dist/index.js");
}
#[test]
fn effective_hot_reload_native_default_is_experimental() {
let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
}
#[test]
fn effective_hot_reload_bun_default_is_supported() {
let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
}
#[test]
fn hot_reload_unsupported_explicit() {
let json = r#"{
"manifest_version": 2, "name": "myapp", "version": "1.0.0",
"app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
}"#;
let m = parse_ok(json);
assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
}
#[test]
fn validate_path_simple_valid() {
assert!(validate_manifest_path("dist/index.js").is_ok());
assert!(validate_manifest_path("app.so").is_ok());
assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
assert!(validate_manifest_path("build_output/main").is_ok());
}
#[test]
fn validate_path_empty_rejected() {
assert!(validate_manifest_path("").is_err());
}
#[test]
fn validate_path_absolute_rejected() {
assert!(validate_manifest_path("/usr/bin/sh").is_err());
}
#[test]
fn validate_path_double_dot_segment_rejected() {
assert!(validate_manifest_path("foo/../bar").is_err());
assert!(validate_manifest_path("../etc/passwd").is_err());
}
#[test]
fn validate_path_leading_dot_rejected() {
assert!(validate_manifest_path(".hidden").is_err());
}
#[test]
fn validate_path_null_byte_rejected() {
let path = "foo\0bar";
assert!(validate_manifest_path(path).is_err());
}
#[test]
fn standalone_socket_path_development_override_is_explicit_and_pure() {
let path = std::path::Path::new("/tmp/node-app/example.sock");
assert!(validate_standalone_socket_path(path).is_err());
assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
assert!(validate_standalone_socket_path_with_policy(
std::path::Path::new("/tmp/node-app/../escape.sock"),
true,
)
.is_err());
}
#[test]
fn manifest_capabilities_defaults_to_empty() {
let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
assert!(m.capabilities.requires.is_empty());
assert!(m.capabilities.provides.is_empty());
}
#[test]
fn resolved_capability_provides_v1_only() {
let json = r#"{
"name": "example", "version": "1.0.0", "app_type": "bun",
"provides": { "core.example.run": { "description": "Run example job" } }
}"#;
let m = parse_ok(json);
let out = m.resolved_capability_provides();
assert_eq!(out.len(), 1);
assert_eq!(
out.get("core.example.run").unwrap().description,
"Run example job"
);
}
#[test]
fn resolved_capability_provides_v2_names_get_blank_declaration() {
let json = r#"{
"manifest_version": 2, "name": "example", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
}"#;
let m = parse_ok(json);
let out = m.resolved_capability_provides();
assert_eq!(out.len(), 2);
assert_eq!(out.get("core.example.run").unwrap().description, "");
assert!(out.get("core.example.run").unwrap().schema.is_none());
assert!(out.contains_key("core.example.other"));
assert!(!out.contains_key("core.example.other:extra"));
}
#[test]
fn resolved_capability_provides_v1_wins_on_conflict() {
let json = r#"{
"manifest_version": 2, "name": "example", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"provides": { "core.example.run": { "description": "v1 wins" } },
"capabilities": { "requires": [], "provides": ["core.example.run"] }
}"#;
let m = parse_ok(json);
let out = m.resolved_capability_provides();
assert_eq!(out.len(), 1);
assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
}
#[test]
fn resolved_capability_provides_blank_v2_name_skipped() {
let json = r#"{
"manifest_version": 2, "name": "example", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
}"#;
let m = parse_ok(json);
let out = m.resolved_capability_provides();
assert_eq!(out.len(), 1);
assert!(out.contains_key("core.example.run"));
}
#[test]
fn resolved_capability_provides_empty_manifest_yields_empty_map() {
let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
assert!(m.resolved_capability_provides().is_empty());
}
#[test]
fn abi_v1_is_supported() {
assert!(AbiVersion::V1.is_supported());
}
#[test]
fn app_tier_display() {
assert_eq!(AppTier::FirstParty.to_string(), "first_party");
assert_eq!(AppTier::Optional.to_string(), "optional");
assert_eq!(AppTier::Development.to_string(), "development");
}
#[test]
fn app_tier_serde_roundtrip() {
for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
let json = serde_json::to_string(&tier).unwrap();
let back: AppTier = serde_json::from_str(&json).unwrap();
assert_eq!(
tier, back,
"roundtrip failed for {:?}: serialized as {}",
tier, json
);
}
assert_eq!(
serde_json::to_string(&AppTier::Development).unwrap(),
"\"development\""
);
}
#[test]
fn app_type_display() {
assert_eq!(AppType::Native.to_string(), "native");
assert_eq!(AppType::Bun.to_string(), "bun");
assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
}
#[test]
fn platform_runtime_is_a_supported_packaging_type() {
let manifest: AppManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 2,
"abi": "v1",
"name": "bun-runtime",
"version": "1.0.0",
"app_type": "platform-runtime",
"entrypoint": "bun"
}))
.expect("platform runtime manifest should parse");
assert_eq!(manifest.app_type, AppType::PlatformRuntime);
assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
}
}