1use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum AppType {
36 Native,
38 Bun,
40 Standalone,
46 #[serde(rename = "platform-runtime")]
50 PlatformRuntime,
51}
52
53impl AppType {
54 pub fn as_str(self) -> &'static str {
55 match self {
56 AppType::Native => "native",
57 AppType::Bun => "bun",
58 AppType::Standalone => "standalone",
59 AppType::PlatformRuntime => "platform-runtime",
60 }
61 }
62}
63
64impl std::fmt::Display for AppType {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{}", self.as_str())
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum AppTier {
77 FirstParty,
81 Optional,
85 Development,
96}
97
98impl AppTier {
99 pub fn as_str(self) -> &'static str {
100 match self {
101 AppTier::FirstParty => "first_party",
102 AppTier::Optional => "optional",
103 AppTier::Development => "development",
104 }
105 }
106}
107
108impl std::fmt::Display for AppTier {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 write!(f, "{}", self.as_str())
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum AbiVersion {
121 V1,
122}
123
124impl AbiVersion {
125 pub fn as_str(&self) -> &'static str {
126 match self {
127 AbiVersion::V1 => "v1",
128 }
129 }
130
131 pub fn is_supported(&self) -> bool {
133 matches!(self, AbiVersion::V1)
134 }
135}
136
137impl std::fmt::Display for AbiVersion {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 write!(f, "{}", self.as_str())
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum HotReloadKind {
150 Supported,
152 Experimental,
155 Unsupported,
157}
158
159impl HotReloadKind {
160 pub fn default_for(app_type: AppType) -> Self {
161 match app_type {
162 AppType::Native => HotReloadKind::Experimental,
163 AppType::Bun => HotReloadKind::Supported,
164 AppType::Standalone => HotReloadKind::Unsupported,
167 AppType::PlatformRuntime => HotReloadKind::Unsupported,
168 }
169 }
170}
171
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ManifestCapabilities {
180 #[serde(default)]
183 pub requires: Vec<String>,
184
185 #[serde(default)]
188 pub provides: Vec<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, Default)]
193pub struct ProvidedScope {
194 pub scope: String,
195 pub description: String,
196 pub resource_pattern: String,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct EndpointPolicy {
202 pub method: String,
203 pub path: String,
204 pub required_permissions: Vec<String>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ProvidedCapability {
210 #[serde(default)]
211 pub description: String,
212 #[serde(default)]
213 pub schema: Option<serde_json::Value>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct StandaloneConfig {
228 pub socket_path: std::path::PathBuf,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum AppUiKind {
235 Stage,
236 Widget,
237}
238
239fn default_app_ui_kind() -> AppUiKind {
240 AppUiKind::Stage
241}
242
243fn default_nav_section() -> String {
244 "default".to_string()
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct AppUiNav {
250 #[serde(default = "default_nav_section")]
251 pub section: String,
252 #[serde(default)]
253 pub order: i32,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct AppUiManifest {
259 #[serde(default = "default_app_ui_kind")]
260 pub kind: AppUiKind,
261 pub entry: String,
262 pub title: String,
263 #[serde(default)]
264 pub icon: Option<String>,
265 #[serde(default)]
266 pub nav: Option<AppUiNav>,
267 #[serde(default)]
268 pub composes: Vec<String>,
269 pub ui_api: u8,
270 #[serde(default)]
271 pub integrity: BTreeMap<String, String>,
272}
273
274pub fn validate_manifest_path(path: &str) -> Result<(), String> {
286 if path.is_empty() {
287 return Err("path must not be empty".to_string());
288 }
289
290 if path.starts_with('/') {
292 return Err(format!(
293 "path '{}' must not be absolute (starts with /)",
294 path
295 ));
296 }
297
298 let first = path.chars().next().unwrap();
301 if !first.is_ascii_alphanumeric() && first != '_' {
302 return Err(format!(
303 "path '{}' must begin with an alphanumeric character or underscore",
304 path
305 ));
306 }
307 for ch in path.chars().skip(1) {
308 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
309 return Err(format!(
310 "path '{}' contains disallowed character '{}'",
311 path, ch
312 ));
313 }
314 }
315
316 for segment in path.split('/') {
318 if segment == ".." {
319 return Err(format!(
320 "path '{}' contains a '..' segment (path traversal rejected)",
321 path
322 ));
323 }
324 }
325
326 Ok(())
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct AppManifest {
338 #[serde(default = "default_manifest_version", rename = "manifest_version")]
340 pub manifest_version: u8,
341
342 pub name: String,
343 pub version: String,
344
345 #[serde(default = "default_app_type_native")]
346 pub app_type: AppType,
347
348 #[serde(default)]
349 pub description: String,
350
351 pub abi: Option<AbiVersion>,
354
355 pub entrypoint: Option<String>,
358
359 pub hot_reload: Option<HotReloadKind>,
362
363 #[serde(default)]
365 pub critical: bool,
366
367 #[serde(
368 default = "default_auto_start",
369 deserialize_with = "deserialize_auto_start"
370 )]
371 pub auto_start: bool,
372
373 #[serde(default)]
374 pub has_ui: bool,
375
376 #[serde(default = "default_ui_path")]
377 pub ui_path: String,
378
379 #[serde(default)]
380 pub permissions: Vec<String>,
381
382 #[serde(default)]
385 pub requires: Vec<String>,
386
387 #[serde(default)]
388 pub optional_permissions: Vec<String>,
389
390 #[serde(default)]
391 pub provides_scopes: Vec<ProvidedScope>,
392
393 #[serde(default)]
394 pub endpoint_policies: Vec<EndpointPolicy>,
395
396 #[serde(default)]
397 pub capability_scopes: HashMap<String, String>,
398
399 #[serde(default)]
400 pub provides: HashMap<String, ProvidedCapability>,
401
402 #[serde(default)]
404 pub capabilities: ManifestCapabilities,
405
406 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub ui: Option<AppUiManifest>,
410
411 #[serde(default)]
413 pub author: Option<String>,
414
415 #[serde(default)]
416 pub homepage: Option<String>,
417
418 #[serde(default)]
419 pub depends_on: Option<Vec<String>>,
420
421 #[serde(default)]
422 pub boot_priority: Option<u32>,
423
424 #[serde(default)]
428 pub governor: Option<GovernorManifest>,
429
430 #[serde(default)]
436 pub subscribes: Vec<String>,
437
438 #[serde(default)]
442 pub standalone: Option<StandaloneConfig>,
443
444 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub tcp: Option<TcpManifest>,
448}
449
450#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
453pub struct GovernorManifest {
454 #[serde(default)]
458 pub terminable: Option<bool>,
459
460 #[serde(default)]
464 pub min_idle_secs: Option<u64>,
465}
466
467#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
471pub struct TcpManifest {
472 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub preferred_port: Option<u16>,
477
478 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub direct_bind: Option<bool>,
486}
487
488fn default_manifest_version() -> u8 {
489 1
490}
491
492fn default_auto_start() -> bool {
493 true
494}
495
496fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
502where
503 D: serde::Deserializer<'de>,
504{
505 #[derive(Deserialize)]
506 #[serde(untagged)]
507 enum BoolOrStr {
508 Bool(bool),
509 Str(String),
510 }
511 Ok(match BoolOrStr::deserialize(deserializer)? {
512 BoolOrStr::Bool(b) => b,
513 BoolOrStr::Str(s) => matches!(
514 s.trim().to_ascii_lowercase().as_str(),
515 "true" | "eager" | "active" | "auto" | "on" | "1"
516 ),
517 })
518}
519
520fn default_ui_path() -> String {
521 "dist".to_string()
522}
523
524fn default_app_type_native() -> AppType {
525 AppType::Native
526}
527
528impl AppManifest {
529 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
533 let top = normalized_requirements(&self.requires)?;
534 let nested = normalized_requirements(&self.capabilities.requires)?;
535 if !top.is_empty()
536 && !nested.is_empty()
537 && top.iter().cloned().collect::<BTreeSet<_>>()
538 != nested.iter().cloned().collect::<BTreeSet<_>>()
539 {
540 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
541 }
542 Ok(if !top.is_empty() { top } else { nested })
543 }
544
545 pub fn effective_hot_reload(&self) -> HotReloadKind {
548 self.hot_reload
549 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
550 }
551
552 pub fn effective_entrypoint(&self) -> &str {
557 if let Some(ref ep) = self.entrypoint {
558 ep.as_str()
559 } else {
560 match self.app_type {
561 AppType::Native => "app.so",
562 AppType::Bun => "dist/index.js",
563 AppType::Standalone => "",
564 AppType::PlatformRuntime => "bun",
565 }
566 }
567 }
568
569 pub fn has_capability_providers(&self) -> bool {
572 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
573 }
574
575 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
589 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
590 for raw in &self.capabilities.provides {
591 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
592 if name.is_empty() {
593 continue;
594 }
595 out.entry(name).or_insert(ProvidedCapability {
596 description: String::new(),
597 schema: None,
598 });
599 }
600 out
601 }
602
603 pub fn validate(&self) -> Result<(), String> {
608 self.validate_with_socket_path_policy(false)
609 }
610
611 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
616 if self.manifest_version == 2 && self.abi.is_none() {
618 return Err("manifest_version 2 requires an 'abi' field".to_string());
619 }
620
621 validate_app_name(&self.name)?;
624 self.resolved_requires()?;
625
626 if let Some(ref ep) = self.entrypoint {
628 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
629 }
630 if !self.ui_path.is_empty() && self.ui_path != "dist" {
632 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
633 }
634
635 if let Some(ui) = &self.ui {
636 validate_app_ui(&self.name, ui)?;
637 }
638
639 if let Some(ref hp) = self.homepage {
641 if !hp.starts_with("https://") && !hp.starts_with("http://") {
642 return Err(format!(
643 "homepage '{}' must use https:// or http:// scheme",
644 hp
645 ));
646 }
647 }
648
649 match self.app_type {
656 AppType::Standalone => {
657 if self.has_capability_providers() {
658 let cfg = self.standalone.as_ref().ok_or_else(|| {
659 "standalone apps that declare 'provides' require a \
660 'standalone.socket_path' field"
661 .to_string()
662 })?;
663 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
664 }
665 }
666 AppType::Native | AppType::Bun | AppType::PlatformRuntime => {
667 if self.standalone.is_some() {
668 return Err(format!(
669 "'standalone' block is only valid when app_type == 'standalone' \
670 (found app_type='{}')",
671 self.app_type
672 ));
673 }
674 }
675 }
676
677 if self.app_type == AppType::PlatformRuntime
678 && !self.resolved_capability_provides().is_empty()
679 {
680 return Err(
681 "platform-runtime packages cannot provide runtime capabilities".to_string(),
682 );
683 }
684
685 Ok(())
686 }
687
688 pub fn from_json(json: &str) -> Result<Self, String> {
690 Self::from_json_with_socket_path_policy(json, false)
691 }
692
693 pub fn from_json_with_socket_path_policy(
695 json: &str,
696 allow_non_run: bool,
697 ) -> Result<Self, String> {
698 let mut manifest: Self =
699 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
700 if manifest.ui.is_some() {
701 manifest.has_ui = true;
702 }
703 manifest.validate_with_socket_path_policy(allow_non_run)?;
704 Ok(manifest)
705 }
706}
707
708fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
709 let mut seen = HashSet::new();
710 let mut resolved = Vec::new();
711 for value in values {
712 let requirement = value.trim();
713 if requirement.is_empty() {
714 return Err("capability requirements must not be blank".to_string());
715 }
716 if seen.insert(requirement.to_string()) {
717 resolved.push(requirement.to_string());
718 }
719 }
720 Ok(resolved)
721}
722
723fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
724 if ui.ui_api != 1 {
725 return Err(format!(
726 "ui.ui_api {} is unsupported; only version 1 is accepted",
727 ui.ui_api
728 ));
729 }
730 if ui.title.trim().is_empty() {
731 return Err("ui.title must not be blank".to_string());
732 }
733 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
734 if let Some(icon) = &ui.icon {
735 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
736 }
737 if let Some(nav) = &ui.nav {
738 if ui.kind == AppUiKind::Widget {
739 return Err("widget ui must omit nav metadata".to_string());
740 }
741 if nav.section.trim().is_empty() {
742 return Err("ui.nav.section must not be blank".to_string());
743 }
744 }
745
746 let mut composed = HashSet::new();
747 for name in &ui.composes {
748 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
749 if name == app_name {
750 return Err("ui.composes must not contain the app itself".to_string());
751 }
752 if !composed.insert(name) {
753 return Err(format!("ui.composes contains duplicate app '{name}'"));
754 }
755 }
756
757 for (path, digest) in &ui.integrity {
758 validate_manifest_path(path)
759 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
760 if !is_lowercase_sha256(digest) {
761 return Err(format!(
762 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
763 ));
764 }
765 }
766 if !ui.integrity.contains_key(&ui.entry) {
767 return Err("ui.integrity must include the declared entry".to_string());
768 }
769 if let Some(icon) = &ui.icon {
770 if !ui.integrity.contains_key(icon) {
771 return Err("ui.integrity must include the declared icon".to_string());
772 }
773 }
774 Ok(())
775}
776
777fn is_lowercase_sha256(value: &str) -> bool {
778 value.len() == 64
779 && value
780 .bytes()
781 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
782}
783
784pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
793 validate_standalone_socket_path_with_policy(path, false)
794}
795
796pub fn validate_standalone_socket_path_with_policy(
798 path: &std::path::Path,
799 allow_non_run: bool,
800) -> Result<(), String> {
801 if !path.is_absolute() {
802 return Err(format!(
803 "standalone.socket_path '{}' must be absolute",
804 path.display()
805 ));
806 }
807 if !allow_non_run && !path.starts_with("/run/") {
808 return Err(format!(
809 "standalone.socket_path '{}' must live under /run/",
810 path.display()
811 ));
812 }
813 if path
814 .components()
815 .any(|c| matches!(c, std::path::Component::ParentDir))
816 {
817 return Err(format!(
818 "standalone.socket_path '{}' must not contain '..' segments",
819 path.display()
820 ));
821 }
822 Ok(())
823}
824
825fn validate_app_name(name: &str) -> Result<(), String> {
829 let (publisher, app) = if let Some(slash) = name.find('/') {
830 let (p, rest) = name.split_at(slash);
831 (Some(p), &rest[1..])
832 } else {
833 (None, name)
834 };
835
836 let valid_segment = |s: &str| -> bool {
837 if s.is_empty() {
838 return false;
839 }
840 let mut chars = s.chars();
841 let first = chars.next().unwrap();
842 if !first.is_ascii_lowercase() {
843 return false;
844 }
845 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
846 };
847
848 if let Some(pub_name) = publisher {
849 if !valid_segment(pub_name) {
850 return Err(format!(
851 "publisher segment '{}' must match [a-z][a-z0-9-]*",
852 pub_name
853 ));
854 }
855 }
856
857 if !valid_segment(app) {
858 return Err(format!(
859 "app name segment '{}' must match [a-z][a-z0-9-]*",
860 app
861 ));
862 }
863
864 Ok(())
865}
866
867#[cfg(test)]
870mod tests {
871 use super::*;
872
873 fn parse_ok(json: &str) -> AppManifest {
874 AppManifest::from_json(json).expect("should parse")
875 }
876
877 fn parse_err(json: &str) -> String {
878 AppManifest::from_json(json).expect_err("should fail")
879 }
880
881 #[test]
884 fn v1_minimal_native() {
885 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
886 assert_eq!(m.manifest_version, 1);
887 assert_eq!(m.app_type, AppType::Native);
888 assert!(m.abi.is_none());
889 }
890
891 #[test]
892 fn v1_minimal_bun() {
893 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
894 assert_eq!(m.app_type, AppType::Bun);
895 assert_eq!(m.effective_entrypoint(), "dist/index.js");
896 }
897
898 #[test]
899 fn v1_no_manifest_version_field_defaults_to_1() {
900 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
901 assert_eq!(m.manifest_version, 1);
902 }
903
904 #[test]
905 fn v1_all_optional_fields_missing() {
906 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
907 assert!(!m.critical);
908 assert!(m.auto_start);
909 assert!(!m.has_ui);
910 assert_eq!(m.ui_path, "dist");
911 assert!(m.permissions.is_empty());
912 assert!(m.optional_permissions.is_empty());
913 assert!(m.governor.is_none());
916 assert!(m.subscribes.is_empty());
917 }
918
919 #[test]
920 fn v1_with_permissions_and_provides() {
921 let json = r#"{
922 "name": "example",
923 "version": "1.0.0",
924 "app_type": "bun",
925 "permissions": ["core.storage.kv"],
926 "optional_permissions": ["core.notifications.create"],
927 "provides": {
928 "core.example.run": { "description": "Run example job" }
929 }
930 }"#;
931 let m = parse_ok(json);
932 assert_eq!(m.permissions, vec!["core.storage.kv"]);
933 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
934 assert!(m.provides.contains_key("core.example.run"));
935 }
936
937 #[test]
940 fn v2_minimal_native() {
941 let json = r#"{
942 "manifest_version": 2,
943 "name": "cron",
944 "version": "1.0.0",
945 "app_type": "native",
946 "abi": "v1",
947 "entrypoint": "app.so",
948 "hot_reload": "experimental"
949 }"#;
950 let m = parse_ok(json);
951 assert_eq!(m.manifest_version, 2);
952 assert_eq!(m.abi, Some(AbiVersion::V1));
953 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
954 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
955 }
956
957 #[test]
958 fn v2_minimal_bun_with_capabilities() {
959 let json = r#"{
960 "manifest_version": 2,
961 "name": "example-fullstack",
962 "version": "1.0.0",
963 "app_type": "bun",
964 "abi": "v1",
965 "entrypoint": "dist/index.js",
966 "hot_reload": "supported",
967 "has_ui": true,
968 "ui_path": "ui/dist",
969 "capabilities": {
970 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
971 "provides": []
972 },
973 "governor": { "terminable": false, "min_idle_secs": 300 },
974 "subscribes": ["core.chat.message.received"]
975 }"#;
976 let m = parse_ok(json);
977 assert_eq!(m.manifest_version, 2);
978 assert_eq!(m.capabilities.requires.len(), 2);
979 let governor = m.governor.expect("governor block should parse");
981 assert_eq!(governor.terminable, Some(false));
982 assert_eq!(governor.min_idle_secs, Some(300));
983 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
984 }
985
986 #[test]
987 fn stage_contract_fixture_parses_with_normalized_requirements() {
988 let json = include_str!(
989 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
990 );
991 let m = parse_ok(json);
992 assert!(m.has_ui);
993 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
994 let ui = m.ui.expect("fixture should declare ui");
995 assert_eq!(ui.kind, AppUiKind::Stage);
996 assert_eq!(ui.entry, "ui/main.js");
997 assert_eq!(ui.nav.unwrap().order, 10);
998 }
999
1000 #[test]
1001 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1002 let m = parse_ok(
1003 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1004 );
1005 assert!(m.has_ui);
1006 assert_eq!(m.ui_path, "ui/dist");
1007 assert!(m.ui.is_none());
1008 }
1009
1010 #[test]
1011 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1012 let accepted = parse_ok(
1013 r#"{
1014 "name":"aliases","version":"1.0.0","app_type":"bun",
1015 "requires":["core.chat.read","core.chat.read","core.chat.send"],
1016 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1017 }"#,
1018 );
1019 assert_eq!(
1020 accepted.resolved_requires().unwrap(),
1021 vec!["core.chat.read", "core.chat.send"]
1022 );
1023
1024 let err = parse_err(
1025 r#"{
1026 "name":"aliases","version":"1.0.0","app_type":"bun",
1027 "requires":["core.chat.read"],
1028 "capabilities":{"requires":["core.wallet.pay"]}
1029 }"#,
1030 );
1031 assert!(err.contains("conflicts"), "unexpected error: {err}");
1032 }
1033
1034 #[test]
1035 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1036 let base = |ui: &str| {
1037 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1038 };
1039 let widget = base(
1040 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1041 );
1042 assert_eq!(parse_ok(&widget).ui.expect("widget ui").kind, AppUiKind::Widget);
1043 let widget_nav = base(
1044 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1045 );
1046 assert!(parse_err(&widget_nav).contains("must omit nav"));
1047 let api = base(
1048 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1049 );
1050 assert!(parse_err(&api).contains("ui_api"));
1051 let path = base(
1052 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1053 );
1054 assert!(parse_err(&path).contains("entry"));
1055 }
1056
1057 #[test]
1058 fn stage_requires_integrity_for_entry_and_icon() {
1059 let missing_entry = r#"{
1060 "name":"stage","version":"1.0.0","app_type":"bun",
1061 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1062 }"#;
1063 assert!(parse_err(missing_entry).contains("entry"));
1064 let missing_icon = r#"{
1065 "name":"stage","version":"1.0.0","app_type":"bun",
1066 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1067 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1068 }"#;
1069 assert!(parse_err(missing_icon).contains("icon"));
1070 let uppercase = r#"{
1071 "name":"stage","version":"1.0.0","app_type":"bun",
1072 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1073 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1074 }"#;
1075 assert!(parse_err(uppercase).contains("lowercase"));
1076 }
1077
1078 #[test]
1079 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1080 let manifest = |composes: &str| {
1081 format!(
1082 r#"{{
1083 "name":"stage","version":"1.0.0","app_type":"bun",
1084 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1085 "composes":{composes},
1086 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1087 }}"#
1088 )
1089 };
1090 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1091 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1092 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1093 }
1094
1095 #[test]
1096 fn v2_missing_abi_is_error() {
1097 let json = r#"{
1098 "manifest_version": 2,
1099 "name": "example",
1100 "version": "1.0.0",
1101 "app_type": "bun"
1102 }"#;
1103 let err = parse_err(json);
1104 assert!(err.contains("abi"), "expected abi error, got: {}", err);
1105 }
1106
1107 #[test]
1110 fn publisher_prefixed_name_accepted() {
1111 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1112 assert_eq!(m.name, "alice/weather");
1113 }
1114
1115 #[test]
1116 fn double_slash_name_rejected() {
1117 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1118 assert!(!err.is_empty());
1119 }
1120
1121 #[test]
1124 fn name_starting_with_digit_rejected() {
1125 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1126 assert!(!err.is_empty());
1127 }
1128
1129 #[test]
1130 fn name_with_uppercase_rejected() {
1131 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1132 assert!(!err.is_empty());
1133 }
1134
1135 #[test]
1136 fn empty_name_rejected() {
1137 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1138 assert!(!err.is_empty());
1139 }
1140
1141 #[test]
1144 fn path_traversal_double_dot_rejected() {
1145 let json = r#"{
1146 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1147 "app_type": "bun", "abi": "v1",
1148 "entrypoint": "../etc/passwd"
1149 }"#;
1150 let err = parse_err(json);
1151 assert!(err.contains(".."), "expected traversal error, got: {}", err);
1152 }
1153
1154 #[test]
1155 fn path_traversal_encoded_dot_not_decoded() {
1156 let json = r#"{
1158 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1159 "app_type": "bun", "abi": "v1",
1160 "entrypoint": "foo/../bar"
1161 }"#;
1162 let err = parse_err(json);
1163 assert!(!err.is_empty(), "should have failed: {}", err);
1164 }
1165
1166 #[test]
1167 fn absolute_path_rejected() {
1168 let json = r#"{
1169 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1170 "app_type": "bun", "abi": "v1",
1171 "entrypoint": "/usr/bin/sh"
1172 }"#;
1173 let err = parse_err(json);
1174 assert!(
1175 err.contains("absolute"),
1176 "expected absolute error, got: {}",
1177 err
1178 );
1179 }
1180
1181 #[test]
1182 fn shell_metachar_in_path_rejected() {
1183 let json = r#"{
1184 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1185 "app_type": "bun", "abi": "v1",
1186 "entrypoint": "dist/index.js;rm -rf /"
1187 }"#;
1188 let err = parse_err(json);
1189 assert!(!err.is_empty());
1190 }
1191
1192 #[test]
1193 fn valid_nested_path_accepted() {
1194 let json = r#"{
1195 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1196 "app_type": "bun", "abi": "v1",
1197 "entrypoint": "dist/index.js",
1198 "ui_path": "ui/dist"
1199 }"#;
1200 parse_ok(json);
1201 }
1202
1203 #[test]
1206 fn homepage_https_accepted() {
1207 let json = r#"{
1208 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1209 "homepage": "https://example.com"
1210 }"#;
1211 parse_ok(json);
1212 }
1213
1214 #[test]
1215 fn homepage_javascript_scheme_rejected() {
1216 let json = r#"{
1217 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1218 "homepage": "javascript:alert(1)"
1219 }"#;
1220 let err = parse_err(json);
1221 assert!(
1222 err.contains("scheme"),
1223 "expected scheme error, got: {}",
1224 err
1225 );
1226 }
1227
1228 #[test]
1229 fn homepage_file_scheme_rejected() {
1230 let json = r#"{
1231 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1232 "homepage": "file:///etc/passwd"
1233 }"#;
1234 let err = parse_err(json);
1235 assert!(!err.is_empty());
1236 }
1237
1238 #[test]
1241 fn effective_entrypoint_native_default() {
1242 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1243 assert_eq!(m.effective_entrypoint(), "app.so");
1244 }
1245
1246 #[test]
1247 fn effective_entrypoint_bun_default() {
1248 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1249 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1250 }
1251
1252 #[test]
1253 fn effective_hot_reload_native_default_is_experimental() {
1254 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1255 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
1256 }
1257
1258 #[test]
1259 fn effective_hot_reload_bun_default_is_supported() {
1260 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1261 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
1262 }
1263
1264 #[test]
1265 fn hot_reload_unsupported_explicit() {
1266 let json = r#"{
1267 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
1268 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
1269 }"#;
1270 let m = parse_ok(json);
1271 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
1272 }
1273
1274 #[test]
1277 fn validate_path_simple_valid() {
1278 assert!(validate_manifest_path("dist/index.js").is_ok());
1279 assert!(validate_manifest_path("app.so").is_ok());
1280 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
1281 assert!(validate_manifest_path("build_output/main").is_ok());
1282 }
1283
1284 #[test]
1285 fn validate_path_empty_rejected() {
1286 assert!(validate_manifest_path("").is_err());
1287 }
1288
1289 #[test]
1290 fn validate_path_absolute_rejected() {
1291 assert!(validate_manifest_path("/usr/bin/sh").is_err());
1292 }
1293
1294 #[test]
1295 fn validate_path_double_dot_segment_rejected() {
1296 assert!(validate_manifest_path("foo/../bar").is_err());
1297 assert!(validate_manifest_path("../etc/passwd").is_err());
1298 }
1299
1300 #[test]
1301 fn validate_path_leading_dot_rejected() {
1302 assert!(validate_manifest_path(".hidden").is_err());
1303 }
1304
1305 #[test]
1306 fn validate_path_null_byte_rejected() {
1307 let path = "foo\0bar";
1309 assert!(validate_manifest_path(path).is_err());
1310 }
1311
1312 #[test]
1313 fn standalone_socket_path_development_override_is_explicit_and_pure() {
1314 let path = std::path::Path::new("/tmp/node-app/example.sock");
1315 assert!(validate_standalone_socket_path(path).is_err());
1316 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
1317 assert!(validate_standalone_socket_path_with_policy(
1318 std::path::Path::new("/tmp/node-app/../escape.sock"),
1319 true,
1320 )
1321 .is_err());
1322 }
1323
1324 #[test]
1327 fn manifest_capabilities_defaults_to_empty() {
1328 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1329 assert!(m.capabilities.requires.is_empty());
1330 assert!(m.capabilities.provides.is_empty());
1331 }
1332
1333 #[test]
1336 fn resolved_capability_provides_v1_only() {
1337 let json = r#"{
1338 "name": "example", "version": "1.0.0", "app_type": "bun",
1339 "provides": { "core.example.run": { "description": "Run example job" } }
1340 }"#;
1341 let m = parse_ok(json);
1342 let out = m.resolved_capability_provides();
1343 assert_eq!(out.len(), 1);
1344 assert_eq!(
1345 out.get("core.example.run").unwrap().description,
1346 "Run example job"
1347 );
1348 }
1349
1350 #[test]
1351 fn resolved_capability_provides_v2_names_get_blank_declaration() {
1352 let json = r#"{
1353 "manifest_version": 2, "name": "example", "version": "1.0.0",
1354 "app_type": "bun", "abi": "v1",
1355 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
1356 }"#;
1357 let m = parse_ok(json);
1358 let out = m.resolved_capability_provides();
1359 assert_eq!(out.len(), 2);
1360 assert_eq!(out.get("core.example.run").unwrap().description, "");
1361 assert!(out.get("core.example.run").unwrap().schema.is_none());
1362 assert!(out.contains_key("core.example.other"));
1364 assert!(!out.contains_key("core.example.other:extra"));
1365 }
1366
1367 #[test]
1368 fn resolved_capability_provides_v1_wins_on_conflict() {
1369 let json = r#"{
1370 "manifest_version": 2, "name": "example", "version": "1.0.0",
1371 "app_type": "bun", "abi": "v1",
1372 "provides": { "core.example.run": { "description": "v1 wins" } },
1373 "capabilities": { "requires": [], "provides": ["core.example.run"] }
1374 }"#;
1375 let m = parse_ok(json);
1376 let out = m.resolved_capability_provides();
1377 assert_eq!(out.len(), 1);
1378 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
1379 }
1380
1381 #[test]
1382 fn resolved_capability_provides_blank_v2_name_skipped() {
1383 let json = r#"{
1384 "manifest_version": 2, "name": "example", "version": "1.0.0",
1385 "app_type": "bun", "abi": "v1",
1386 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
1387 }"#;
1388 let m = parse_ok(json);
1389 let out = m.resolved_capability_provides();
1390 assert_eq!(out.len(), 1);
1391 assert!(out.contains_key("core.example.run"));
1392 }
1393
1394 #[test]
1395 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
1396 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1397 assert!(m.resolved_capability_provides().is_empty());
1398 }
1399
1400 #[test]
1403 fn abi_v1_is_supported() {
1404 assert!(AbiVersion::V1.is_supported());
1405 }
1406
1407 #[test]
1410 fn app_tier_display() {
1411 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
1412 assert_eq!(AppTier::Optional.to_string(), "optional");
1413 assert_eq!(AppTier::Development.to_string(), "development");
1414 }
1415
1416 #[test]
1417 fn app_tier_serde_roundtrip() {
1418 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
1420 let json = serde_json::to_string(&tier).unwrap();
1421 let back: AppTier = serde_json::from_str(&json).unwrap();
1422 assert_eq!(
1423 tier, back,
1424 "roundtrip failed for {:?}: serialized as {}",
1425 tier, json
1426 );
1427 }
1428 assert_eq!(
1429 serde_json::to_string(&AppTier::Development).unwrap(),
1430 "\"development\""
1431 );
1432 }
1433
1434 #[test]
1437 fn app_type_display() {
1438 assert_eq!(AppType::Native.to_string(), "native");
1439 assert_eq!(AppType::Bun.to_string(), "bun");
1440 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
1441 }
1442
1443 #[test]
1444 fn platform_runtime_is_a_supported_packaging_type() {
1445 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
1446 "manifest_version": 2,
1447 "abi": "v1",
1448 "name": "bun-runtime",
1449 "version": "1.0.0",
1450 "app_type": "platform-runtime",
1451 "entrypoint": "bun"
1452 }))
1453 .expect("platform runtime manifest should parse");
1454
1455 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
1456 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
1457 }
1458}