1use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
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
231pub fn validate_manifest_path(path: &str) -> Result<(), String> {
243 if path.is_empty() {
244 return Err("path must not be empty".to_string());
245 }
246
247 if path.starts_with('/') {
249 return Err(format!(
250 "path '{}' must not be absolute (starts with /)",
251 path
252 ));
253 }
254
255 let first = path.chars().next().unwrap();
258 if !first.is_ascii_alphanumeric() && first != '_' {
259 return Err(format!(
260 "path '{}' must begin with an alphanumeric character or underscore",
261 path
262 ));
263 }
264 for ch in path.chars().skip(1) {
265 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
266 return Err(format!(
267 "path '{}' contains disallowed character '{}'",
268 path, ch
269 ));
270 }
271 }
272
273 for segment in path.split('/') {
275 if segment == ".." {
276 return Err(format!(
277 "path '{}' contains a '..' segment (path traversal rejected)",
278 path
279 ));
280 }
281 }
282
283 Ok(())
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct AppManifest {
295 #[serde(default = "default_manifest_version", rename = "manifest_version")]
297 pub manifest_version: u8,
298
299 pub name: String,
300 pub version: String,
301
302 #[serde(default = "default_app_type_native")]
303 pub app_type: AppType,
304
305 #[serde(default)]
306 pub description: String,
307
308 pub abi: Option<AbiVersion>,
311
312 pub entrypoint: Option<String>,
315
316 pub hot_reload: Option<HotReloadKind>,
319
320 #[serde(default)]
322 pub critical: bool,
323
324 #[serde(
325 default = "default_auto_start",
326 deserialize_with = "deserialize_auto_start"
327 )]
328 pub auto_start: bool,
329
330 #[serde(default)]
331 pub has_ui: bool,
332
333 #[serde(default = "default_ui_path")]
334 pub ui_path: String,
335
336 #[serde(default)]
337 pub permissions: Vec<String>,
338
339 #[serde(default)]
340 pub optional_permissions: Vec<String>,
341
342 #[serde(default)]
343 pub provides_scopes: Vec<ProvidedScope>,
344
345 #[serde(default)]
346 pub endpoint_policies: Vec<EndpointPolicy>,
347
348 #[serde(default)]
349 pub capability_scopes: HashMap<String, String>,
350
351 #[serde(default)]
352 pub provides: HashMap<String, ProvidedCapability>,
353
354 #[serde(default)]
356 pub capabilities: ManifestCapabilities,
357
358 #[serde(default)]
360 pub author: Option<String>,
361
362 #[serde(default)]
363 pub homepage: Option<String>,
364
365 #[serde(default)]
366 pub depends_on: Option<Vec<String>>,
367
368 #[serde(default)]
369 pub boot_priority: Option<u32>,
370
371 #[serde(default)]
375 pub standalone: Option<StandaloneConfig>,
376
377 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub tcp: Option<TcpManifest>,
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
387pub struct TcpManifest {
388 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub preferred_port: Option<u16>,
393
394 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub direct_bind: Option<bool>,
402}
403
404fn default_manifest_version() -> u8 {
405 1
406}
407
408fn default_auto_start() -> bool {
409 true
410}
411
412fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
418where
419 D: serde::Deserializer<'de>,
420{
421 #[derive(Deserialize)]
422 #[serde(untagged)]
423 enum BoolOrStr {
424 Bool(bool),
425 Str(String),
426 }
427 Ok(match BoolOrStr::deserialize(deserializer)? {
428 BoolOrStr::Bool(b) => b,
429 BoolOrStr::Str(s) => matches!(
430 s.trim().to_ascii_lowercase().as_str(),
431 "true" | "eager" | "active" | "auto" | "on" | "1"
432 ),
433 })
434}
435
436fn default_ui_path() -> String {
437 "dist".to_string()
438}
439
440fn default_app_type_native() -> AppType {
441 AppType::Native
442}
443
444impl AppManifest {
445 pub fn effective_hot_reload(&self) -> HotReloadKind {
448 self.hot_reload
449 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
450 }
451
452 pub fn effective_entrypoint(&self) -> &str {
457 if let Some(ref ep) = self.entrypoint {
458 ep.as_str()
459 } else {
460 match self.app_type {
461 AppType::Native => "app.so",
462 AppType::Bun => "dist/index.js",
463 AppType::Standalone => "",
464 AppType::PlatformRuntime => "bun",
465 }
466 }
467 }
468
469 pub fn has_capability_providers(&self) -> bool {
472 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
473 }
474
475 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
489 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
490 for raw in &self.capabilities.provides {
491 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
492 if name.is_empty() {
493 continue;
494 }
495 out.entry(name).or_insert(ProvidedCapability {
496 description: String::new(),
497 schema: None,
498 });
499 }
500 out
501 }
502
503 pub fn validate(&self) -> Result<(), String> {
508 self.validate_with_socket_path_policy(false)
509 }
510
511 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
516 if self.manifest_version == 2 && self.abi.is_none() {
518 return Err("manifest_version 2 requires an 'abi' field".to_string());
519 }
520
521 validate_app_name(&self.name)?;
524
525 if let Some(ref ep) = self.entrypoint {
527 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
528 }
529 if !self.ui_path.is_empty() && self.ui_path != "dist" {
531 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
532 }
533
534 if let Some(ref hp) = self.homepage {
536 if !hp.starts_with("https://") && !hp.starts_with("http://") {
537 return Err(format!(
538 "homepage '{}' must use https:// or http:// scheme",
539 hp
540 ));
541 }
542 }
543
544 match self.app_type {
551 AppType::Standalone => {
552 if self.has_capability_providers() {
553 let cfg = self.standalone.as_ref().ok_or_else(|| {
554 "standalone apps that declare 'provides' require a \
555 'standalone.socket_path' field"
556 .to_string()
557 })?;
558 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
559 }
560 }
561 AppType::Native | AppType::Bun | AppType::PlatformRuntime => {
562 if self.standalone.is_some() {
563 return Err(format!(
564 "'standalone' block is only valid when app_type == 'standalone' \
565 (found app_type='{}')",
566 self.app_type
567 ));
568 }
569 }
570 }
571
572 if self.app_type == AppType::PlatformRuntime
573 && !self.resolved_capability_provides().is_empty()
574 {
575 return Err(
576 "platform-runtime packages cannot provide runtime capabilities".to_string(),
577 );
578 }
579
580 Ok(())
581 }
582
583 pub fn from_json(json: &str) -> Result<Self, String> {
585 Self::from_json_with_socket_path_policy(json, false)
586 }
587
588 pub fn from_json_with_socket_path_policy(
590 json: &str,
591 allow_non_run: bool,
592 ) -> Result<Self, String> {
593 let manifest: Self =
594 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
595 manifest.validate_with_socket_path_policy(allow_non_run)?;
596 Ok(manifest)
597 }
598}
599
600pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
609 validate_standalone_socket_path_with_policy(path, false)
610}
611
612pub fn validate_standalone_socket_path_with_policy(
614 path: &std::path::Path,
615 allow_non_run: bool,
616) -> Result<(), String> {
617 if !path.is_absolute() {
618 return Err(format!(
619 "standalone.socket_path '{}' must be absolute",
620 path.display()
621 ));
622 }
623 if !allow_non_run && !path.starts_with("/run/") {
624 return Err(format!(
625 "standalone.socket_path '{}' must live under /run/",
626 path.display()
627 ));
628 }
629 if path
630 .components()
631 .any(|c| matches!(c, std::path::Component::ParentDir))
632 {
633 return Err(format!(
634 "standalone.socket_path '{}' must not contain '..' segments",
635 path.display()
636 ));
637 }
638 Ok(())
639}
640
641fn validate_app_name(name: &str) -> Result<(), String> {
645 let (publisher, app) = if let Some(slash) = name.find('/') {
646 let (p, rest) = name.split_at(slash);
647 (Some(p), &rest[1..])
648 } else {
649 (None, name)
650 };
651
652 let valid_segment = |s: &str| -> bool {
653 if s.is_empty() {
654 return false;
655 }
656 let mut chars = s.chars();
657 let first = chars.next().unwrap();
658 if !first.is_ascii_lowercase() {
659 return false;
660 }
661 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
662 };
663
664 if let Some(pub_name) = publisher {
665 if !valid_segment(pub_name) {
666 return Err(format!(
667 "publisher segment '{}' must match [a-z][a-z0-9-]*",
668 pub_name
669 ));
670 }
671 }
672
673 if !valid_segment(app) {
674 return Err(format!(
675 "app name segment '{}' must match [a-z][a-z0-9-]*",
676 app
677 ));
678 }
679
680 Ok(())
681}
682
683#[cfg(test)]
686mod tests {
687 use super::*;
688
689 fn parse_ok(json: &str) -> AppManifest {
690 AppManifest::from_json(json).expect("should parse")
691 }
692
693 fn parse_err(json: &str) -> String {
694 AppManifest::from_json(json).expect_err("should fail")
695 }
696
697 #[test]
700 fn v1_minimal_native() {
701 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
702 assert_eq!(m.manifest_version, 1);
703 assert_eq!(m.app_type, AppType::Native);
704 assert!(m.abi.is_none());
705 }
706
707 #[test]
708 fn v1_minimal_bun() {
709 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
710 assert_eq!(m.app_type, AppType::Bun);
711 assert_eq!(m.effective_entrypoint(), "dist/index.js");
712 }
713
714 #[test]
715 fn v1_no_manifest_version_field_defaults_to_1() {
716 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
717 assert_eq!(m.manifest_version, 1);
718 }
719
720 #[test]
721 fn v1_all_optional_fields_missing() {
722 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
723 assert!(!m.critical);
724 assert!(m.auto_start);
725 assert!(!m.has_ui);
726 assert_eq!(m.ui_path, "dist");
727 assert!(m.permissions.is_empty());
728 assert!(m.optional_permissions.is_empty());
729 }
730
731 #[test]
732 fn v1_with_permissions_and_provides() {
733 let json = r#"{
734 "name": "example",
735 "version": "1.0.0",
736 "app_type": "bun",
737 "permissions": ["core.storage.kv"],
738 "optional_permissions": ["core.notifications.create"],
739 "provides": {
740 "core.example.run": { "description": "Run example job" }
741 }
742 }"#;
743 let m = parse_ok(json);
744 assert_eq!(m.permissions, vec!["core.storage.kv"]);
745 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
746 assert!(m.provides.contains_key("core.example.run"));
747 }
748
749 #[test]
752 fn v2_minimal_native() {
753 let json = r#"{
754 "manifest_version": 2,
755 "name": "cron",
756 "version": "1.0.0",
757 "app_type": "native",
758 "abi": "v1",
759 "entrypoint": "app.so",
760 "hot_reload": "experimental"
761 }"#;
762 let m = parse_ok(json);
763 assert_eq!(m.manifest_version, 2);
764 assert_eq!(m.abi, Some(AbiVersion::V1));
765 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
766 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
767 }
768
769 #[test]
770 fn v2_minimal_bun_with_capabilities() {
771 let json = r#"{
772 "manifest_version": 2,
773 "name": "example-fullstack",
774 "version": "1.0.0",
775 "app_type": "bun",
776 "abi": "v1",
777 "entrypoint": "dist/index.js",
778 "hot_reload": "supported",
779 "has_ui": true,
780 "ui_path": "ui/dist",
781 "capabilities": {
782 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
783 "provides": []
784 }
785 }"#;
786 let m = parse_ok(json);
787 assert_eq!(m.manifest_version, 2);
788 assert_eq!(m.capabilities.requires.len(), 2);
789 }
790
791 #[test]
792 fn v2_missing_abi_is_error() {
793 let json = r#"{
794 "manifest_version": 2,
795 "name": "example",
796 "version": "1.0.0",
797 "app_type": "bun"
798 }"#;
799 let err = parse_err(json);
800 assert!(err.contains("abi"), "expected abi error, got: {}", err);
801 }
802
803 #[test]
806 fn publisher_prefixed_name_accepted() {
807 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
808 assert_eq!(m.name, "alice/weather");
809 }
810
811 #[test]
812 fn double_slash_name_rejected() {
813 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
814 assert!(!err.is_empty());
815 }
816
817 #[test]
820 fn name_starting_with_digit_rejected() {
821 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
822 assert!(!err.is_empty());
823 }
824
825 #[test]
826 fn name_with_uppercase_rejected() {
827 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
828 assert!(!err.is_empty());
829 }
830
831 #[test]
832 fn empty_name_rejected() {
833 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
834 assert!(!err.is_empty());
835 }
836
837 #[test]
840 fn path_traversal_double_dot_rejected() {
841 let json = r#"{
842 "manifest_version": 2, "name": "evil", "version": "1.0.0",
843 "app_type": "bun", "abi": "v1",
844 "entrypoint": "../etc/passwd"
845 }"#;
846 let err = parse_err(json);
847 assert!(err.contains(".."), "expected traversal error, got: {}", err);
848 }
849
850 #[test]
851 fn path_traversal_encoded_dot_not_decoded() {
852 let json = r#"{
854 "manifest_version": 2, "name": "evil", "version": "1.0.0",
855 "app_type": "bun", "abi": "v1",
856 "entrypoint": "foo/../bar"
857 }"#;
858 let err = parse_err(json);
859 assert!(!err.is_empty(), "should have failed: {}", err);
860 }
861
862 #[test]
863 fn absolute_path_rejected() {
864 let json = r#"{
865 "manifest_version": 2, "name": "evil", "version": "1.0.0",
866 "app_type": "bun", "abi": "v1",
867 "entrypoint": "/usr/bin/sh"
868 }"#;
869 let err = parse_err(json);
870 assert!(
871 err.contains("absolute"),
872 "expected absolute error, got: {}",
873 err
874 );
875 }
876
877 #[test]
878 fn shell_metachar_in_path_rejected() {
879 let json = r#"{
880 "manifest_version": 2, "name": "evil", "version": "1.0.0",
881 "app_type": "bun", "abi": "v1",
882 "entrypoint": "dist/index.js;rm -rf /"
883 }"#;
884 let err = parse_err(json);
885 assert!(!err.is_empty());
886 }
887
888 #[test]
889 fn valid_nested_path_accepted() {
890 let json = r#"{
891 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
892 "app_type": "bun", "abi": "v1",
893 "entrypoint": "dist/index.js",
894 "ui_path": "ui/dist"
895 }"#;
896 parse_ok(json);
897 }
898
899 #[test]
902 fn homepage_https_accepted() {
903 let json = r#"{
904 "name": "my-app", "version": "1.0.0", "app_type": "bun",
905 "homepage": "https://example.com"
906 }"#;
907 parse_ok(json);
908 }
909
910 #[test]
911 fn homepage_javascript_scheme_rejected() {
912 let json = r#"{
913 "name": "my-app", "version": "1.0.0", "app_type": "bun",
914 "homepage": "javascript:alert(1)"
915 }"#;
916 let err = parse_err(json);
917 assert!(
918 err.contains("scheme"),
919 "expected scheme error, got: {}",
920 err
921 );
922 }
923
924 #[test]
925 fn homepage_file_scheme_rejected() {
926 let json = r#"{
927 "name": "my-app", "version": "1.0.0", "app_type": "bun",
928 "homepage": "file:///etc/passwd"
929 }"#;
930 let err = parse_err(json);
931 assert!(!err.is_empty());
932 }
933
934 #[test]
937 fn effective_entrypoint_native_default() {
938 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
939 assert_eq!(m.effective_entrypoint(), "app.so");
940 }
941
942 #[test]
943 fn effective_entrypoint_bun_default() {
944 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
945 assert_eq!(m.effective_entrypoint(), "dist/index.js");
946 }
947
948 #[test]
949 fn effective_hot_reload_native_default_is_experimental() {
950 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
951 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
952 }
953
954 #[test]
955 fn effective_hot_reload_bun_default_is_supported() {
956 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
957 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
958 }
959
960 #[test]
961 fn hot_reload_unsupported_explicit() {
962 let json = r#"{
963 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
964 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
965 }"#;
966 let m = parse_ok(json);
967 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
968 }
969
970 #[test]
973 fn validate_path_simple_valid() {
974 assert!(validate_manifest_path("dist/index.js").is_ok());
975 assert!(validate_manifest_path("app.so").is_ok());
976 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
977 assert!(validate_manifest_path("build_output/main").is_ok());
978 }
979
980 #[test]
981 fn validate_path_empty_rejected() {
982 assert!(validate_manifest_path("").is_err());
983 }
984
985 #[test]
986 fn validate_path_absolute_rejected() {
987 assert!(validate_manifest_path("/usr/bin/sh").is_err());
988 }
989
990 #[test]
991 fn validate_path_double_dot_segment_rejected() {
992 assert!(validate_manifest_path("foo/../bar").is_err());
993 assert!(validate_manifest_path("../etc/passwd").is_err());
994 }
995
996 #[test]
997 fn validate_path_leading_dot_rejected() {
998 assert!(validate_manifest_path(".hidden").is_err());
999 }
1000
1001 #[test]
1002 fn validate_path_null_byte_rejected() {
1003 let path = "foo\0bar";
1005 assert!(validate_manifest_path(path).is_err());
1006 }
1007
1008 #[test]
1009 fn standalone_socket_path_development_override_is_explicit_and_pure() {
1010 let path = std::path::Path::new("/tmp/node-app/example.sock");
1011 assert!(validate_standalone_socket_path(path).is_err());
1012 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
1013 assert!(validate_standalone_socket_path_with_policy(
1014 std::path::Path::new("/tmp/node-app/../escape.sock"),
1015 true,
1016 )
1017 .is_err());
1018 }
1019
1020 #[test]
1023 fn manifest_capabilities_defaults_to_empty() {
1024 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1025 assert!(m.capabilities.requires.is_empty());
1026 assert!(m.capabilities.provides.is_empty());
1027 }
1028
1029 #[test]
1032 fn resolved_capability_provides_v1_only() {
1033 let json = r#"{
1034 "name": "example", "version": "1.0.0", "app_type": "bun",
1035 "provides": { "core.example.run": { "description": "Run example job" } }
1036 }"#;
1037 let m = parse_ok(json);
1038 let out = m.resolved_capability_provides();
1039 assert_eq!(out.len(), 1);
1040 assert_eq!(
1041 out.get("core.example.run").unwrap().description,
1042 "Run example job"
1043 );
1044 }
1045
1046 #[test]
1047 fn resolved_capability_provides_v2_names_get_blank_declaration() {
1048 let json = r#"{
1049 "manifest_version": 2, "name": "example", "version": "1.0.0",
1050 "app_type": "bun", "abi": "v1",
1051 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
1052 }"#;
1053 let m = parse_ok(json);
1054 let out = m.resolved_capability_provides();
1055 assert_eq!(out.len(), 2);
1056 assert_eq!(out.get("core.example.run").unwrap().description, "");
1057 assert!(out.get("core.example.run").unwrap().schema.is_none());
1058 assert!(out.contains_key("core.example.other"));
1060 assert!(!out.contains_key("core.example.other:extra"));
1061 }
1062
1063 #[test]
1064 fn resolved_capability_provides_v1_wins_on_conflict() {
1065 let json = r#"{
1066 "manifest_version": 2, "name": "example", "version": "1.0.0",
1067 "app_type": "bun", "abi": "v1",
1068 "provides": { "core.example.run": { "description": "v1 wins" } },
1069 "capabilities": { "requires": [], "provides": ["core.example.run"] }
1070 }"#;
1071 let m = parse_ok(json);
1072 let out = m.resolved_capability_provides();
1073 assert_eq!(out.len(), 1);
1074 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
1075 }
1076
1077 #[test]
1078 fn resolved_capability_provides_blank_v2_name_skipped() {
1079 let json = r#"{
1080 "manifest_version": 2, "name": "example", "version": "1.0.0",
1081 "app_type": "bun", "abi": "v1",
1082 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
1083 }"#;
1084 let m = parse_ok(json);
1085 let out = m.resolved_capability_provides();
1086 assert_eq!(out.len(), 1);
1087 assert!(out.contains_key("core.example.run"));
1088 }
1089
1090 #[test]
1091 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
1092 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1093 assert!(m.resolved_capability_provides().is_empty());
1094 }
1095
1096 #[test]
1099 fn abi_v1_is_supported() {
1100 assert!(AbiVersion::V1.is_supported());
1101 }
1102
1103 #[test]
1106 fn app_tier_display() {
1107 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
1108 assert_eq!(AppTier::Optional.to_string(), "optional");
1109 assert_eq!(AppTier::Development.to_string(), "development");
1110 }
1111
1112 #[test]
1113 fn app_tier_serde_roundtrip() {
1114 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
1116 let json = serde_json::to_string(&tier).unwrap();
1117 let back: AppTier = serde_json::from_str(&json).unwrap();
1118 assert_eq!(
1119 tier, back,
1120 "roundtrip failed for {:?}: serialized as {}",
1121 tier, json
1122 );
1123 }
1124 assert_eq!(
1125 serde_json::to_string(&AppTier::Development).unwrap(),
1126 "\"development\""
1127 );
1128 }
1129
1130 #[test]
1133 fn app_type_display() {
1134 assert_eq!(AppType::Native.to_string(), "native");
1135 assert_eq!(AppType::Bun.to_string(), "bun");
1136 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
1137 }
1138
1139 #[test]
1140 fn platform_runtime_is_a_supported_packaging_type() {
1141 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
1142 "manifest_version": 2,
1143 "abi": "v1",
1144 "name": "bun-runtime",
1145 "version": "1.0.0",
1146 "app_type": "platform-runtime",
1147 "entrypoint": "bun"
1148 }))
1149 .expect("platform runtime manifest should parse");
1150
1151 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
1152 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
1153 }
1154}