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 #[serde(rename = "managed-v1")]
54 ManagedV1,
55 Burger,
58 #[serde(rename = "ui-only")]
65 UiOnly,
66}
67
68impl AppType {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 AppType::Native => "native",
72 AppType::Bun => "bun",
73 AppType::Standalone => "standalone",
74 AppType::PlatformRuntime => "platform-runtime",
75 AppType::ManagedV1 => "managed-v1",
76 AppType::Burger => "burger",
77 AppType::UiOnly => "ui-only",
78 }
79 }
80}
81
82impl std::fmt::Display for AppType {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 write!(f, "{}", self.as_str())
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum AppTier {
95 FirstParty,
99 Optional,
103 Development,
114}
115
116impl AppTier {
117 pub fn as_str(self) -> &'static str {
118 match self {
119 AppTier::FirstParty => "first_party",
120 AppTier::Optional => "optional",
121 AppTier::Development => "development",
122 }
123 }
124}
125
126impl std::fmt::Display for AppTier {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 write!(f, "{}", self.as_str())
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum AbiVersion {
139 V1,
140}
141
142impl AbiVersion {
143 pub fn as_str(&self) -> &'static str {
144 match self {
145 AbiVersion::V1 => "v1",
146 }
147 }
148
149 pub fn is_supported(&self) -> bool {
151 matches!(self, AbiVersion::V1)
152 }
153}
154
155impl std::fmt::Display for AbiVersion {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 write!(f, "{}", self.as_str())
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum HotReloadKind {
168 Supported,
170 Experimental,
180 Unsupported,
182}
183
184impl HotReloadKind {
185 pub fn default_for(app_type: AppType) -> Self {
186 match app_type {
187 AppType::Native => HotReloadKind::Experimental,
188 AppType::Bun => HotReloadKind::Supported,
189 AppType::Standalone => HotReloadKind::Unsupported,
192 AppType::PlatformRuntime => HotReloadKind::Unsupported,
193 AppType::ManagedV1 => HotReloadKind::Supported,
194 AppType::Burger => HotReloadKind::Supported,
195 AppType::UiOnly => HotReloadKind::Unsupported,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct ManifestCapabilities {
209 #[serde(default)]
212 pub requires: Vec<String>,
213
214 #[serde(default)]
217 pub provides: Vec<String>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, Default)]
222pub struct ProvidedScope {
223 pub scope: String,
224 pub description: String,
225 pub resource_pattern: String,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct EndpointPolicy {
231 pub method: String,
232 pub path: String,
233 pub required_permissions: Vec<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ProvidedCapability {
239 #[serde(default)]
240 pub description: String,
241 #[serde(default)]
242 pub schema: Option<serde_json::Value>,
243 #[serde(
248 default = "default_discoverable",
249 skip_serializing_if = "is_discoverable"
250 )]
251 pub discoverable: bool,
252}
253
254fn default_discoverable() -> bool {
255 true
256}
257
258fn is_discoverable(value: &bool) -> bool {
259 *value
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct StandaloneConfig {
274 pub socket_path: std::path::PathBuf,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "snake_case")]
280pub enum AppUiKind {
281 Stage,
282 Widget,
283}
284
285fn default_app_ui_kind() -> AppUiKind {
286 AppUiKind::Stage
287}
288
289fn default_nav_section() -> String {
290 "default".to_string()
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct AppUiNav {
296 #[serde(default = "default_nav_section")]
297 pub section: String,
298 #[serde(default)]
299 pub order: i32,
300}
301
302pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail"];
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323pub struct AppUiSurface {
324 pub id: String,
325 pub slot: String,
326 pub entry: String,
327 pub title: String,
328 #[serde(default)]
329 pub order: i32,
330 #[serde(default)]
331 pub requires: AppUiRequirements,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "kebab-case")]
337pub enum AppDataOfflinePolicy {
338 LastKnown,
340 OnlineOnly,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum AppDataQueryKind {
348 Collection,
349 Detail,
350 Snapshot,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "kebab-case")]
356pub enum AppDataStreamKind {
357 Changes,
358 Events,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "kebab-case")]
364pub enum AppDataSyncKind {
365 Cursor,
366 Snapshot,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct AppDataSyncPolicy {
372 pub kind: AppDataSyncKind,
373 pub cursor_ttl_secs: Option<u32>,
374 pub full_refresh_interval_secs: Option<u32>,
375 pub retention_secs: Option<u32>,
376}
377
378impl Serialize for AppDataSyncPolicy {
379 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
380 where
381 S: serde::Serializer,
382 {
383 use serde::ser::SerializeStruct;
384
385 if self.cursor_ttl_secs.is_none()
386 && self.full_refresh_interval_secs.is_none()
387 && self.retention_secs.is_none()
388 {
389 return self.kind.serialize(serializer);
390 }
391
392 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
393 state.serialize_field("kind", &self.kind)?;
394 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
395 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
396 }
397 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
398 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
399 }
400 if let Some(retention_secs) = self.retention_secs {
401 state.serialize_field("retention_secs", &retention_secs)?;
402 }
403 state.end()
404 }
405}
406
407impl<'de> Deserialize<'de> for AppDataSyncPolicy {
408 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
409 where
410 D: serde::Deserializer<'de>,
411 {
412 #[derive(Deserialize)]
413 #[serde(deny_unknown_fields)]
414 struct ObjectPolicy {
415 kind: AppDataSyncKind,
416 #[serde(default)]
417 cursor_ttl_secs: Option<u32>,
418 #[serde(default)]
419 full_refresh_interval_secs: Option<u32>,
420 #[serde(default)]
421 retention_secs: Option<u32>,
422 }
423
424 #[derive(Deserialize)]
425 #[serde(untagged)]
426 enum WirePolicy {
427 Kind(AppDataSyncKind),
428 Object(ObjectPolicy),
429 }
430
431 match WirePolicy::deserialize(deserializer)? {
432 WirePolicy::Kind(kind) => Ok(Self {
433 kind,
434 cursor_ttl_secs: None,
435 full_refresh_interval_secs: None,
436 retention_secs: None,
437 }),
438 WirePolicy::Object(policy) => Ok(Self {
439 kind: policy.kind,
440 cursor_ttl_secs: policy.cursor_ttl_secs,
441 full_refresh_interval_secs: policy.full_refresh_interval_secs,
442 retention_secs: policy.retention_secs,
443 }),
444 }
445 }
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct AppDataQueryDeclaration {
452 pub name: String,
453 pub capability: String,
454 pub kind: AppDataQueryKind,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460pub struct AppDataStreamDeclaration {
461 pub name: String,
462 pub kind: AppDataStreamKind,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(deny_unknown_fields)]
468pub struct AppDataManifest {
469 pub namespace: String,
470 pub offline: AppDataOfflinePolicy,
471 pub sync: AppDataSyncPolicy,
472 #[serde(default)]
473 pub queries: Vec<AppDataQueryDeclaration>,
474 #[serde(default)]
475 pub streams: Vec<AppDataStreamDeclaration>,
476}
477
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
483#[serde(deny_unknown_fields)]
484pub struct AppUiRequirements {
485 #[serde(default)]
486 pub capabilities: Vec<String>,
487 #[serde(default)]
488 pub queries: Vec<String>,
489 #[serde(default)]
490 pub streams: Vec<String>,
491}
492
493impl AppUiRequirements {
494 pub fn resolved(&self) -> Result<Vec<String>, String> {
498 let mut resolved = Vec::new();
499 let mut seen = HashSet::new();
500 for (values, allow_wildcard) in [
501 (&self.capabilities, true),
502 (&self.queries, false),
503 (&self.streams, false),
504 ] {
505 for value in values {
506 let requirement = value.trim();
507 if requirement.is_empty() {
508 return Err("ui.requires entries must not be blank".to_string());
509 }
510 validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
511 format!("ui.requires entry '{requirement}' invalid: {error}")
512 })?;
513 if seen.insert(requirement.to_string()) {
514 resolved.push(requirement.to_string());
515 }
516 }
517 }
518 Ok(resolved)
519 }
520}
521
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
524pub struct AppUiManifest {
525 #[serde(default = "default_app_ui_kind")]
526 pub kind: AppUiKind,
527 pub entry: String,
528 pub title: String,
529 #[serde(default)]
530 pub icon: Option<String>,
531 #[serde(default)]
532 pub nav: Option<AppUiNav>,
533 #[serde(default)]
534 pub composes: Vec<String>,
535 #[serde(default)]
537 pub surfaces: Vec<AppUiSurface>,
538 pub ui_api: u8,
539 #[serde(default)]
540 pub integrity: BTreeMap<String, String>,
541 #[serde(default)]
545 pub description: Option<String>,
546 #[serde(default)]
549 pub keywords: Vec<String>,
550 #[serde(default)]
553 pub requires: AppUiRequirements,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub data: Option<AppDataManifest>,
556}
557
558pub fn validate_manifest_path(path: &str) -> Result<(), String> {
570 if path.is_empty() {
571 return Err("path must not be empty".to_string());
572 }
573
574 if path.starts_with('/') {
576 return Err(format!(
577 "path '{}' must not be absolute (starts with /)",
578 path
579 ));
580 }
581
582 let first = path.chars().next().unwrap();
585 if !first.is_ascii_alphanumeric() && first != '_' {
586 return Err(format!(
587 "path '{}' must begin with an alphanumeric character or underscore",
588 path
589 ));
590 }
591 for ch in path.chars().skip(1) {
592 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
593 return Err(format!(
594 "path '{}' contains disallowed character '{}'",
595 path, ch
596 ));
597 }
598 }
599
600 for segment in path.split('/') {
602 if segment == ".." {
603 return Err(format!(
604 "path '{}' contains a '..' segment (path traversal rejected)",
605 path
606 ));
607 }
608 }
609
610 Ok(())
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct AppManifest {
622 #[serde(default = "default_manifest_version", rename = "manifest_version")]
624 pub manifest_version: u8,
625
626 pub name: String,
627 pub version: String,
628
629 #[serde(default = "default_app_type_native")]
630 pub app_type: AppType,
631
632 #[serde(default)]
633 pub description: String,
634
635 pub abi: Option<AbiVersion>,
638
639 pub entrypoint: Option<String>,
642
643 pub hot_reload: Option<HotReloadKind>,
646
647 #[serde(default)]
649 pub critical: bool,
650
651 #[serde(
652 default = "default_auto_start",
653 deserialize_with = "deserialize_auto_start"
654 )]
655 pub auto_start: bool,
656
657 #[serde(default)]
658 pub has_ui: bool,
659
660 #[serde(default = "default_ui_path")]
661 pub ui_path: String,
662
663 #[serde(default)]
664 pub permissions: Vec<String>,
665
666 #[serde(default)]
669 pub requires: Vec<String>,
670
671 #[serde(default)]
672 pub optional_permissions: Vec<String>,
673
674 #[serde(default)]
675 pub provides_scopes: Vec<ProvidedScope>,
676
677 #[serde(default)]
678 pub endpoint_policies: Vec<EndpointPolicy>,
679
680 #[serde(default)]
681 pub capability_scopes: HashMap<String, String>,
682
683 #[serde(default)]
684 pub provides: HashMap<String, ProvidedCapability>,
685
686 #[serde(default)]
688 pub capabilities: ManifestCapabilities,
689
690 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub ui: Option<AppUiManifest>,
694
695 #[serde(default)]
697 pub author: Option<String>,
698
699 #[serde(default)]
700 pub homepage: Option<String>,
701
702 #[serde(default)]
703 pub depends_on: Option<Vec<String>>,
704
705 #[serde(default)]
706 pub boot_priority: Option<u32>,
707
708 #[serde(default)]
712 pub governor: Option<GovernorManifest>,
713
714 #[serde(default)]
720 pub subscribes: Vec<String>,
721
722 #[serde(default)]
726 pub standalone: Option<StandaloneConfig>,
727
728 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub tcp: Option<TcpManifest>,
732
733 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub resources: Option<ResourcesManifest>,
737
738 #[serde(default, skip_serializing_if = "Vec::is_empty")]
745 pub schedules: Vec<AppScheduleManifest>,
746
747 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub host_contract: Option<crate::HostContractStamp>,
752}
753
754pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
756
757#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
763pub struct ResourcesManifest {
764 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub memory_mb: Option<u32>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub callback_deadline_ms: Option<u32>,
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
776#[serde(rename_all = "lowercase")]
777pub enum LatencyClass {
778 Interactive,
781 Background,
784}
785
786impl LatencyClass {
787 pub fn as_str(&self) -> &'static str {
788 match self {
789 LatencyClass::Interactive => "interactive",
790 LatencyClass::Background => "background",
791 }
792 }
793
794 #[allow(clippy::should_implement_trait)]
795 pub fn from_str(s: &str) -> Result<Self, String> {
796 match s {
797 "interactive" => Ok(LatencyClass::Interactive),
798 "background" => Ok(LatencyClass::Background),
799 _ => Err(format!("Invalid LatencyClass: {}", s)),
800 }
801 }
802}
803
804impl std::fmt::Display for LatencyClass {
805 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
806 write!(f, "{}", self.as_str())
807 }
808}
809
810#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
813pub struct GovernorManifest {
814 #[serde(default)]
818 pub terminable: Option<bool>,
819
820 #[serde(default)]
824 pub min_idle_secs: Option<u64>,
825
826 #[serde(default)]
859 pub memory_budget_kb: Option<u64>,
860
861 #[serde(default)]
866 pub latency_class: Option<LatencyClass>,
867}
868
869#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
873pub struct TcpManifest {
874 #[serde(default, skip_serializing_if = "Option::is_none")]
878 pub preferred_port: Option<u16>,
879
880 #[serde(default, skip_serializing_if = "Option::is_none")]
887 pub direct_bind: Option<bool>,
888}
889
890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907pub struct AppScheduleManifest {
908 pub id: String,
915
916 pub cron: String,
925
926 pub capability: String,
934
935 #[serde(default, skip_serializing_if = "Option::is_none")]
939 pub payload: Option<serde_json::Value>,
940}
941
942impl AppScheduleManifest {
943 pub fn effective_payload(&self) -> serde_json::Value {
945 self.payload
946 .clone()
947 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()))
948 }
949}
950
951fn cron_field_char_allowed(ch: char) -> bool {
956 ch.is_ascii_alphanumeric() || matches!(ch, '*' | ',' | '-' | '/' | '?' | 'L' | 'W' | '#')
957}
958
959fn validate_cron_shape(expression: &str) -> Result<(), String> {
962 let fields: Vec<&str> = expression.split_whitespace().collect();
963 if fields.len() != 6 {
964 return Err(format!(
965 "cron '{}' must have 6 fields (sec min hour day month weekday), found {}",
966 expression,
967 fields.len()
968 ));
969 }
970 for field in fields {
971 if let Some(ch) = field.chars().find(|c| !cron_field_char_allowed(*c)) {
972 return Err(format!(
973 "cron '{}' contains disallowed character '{}'",
974 expression, ch
975 ));
976 }
977 }
978 Ok(())
979}
980
981fn validate_schedule_id(id: &str) -> Result<(), String> {
984 if id.is_empty() {
985 return Err("schedule id must not be empty".to_string());
986 }
987 let first = id.chars().next().unwrap();
988 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
989 return Err(format!(
990 "schedule id '{}' must begin with a lowercase letter or digit",
991 id
992 ));
993 }
994 for ch in id.chars() {
995 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && !matches!(ch, '-' | '_' | '.') {
996 return Err(format!(
997 "schedule id '{}' contains disallowed character '{}' (allowed: a-z 0-9 - _ .)",
998 id, ch
999 ));
1000 }
1001 }
1002 Ok(())
1003}
1004
1005fn default_manifest_version() -> u8 {
1006 1
1007}
1008
1009fn default_auto_start() -> bool {
1010 true
1011}
1012
1013fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
1019where
1020 D: serde::Deserializer<'de>,
1021{
1022 #[derive(Deserialize)]
1023 #[serde(untagged)]
1024 enum BoolOrStr {
1025 Bool(bool),
1026 Str(String),
1027 }
1028 Ok(match BoolOrStr::deserialize(deserializer)? {
1029 BoolOrStr::Bool(b) => b,
1030 BoolOrStr::Str(s) => matches!(
1031 s.trim().to_ascii_lowercase().as_str(),
1032 "true" | "eager" | "active" | "auto" | "on" | "1"
1033 ),
1034 })
1035}
1036
1037fn default_ui_path() -> String {
1038 "dist".to_string()
1039}
1040
1041fn default_app_type_native() -> AppType {
1042 AppType::Native
1043}
1044
1045impl AppManifest {
1046 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
1050 let top = normalized_requirements(&self.requires)?;
1051 let nested = normalized_requirements(&self.capabilities.requires)?;
1052 if !top.is_empty()
1053 && !nested.is_empty()
1054 && top.iter().cloned().collect::<BTreeSet<_>>()
1055 != nested.iter().cloned().collect::<BTreeSet<_>>()
1056 {
1057 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
1058 }
1059 Ok(if !top.is_empty() { top } else { nested })
1060 }
1061
1062 pub fn effective_hot_reload(&self) -> HotReloadKind {
1065 self.hot_reload
1066 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
1067 }
1068
1069 pub fn effective_entrypoint(&self) -> &str {
1075 if let Some(ref ep) = self.entrypoint {
1076 ep.as_str()
1077 } else {
1078 match self.app_type {
1079 AppType::Native => "app.so",
1080 AppType::Bun => "dist/index.js",
1081 AppType::Standalone => "",
1082 AppType::PlatformRuntime => "bun",
1083 AppType::ManagedV1 => "llmc-generated-app",
1084 AppType::Burger => "dist/index.js",
1085 AppType::UiOnly => "",
1086 }
1087 }
1088 }
1089
1090 pub fn has_capability_providers(&self) -> bool {
1093 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
1094 }
1095
1096 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
1110 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
1111 for raw in &self.capabilities.provides {
1112 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
1113 if name.is_empty() {
1114 continue;
1115 }
1116 out.entry(name).or_insert(ProvidedCapability {
1117 description: String::new(),
1118 schema: None,
1119 discoverable: true,
1120 });
1121 }
1122 out
1123 }
1124
1125 fn validate_schedules(&self) -> Result<(), String> {
1135 if self.schedules.is_empty() {
1136 return Ok(());
1137 }
1138 let provided = self.resolved_capability_provides();
1139 let mut seen: HashSet<&str> = HashSet::new();
1140 for schedule in &self.schedules {
1141 validate_schedule_id(&schedule.id)?;
1142 if !seen.insert(schedule.id.as_str()) {
1143 return Err(format!("duplicate schedule id '{}'", schedule.id));
1144 }
1145 validate_cron_shape(&schedule.cron)
1146 .map_err(|e| format!("schedule '{}': {}", schedule.id, e))?;
1147 if schedule.capability.trim().is_empty() {
1148 return Err(format!(
1149 "schedule '{}': capability must not be blank",
1150 schedule.id
1151 ));
1152 }
1153 if !provided.contains_key(schedule.capability.trim()) {
1154 return Err(format!(
1155 "schedule '{}' names capability '{}', which this app does not provide \
1156 (a schedule may only dispatch a capability declared in this manifest's \
1157 'provides')",
1158 schedule.id, schedule.capability
1159 ));
1160 }
1161 if let Some(payload) = &schedule.payload {
1162 if !payload.is_object() {
1163 return Err(format!(
1164 "schedule '{}': payload must be a JSON object",
1165 schedule.id
1166 ));
1167 }
1168 }
1169 }
1170 Ok(())
1171 }
1172
1173 pub fn validate(&self) -> Result<(), String> {
1178 self.validate_with_socket_path_policy(false)
1179 }
1180
1181 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
1186 if self.manifest_version == 2 && self.abi.is_none() {
1188 return Err("manifest_version 2 requires an 'abi' field".to_string());
1189 }
1190
1191 validate_app_name(&self.name)?;
1194 self.resolved_requires()?;
1195
1196 if let Some(ref ep) = self.entrypoint {
1198 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
1199 }
1200 if !self.ui_path.is_empty() && self.ui_path != "dist" {
1202 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
1203 }
1204
1205 if let Some(ui) = &self.ui {
1206 validate_app_ui(&self.name, ui)?;
1207 }
1208
1209 if let Some(ref hp) = self.homepage {
1211 if !hp.starts_with("https://") && !hp.starts_with("http://") {
1212 return Err(format!(
1213 "homepage '{}' must use https:// or http:// scheme",
1214 hp
1215 ));
1216 }
1217 }
1218
1219 match self.app_type {
1226 AppType::Standalone => {
1227 if self.has_capability_providers() {
1228 let cfg = self.standalone.as_ref().ok_or_else(|| {
1229 "standalone apps that declare 'provides' require a \
1230 'standalone.socket_path' field"
1231 .to_string()
1232 })?;
1233 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1234 }
1235 }
1236 AppType::Native
1237 | AppType::Bun
1238 | AppType::PlatformRuntime
1239 | AppType::ManagedV1
1240 | AppType::Burger
1241 | AppType::UiOnly => {
1242 if self.standalone.is_some() {
1243 return Err(format!(
1244 "'standalone' block is only valid when app_type == 'standalone' \
1245 (found app_type='{}')",
1246 self.app_type
1247 ));
1248 }
1249 }
1250 }
1251
1252 if self.app_type == AppType::UiOnly {
1253 validate_ui_only(self)?;
1254 }
1255
1256 if let Some(resources) = &self.resources {
1257 if resources.memory_mb == Some(0) {
1258 return Err("resources.memory_mb must be at least 1".to_string());
1259 }
1260 if let Some(deadline) = resources.callback_deadline_ms {
1261 if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1262 return Err(format!(
1263 "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1264 ));
1265 }
1266 }
1267 }
1268
1269 self.validate_schedules()?;
1270
1271 if self.app_type == AppType::PlatformRuntime
1272 && !self.resolved_capability_provides().is_empty()
1273 {
1274 return Err(
1275 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1276 );
1277 }
1278
1279 Ok(())
1280 }
1281
1282 pub fn from_json(json: &str) -> Result<Self, String> {
1284 Self::from_json_with_socket_path_policy(json, false)
1285 }
1286
1287 pub fn from_json_with_socket_path_policy(
1289 json: &str,
1290 allow_non_run: bool,
1291 ) -> Result<Self, String> {
1292 let mut manifest: Self =
1293 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1294 if serde_json::from_str::<serde_json::Value>(json).is_ok_and(|raw| declares_ui_only(&raw)) {
1295 manifest.app_type = AppType::UiOnly;
1296 }
1297 if manifest.ui.is_some() {
1298 manifest.has_ui = true;
1299 }
1300 manifest.validate_with_socket_path_policy(allow_non_run)?;
1301 Ok(manifest)
1302 }
1303}
1304
1305fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1306 let mut seen = HashSet::new();
1307 let mut resolved = Vec::new();
1308 for value in values {
1309 let requirement = value.trim();
1310 if requirement.is_empty() {
1311 return Err("capability requirements must not be blank".to_string());
1312 }
1313 if seen.insert(requirement.to_string()) {
1314 resolved.push(requirement.to_string());
1315 }
1316 }
1317 Ok(resolved)
1318}
1319
1320fn validate_ui_only(manifest: &AppManifest) -> Result<(), String> {
1322 match &manifest.ui {
1323 Some(ui) if ui.kind == AppUiKind::Stage => {}
1324 _ => return Err("ui-only apps must declare a ui block of kind \"stage\"".to_string()),
1325 }
1326 match ui_only_process_declaration(manifest) {
1327 Some(what) => Err(format!(
1328 "ui-only apps have no backend process and must not declare {what}"
1329 )),
1330 None => Ok(()),
1331 }
1332}
1333
1334pub fn ui_only_process_declaration(manifest: &AppManifest) -> Option<&'static str> {
1339 if manifest.entrypoint.is_some() {
1340 Some("'entrypoint'")
1341 } else if manifest.has_capability_providers() {
1342 Some("capability providers ('provides' / 'capabilities.provides')")
1343 } else if manifest.tcp.is_some() {
1344 Some("a 'tcp' block")
1345 } else if manifest.resources.is_some() {
1346 Some("a 'resources' block")
1347 } else if manifest.standalone.is_some() {
1348 Some("a 'standalone' block")
1349 } else if !manifest.subscribes.is_empty() {
1350 Some("event subscriptions ('subscribes')")
1351 } else if manifest
1352 .depends_on
1353 .as_ref()
1354 .is_some_and(|deps| !deps.is_empty())
1355 {
1356 Some("dependencies ('depends_on')")
1357 } else {
1358 None
1359 }
1360}
1361
1362pub fn declares_ui_only(manifest: &serde_json::Value) -> bool {
1375 manifest.as_object().is_some_and(|object| {
1376 let derived = object.get("ui").is_some_and(serde_json::Value::is_object)
1377 && !object.contains_key("app_type")
1378 && !object.contains_key("entrypoint");
1379 derived || object.get("app_type").and_then(serde_json::Value::as_str) == Some("ui-only")
1380 })
1381}
1382
1383fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1384 if ui.ui_api != 1 && ui.ui_api != 2 {
1385 return Err(format!(
1386 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1387 ui.ui_api
1388 ));
1389 }
1390 if ui.title.trim().is_empty() {
1391 return Err("ui.title must not be blank".to_string());
1392 }
1393 ui.requires.resolved()?;
1394 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1395 if let Some(icon) = &ui.icon {
1396 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1397 }
1398 if let Some(nav) = &ui.nav {
1399 if ui.kind == AppUiKind::Widget {
1400 return Err("widget ui must omit nav metadata".to_string());
1401 }
1402 if nav.section.trim().is_empty() {
1403 return Err("ui.nav.section must not be blank".to_string());
1404 }
1405 }
1406 if let Some(data) = &ui.data {
1410 validate_app_data(app_name, data, &ui.requires.resolved()?)?;
1411 }
1412
1413 let mut composed = HashSet::new();
1414 for name in &ui.composes {
1415 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1416 if name == app_name {
1417 return Err("ui.composes must not contain the app itself".to_string());
1418 }
1419 if !composed.insert(name) {
1420 return Err(format!("ui.composes contains duplicate app '{name}'"));
1421 }
1422 }
1423
1424 let mut surface_ids = HashSet::new();
1425 for surface in &ui.surfaces {
1426 let id = surface.id.trim();
1427 if id.is_empty() {
1428 return Err("ui.surfaces entry id must not be blank".to_string());
1429 }
1430 if !surface_ids.insert(id.to_string()) {
1431 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1432 }
1433 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1434 return Err(format!(
1435 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1436 surface.slot,
1437 KNOWN_SURFACE_SLOTS.join(", ")
1438 ));
1439 }
1440 if surface.title.trim().is_empty() {
1441 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1442 }
1443 validate_manifest_path(&surface.entry)
1444 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1445 if !ui.integrity.contains_key(&surface.entry) {
1454 return Err(format!("ui.integrity must include surface '{id}' entry"));
1455 }
1456 surface
1457 .requires
1458 .resolved()
1459 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1460 }
1461
1462 for (path, digest) in &ui.integrity {
1463 validate_manifest_path(path)
1464 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1465 if !is_lowercase_sha256(digest) {
1466 return Err(format!(
1467 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1468 ));
1469 }
1470 }
1471 if !ui.integrity.contains_key(&ui.entry) {
1472 return Err("ui.integrity must include the declared entry".to_string());
1473 }
1474 if let Some(icon) = &ui.icon {
1475 if !ui.integrity.contains_key(icon) {
1476 return Err("ui.integrity must include the declared icon".to_string());
1477 }
1478 }
1479 Ok(())
1480}
1481
1482fn validate_app_data(
1483 app_name: &str,
1484 data: &AppDataManifest,
1485 resolved_requires: &[String],
1486) -> Result<(), String> {
1487 validate_app_data_namespace(&data.namespace)?;
1488 validate_app_data_namespace_owner(app_name, &data.namespace)?;
1489 validate_app_data_sync_policy(&data.sync)?;
1490 if data.queries.is_empty() && data.offline != AppDataOfflinePolicy::OnlineOnly {
1491 return Err("ui.data.queries must declare at least one query for last-known data".to_string());
1492 }
1493
1494 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1495 let mut names = BTreeSet::new();
1496 for query in &data.queries {
1497 validate_namespaced_data_name(&query.name, &data.namespace)
1498 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1499 validate_capability_name(&query.capability).map_err(|error| {
1500 format!(
1501 "ui.data query '{}' capability '{}' invalid: {error}",
1502 query.name, query.capability
1503 )
1504 })?;
1505 if !requires.contains(query.capability.as_str()) {
1506 return Err(format!(
1507 "ui.data query '{}' capability '{}' must be declared in requires",
1508 query.name, query.capability
1509 ));
1510 }
1511 if !names.insert(query.name.as_str()) {
1512 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1513 }
1514 }
1515
1516 for stream in &data.streams {
1517 validate_namespaced_data_name(&stream.name, &data.namespace)
1518 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1519 if !names.insert(stream.name.as_str()) {
1520 return Err(format!(
1521 "ui.data contains duplicate declaration '{}'",
1522 stream.name
1523 ));
1524 }
1525 }
1526
1527 Ok(())
1528}
1529
1530fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1531 if !is_safe_name_segment(namespace) {
1532 return Err(format!(
1533 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1534 namespace
1535 ));
1536 }
1537 if matches!(
1538 namespace,
1539 "core" | "internal" | "node" | "platform" | "system"
1540 ) {
1541 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1542 }
1543 Ok(())
1544}
1545
1546pub fn validate_app_data_namespace_owner(app_name: &str, namespace: &str) -> Result<(), String> {
1557 if namespace != app_name {
1558 return Err(format!(
1559 "ui.data namespace '{namespace}' must equal the app name '{app_name}' — the Client \
1560 Node PWA refuses a stage whose data namespace it does not own; rename the namespace \
1561 to '{app_name}' and its query and stream names to '{app_name}.<name>.v<N>'"
1562 ));
1563 }
1564 Ok(())
1565}
1566
1567fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1568 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1569 validate_optional_range(
1570 "full_refresh_interval_secs",
1571 sync.full_refresh_interval_secs,
1572 60,
1573 604_800,
1574 )?;
1575 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1576 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1577 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1578 }
1579 Ok(())
1580}
1581
1582fn validate_optional_range(
1583 field: &str,
1584 value: Option<u32>,
1585 min: u32,
1586 max: u32,
1587) -> Result<(), String> {
1588 if let Some(value) = value {
1589 if value < min || value > max {
1590 return Err(format!(
1591 "ui.data.sync {field} must be between {min} and {max} seconds"
1592 ));
1593 }
1594 }
1595 Ok(())
1596}
1597
1598fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1599 validate_capability_name(name)?;
1600 let Some(rest) = name
1601 .strip_prefix(namespace)
1602 .and_then(|suffix| suffix.strip_prefix('.'))
1603 else {
1604 return Err(format!("name must use namespace '{namespace}'"));
1605 };
1606 if rest.is_empty() {
1607 return Err("name must include a value after its namespace".to_string());
1608 }
1609 if !has_version_suffix(name) {
1610 return Err("name must end with a .vN version suffix".to_string());
1611 }
1612 Ok(())
1613}
1614
1615fn validate_capability_name(name: &str) -> Result<(), String> {
1616 if name.is_empty() {
1617 return Err("name must not be empty".to_string());
1618 }
1619 if name.contains('/') || name.contains("..") {
1620 return Err("name must not contain path separators or traversal".to_string());
1621 }
1622 if !name.split('.').all(is_safe_declaration_segment) {
1623 return Err("name must contain only lowercase dot-separated segments".to_string());
1624 }
1625 Ok(())
1626}
1627
1628fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1629 if allow_wildcard && name.ends_with(".*") {
1630 return validate_capability_name(&name[..name.len() - 2]);
1631 }
1632 validate_capability_name(name)
1633}
1634
1635fn has_version_suffix(name: &str) -> bool {
1636 let Some(version) = name.rsplit('.').next() else {
1637 return false;
1638 };
1639 let Some(digits) = version.strip_prefix('v') else {
1640 return false;
1641 };
1642 !digits.is_empty()
1643 && !digits.starts_with('0')
1644 && digits.bytes().all(|byte| byte.is_ascii_digit())
1645}
1646
1647fn is_safe_name_segment(segment: &str) -> bool {
1648 if segment.is_empty() {
1649 return false;
1650 }
1651 let mut chars = segment.chars();
1652 let Some(first) = chars.next() else {
1653 return false;
1654 };
1655 first.is_ascii_lowercase()
1656 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1657}
1658
1659fn is_safe_declaration_segment(segment: &str) -> bool {
1672 let mut chars = segment.chars();
1673 let Some(first) = chars.next() else {
1674 return false;
1675 };
1676 first.is_ascii_lowercase()
1677 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1678}
1679
1680fn is_lowercase_sha256(value: &str) -> bool {
1681 value.len() == 64
1682 && value
1683 .bytes()
1684 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1685}
1686
1687pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1696 validate_standalone_socket_path_with_policy(path, false)
1697}
1698
1699pub fn validate_standalone_socket_path_with_policy(
1701 path: &std::path::Path,
1702 allow_non_run: bool,
1703) -> Result<(), String> {
1704 if !path.is_absolute() {
1705 return Err(format!(
1706 "standalone.socket_path '{}' must be absolute",
1707 path.display()
1708 ));
1709 }
1710 if !allow_non_run && !path.starts_with("/run/") {
1711 return Err(format!(
1712 "standalone.socket_path '{}' must live under /run/",
1713 path.display()
1714 ));
1715 }
1716 if path
1717 .components()
1718 .any(|c| matches!(c, std::path::Component::ParentDir))
1719 {
1720 return Err(format!(
1721 "standalone.socket_path '{}' must not contain '..' segments",
1722 path.display()
1723 ));
1724 }
1725 Ok(())
1726}
1727
1728fn validate_app_name(name: &str) -> Result<(), String> {
1732 let (publisher, app) = if let Some(slash) = name.find('/') {
1733 let (p, rest) = name.split_at(slash);
1734 (Some(p), &rest[1..])
1735 } else {
1736 (None, name)
1737 };
1738
1739 let valid_segment = |s: &str| -> bool {
1740 if s.is_empty() {
1741 return false;
1742 }
1743 let mut chars = s.chars();
1744 let first = chars.next().unwrap();
1745 if !first.is_ascii_lowercase() {
1746 return false;
1747 }
1748 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1749 };
1750
1751 if let Some(pub_name) = publisher {
1752 if !valid_segment(pub_name) {
1753 return Err(format!(
1754 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1755 pub_name
1756 ));
1757 }
1758 }
1759
1760 if !valid_segment(app) {
1761 return Err(format!(
1762 "app name segment '{}' must match [a-z][a-z0-9-]*",
1763 app
1764 ));
1765 }
1766
1767 Ok(())
1768}
1769
1770#[cfg(test)]
1773mod tests {
1774 use super::*;
1775
1776 fn parse_ok(json: &str) -> AppManifest {
1777 AppManifest::from_json(json).expect("should parse")
1778 }
1779
1780 fn parse_err(json: &str) -> String {
1781 AppManifest::from_json(json).expect_err("should fail")
1782 }
1783
1784 const SCHEDULED_APP: &str = r#"{
1787 "name":"network","version":"1.0.0","app_type":"bun","auto_start":"lazy",
1788 "provides":{"network.ledger.poll":{"description":"Record connections"}},
1789 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1790 "capability":"network.ledger.poll"}]
1791 }"#;
1792
1793 #[test]
1794 fn a_manifest_with_no_schedules_block_parses_to_an_empty_list() {
1795 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1798 assert!(m.schedules.is_empty());
1799 }
1800
1801 #[test]
1802 fn a_schedule_is_parsed_whole() {
1803 let m = parse_ok(SCHEDULED_APP);
1804 assert_eq!(m.schedules.len(), 1);
1805 let s = &m.schedules[0];
1806 assert_eq!(s.id, "ledger-poll");
1807 assert_eq!(s.cron, "0 */15 * * * *");
1808 assert_eq!(s.capability, "network.ledger.poll");
1809 assert_eq!(s.effective_payload(), serde_json::json!({}));
1812 }
1813
1814 #[test]
1815 fn a_schedule_may_only_dispatch_a_capability_this_app_provides() {
1816 let err = parse_err(
1819 r#"{"name":"network","version":"1.0.0","app_type":"bun",
1820 "provides":{"network.ledger.poll":{"description":"x"}},
1821 "schedules":[{"id":"drain","cron":"0 0 * * * *",
1822 "capability":"core.lightning.send_payment"}]}"#,
1823 );
1824 assert!(err.contains("core.lightning.send_payment"), "{err}");
1825 assert!(err.contains("does not provide"), "{err}");
1826 }
1827
1828 #[test]
1829 fn a_v2_capabilities_provides_entry_also_satisfies_the_gate() {
1830 let m = parse_ok(
1833 r#"{"manifest_version":2,"abi":"v1","name":"network","version":"1.0.0",
1834 "app_type":"bun",
1835 "capabilities":{"provides":["network.ledger.poll"]},
1836 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1837 "capability":"network.ledger.poll"}]}"#,
1838 );
1839 assert_eq!(m.schedules.len(), 1);
1840 }
1841
1842 #[test]
1843 fn a_cron_expression_without_six_fields_is_refused() {
1844 let err = parse_err(
1846 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1847 "provides":{"n.poll":{"description":"x"}},
1848 "schedules":[{"id":"p","cron":"*/15 * * * *","capability":"n.poll"}]}"#,
1849 );
1850 assert!(err.contains("6 fields"), "{err}");
1851 }
1852
1853 #[test]
1854 fn a_cron_expression_with_junk_in_it_is_refused() {
1855 let err = parse_err(
1856 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1857 "provides":{"n.poll":{"description":"x"}},
1858 "schedules":[{"id":"p","cron":"0 0 2 * * $(whoami)","capability":"n.poll"}]}"#,
1859 );
1860 assert!(err.contains("disallowed character"), "{err}");
1861 }
1862
1863 #[test]
1864 fn two_schedules_cannot_share_an_id() {
1865 let err = parse_err(
1868 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1869 "provides":{"n.poll":{"description":"x"}},
1870 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll"},
1871 {"id":"p","cron":"0 30 * * * *","capability":"n.poll"}]}"#,
1872 );
1873 assert!(err.contains("duplicate schedule id"), "{err}");
1874 }
1875
1876 #[test]
1877 fn a_schedule_id_must_be_a_safe_token() {
1878 let err = parse_err(
1879 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1880 "provides":{"n.poll":{"description":"x"}},
1881 "schedules":[{"id":"../escape","cron":"0 0 * * * *","capability":"n.poll"}]}"#,
1882 );
1883 assert!(err.contains("schedule id"), "{err}");
1884 }
1885
1886 #[test]
1887 fn a_non_object_payload_is_refused() {
1888 let err = parse_err(
1889 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1890 "provides":{"n.poll":{"description":"x"}},
1891 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1892 "payload":"not-an-object"}]}"#,
1893 );
1894 assert!(err.contains("JSON object"), "{err}");
1895 }
1896
1897 #[test]
1898 fn a_declared_payload_survives_the_round_trip() {
1899 let m = parse_ok(
1900 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1901 "provides":{"n.poll":{"description":"x"}},
1902 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1903 "payload":{"depth":2}}]}"#,
1904 );
1905 assert_eq!(m.schedules[0].effective_payload(), serde_json::json!({"depth": 2}));
1906 let round_tripped: AppManifest =
1907 serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
1908 assert_eq!(round_tripped.schedules, m.schedules);
1909 }
1910
1911 #[test]
1912 fn an_empty_schedules_list_is_omitted_from_the_serialized_form() {
1913 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1915 let json = serde_json::to_string(&m).unwrap();
1916 assert!(!json.contains("schedules"), "{json}");
1917 }
1918
1919 #[test]
1922 fn v1_minimal_native() {
1923 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1924 assert_eq!(m.manifest_version, 1);
1925 assert_eq!(m.app_type, AppType::Native);
1926 assert!(m.abi.is_none());
1927 }
1928
1929 #[test]
1930 fn v1_minimal_bun() {
1931 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1932 assert_eq!(m.app_type, AppType::Bun);
1933 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1934 }
1935
1936 #[test]
1937 fn v1_no_manifest_version_field_defaults_to_1() {
1938 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1939 assert_eq!(m.manifest_version, 1);
1940 }
1941
1942 #[test]
1943 fn v1_all_optional_fields_missing() {
1944 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1945 assert!(!m.critical);
1946 assert!(m.auto_start);
1947 assert!(!m.has_ui);
1948 assert_eq!(m.ui_path, "dist");
1949 assert!(m.permissions.is_empty());
1950 assert!(m.optional_permissions.is_empty());
1951 assert!(m.governor.is_none());
1954 assert!(m.subscribes.is_empty());
1955 }
1956
1957 #[test]
1958 fn v1_with_permissions_and_provides() {
1959 let json = r#"{
1960 "name": "example",
1961 "version": "1.0.0",
1962 "app_type": "bun",
1963 "permissions": ["core.storage.kv"],
1964 "optional_permissions": ["core.notifications.create"],
1965 "provides": {
1966 "core.example.run": { "description": "Run example job" }
1967 }
1968 }"#;
1969 let m = parse_ok(json);
1970 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1971 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1972 assert!(m.provides.contains_key("core.example.run"));
1973 }
1974
1975 #[test]
1978 fn provides_discoverable_defaults_true_and_parses_false() {
1979 let json = r#"{
1980 "name": "remote-support",
1981 "version": "1.0.0",
1982 "app_type": "bun",
1983 "capabilities": { "provides": ["remote_support.status", "remote_support.challenge"] },
1984 "provides": {
1985 "remote_support.status": { "description": "Status" },
1986 "remote_support.mode.set": { "description": "Set mode", "discoverable": false }
1987 }
1988 }"#;
1989 let m = parse_ok(json);
1990 assert!(m.provides["remote_support.status"].discoverable);
1991 assert!(!m.provides["remote_support.mode.set"].discoverable);
1992
1993 let resolved = m.resolved_capability_provides();
1994 assert!(!resolved["remote_support.mode.set"].discoverable);
1995 assert!(resolved["remote_support.status"].discoverable);
1996 assert!(resolved["remote_support.challenge"].discoverable);
1998
1999 let written = serde_json::to_value(&m).unwrap();
2000 assert_eq!(
2001 written["provides"]["remote_support.mode.set"]["discoverable"],
2002 serde_json::json!(false)
2003 );
2004 assert!(written["provides"]["remote_support.status"]
2005 .get("discoverable")
2006 .is_none());
2007 }
2008
2009 #[test]
2010 fn provides_discoverable_must_be_a_boolean() {
2011 let json = r#"{
2012 "name": "example",
2013 "version": "1.0.0",
2014 "app_type": "bun",
2015 "provides": { "example.run": { "discoverable": "no" } }
2016 }"#;
2017 assert!(AppManifest::from_json(json).is_err());
2018 }
2019
2020 #[test]
2023 fn v2_minimal_native() {
2024 let json = r#"{
2025 "manifest_version": 2,
2026 "name": "cron",
2027 "version": "1.0.0",
2028 "app_type": "native",
2029 "abi": "v1",
2030 "entrypoint": "app.so",
2031 "hot_reload": "experimental"
2032 }"#;
2033 let m = parse_ok(json);
2034 assert_eq!(m.manifest_version, 2);
2035 assert_eq!(m.abi, Some(AbiVersion::V1));
2036 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
2037 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
2038 }
2039
2040 #[test]
2041 fn v2_minimal_bun_with_capabilities() {
2042 let json = r#"{
2043 "manifest_version": 2,
2044 "name": "example-fullstack",
2045 "version": "1.0.0",
2046 "app_type": "bun",
2047 "abi": "v1",
2048 "entrypoint": "dist/index.js",
2049 "hot_reload": "supported",
2050 "has_ui": true,
2051 "ui_path": "ui/dist",
2052 "capabilities": {
2053 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
2054 "provides": []
2055 },
2056 "governor": { "terminable": false, "min_idle_secs": 300 },
2057 "subscribes": ["core.chat.message.received"]
2058 }"#;
2059 let m = parse_ok(json);
2060 assert_eq!(m.manifest_version, 2);
2061 assert_eq!(m.capabilities.requires.len(), 2);
2062 let governor = m.governor.expect("governor block should parse");
2064 assert_eq!(governor.terminable, Some(false));
2065 assert_eq!(governor.min_idle_secs, Some(300));
2066 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
2067 }
2068
2069 #[test]
2070 fn stage_contract_fixture_parses_with_normalized_requirements() {
2071 let json = include_str!(
2072 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2073 );
2074 let m = parse_ok(json);
2075 assert!(m.has_ui);
2076 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
2077 let ui = m.ui.expect("fixture should declare ui");
2078 assert_eq!(ui.kind, AppUiKind::Stage);
2079 assert_eq!(ui.entry, "ui/main.js");
2080 assert_eq!(ui.nav.unwrap().order, 10);
2081 }
2082
2083 #[test]
2084 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
2085 let m = parse_ok(
2086 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
2087 );
2088 assert!(m.has_ui);
2089 assert_eq!(m.ui_path, "ui/dist");
2090 assert!(m.ui.is_none());
2091 }
2092
2093 #[test]
2094 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
2095 let manifest = parse_ok(
2096 r#"{
2097 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2098 "requires":["core.cron.register"],
2099 "ui":{
2100 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2101 "requires":{
2102 "capabilities":["ui.snapshot.v1"],
2103 "queries":["ui.query.v1"],
2104 "streams":["ui.event.v1"]
2105 },
2106 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2107 }
2108 }"#,
2109 );
2110
2111 assert_eq!(
2112 manifest.resolved_requires().unwrap(),
2113 vec!["core.cron.register"]
2114 );
2115 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
2116 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
2117 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
2118 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
2119 assert_eq!(
2120 ui.requires.resolved().unwrap(),
2121 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
2122 );
2123 let serialized = serde_json::to_value(ui).unwrap();
2124 assert_eq!(
2125 serialized["requires"]["queries"],
2126 serde_json::json!(["ui.query.v1"])
2127 );
2128 }
2129
2130 #[test]
2131 fn ui_requirements_reject_blank_entries() {
2132 let error = parse_err(
2133 r#"{
2134 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2135 "ui":{
2136 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2137 "requires":{"capabilities":[""],"queries":[],"streams":[]},
2138 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2139 }
2140 }"#,
2141 );
2142 assert!(error.contains("ui.requires entries must not be blank"));
2143 }
2144
2145 #[test]
2146 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
2147 assert!(validate_app_data_namespace("obs-viewer").is_ok());
2154
2155 let error = validate_app_data_namespace("obs_viewer")
2156 .expect_err("underscore must not be admitted into a storage namespace");
2157 assert!(
2158 error.contains("must match [a-z][a-z0-9-]*"),
2159 "unexpected error: {error}"
2160 );
2161 }
2162
2163 #[test]
2164 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
2165 let error = parse_err(
2166 r#"{
2167 "name":"ui-data","version":"1.0.0","app_type":"bun",
2168 "requires":["ui.snapshot.v1"],
2169 "ui":{
2170 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
2171 "requires":{"capabilities":[],"queries":[],"streams":[]},
2172 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2173 "data":{
2174 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
2175 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
2176 "streams":[]
2177 }
2178 }
2179 }"#,
2180 );
2181 assert!(error.contains("must be declared in requires"));
2182 }
2183
2184 #[test]
2189 fn ui_data_namespace_must_equal_the_app_name() {
2190 let manifest = |namespace: &str| {
2191 format!(
2192 r#"{{
2193 "name":"burger-runtime","version":"0.2.0",
2194 "ui":{{
2195 "kind":"stage","entry":"ui/dist/main.js","title":"Burger","ui_api":1,
2196 "requires":{{"capabilities":["core.runtime.burger_snapshot"]}},
2197 "integrity":{{"ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
2198 "data":{{
2199 "namespace":"{namespace}","offline":"online-only","sync":"snapshot",
2200 "queries":[{{"name":"{namespace}.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}}],
2201 "streams":[{{"name":"{namespace}.metrics.v1","kind":"events"}}]
2202 }}
2203 }}
2204 }}"#
2205 )
2206 };
2207
2208 let error = parse_err(&manifest("burger"));
2209 assert!(
2210 error.contains("ui.data namespace 'burger' must equal the app name 'burger-runtime'"),
2211 "unexpected error: {error}"
2212 );
2213
2214 let accepted = parse_ok(&manifest("burger-runtime"));
2215 let data = accepted.ui.as_ref().and_then(|ui| ui.data.as_ref());
2216 assert_eq!(
2217 data.map(|data| data.namespace.as_str()),
2218 Some("burger-runtime")
2219 );
2220 }
2221
2222 #[test]
2223 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
2224 let accepted = parse_ok(
2225 r#"{
2226 "name":"aliases","version":"1.0.0","app_type":"bun",
2227 "requires":["core.chat.read","core.chat.read","core.chat.send"],
2228 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
2229 }"#,
2230 );
2231 assert_eq!(
2232 accepted.resolved_requires().unwrap(),
2233 vec!["core.chat.read", "core.chat.send"]
2234 );
2235
2236 let err = parse_err(
2237 r#"{
2238 "name":"aliases","version":"1.0.0","app_type":"bun",
2239 "requires":["core.chat.read"],
2240 "capabilities":{"requires":["core.wallet.pay"]}
2241 }"#,
2242 );
2243 assert!(err.contains("conflicts"), "unexpected error: {err}");
2244 }
2245
2246 #[test]
2247 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
2248 let base = |ui: &str| {
2249 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
2250 };
2251 let widget = base(
2252 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2253 );
2254 assert_eq!(
2255 parse_ok(&widget).ui.expect("widget ui").kind,
2256 AppUiKind::Widget
2257 );
2258 let widget_nav = base(
2259 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2260 );
2261 assert!(parse_err(&widget_nav).contains("must omit nav"));
2262 let api = base(
2263 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2264 );
2265 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
2266 let unsupported_api = base(
2267 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2268 );
2269 assert!(parse_err(&unsupported_api).contains("ui_api"));
2270 let path = base(
2271 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2272 );
2273 assert!(parse_err(&path).contains("entry"));
2274 }
2275
2276 #[test]
2277 fn stage_requires_integrity_for_entry_and_icon() {
2278 let missing_entry = r#"{
2279 "name":"stage","version":"1.0.0","app_type":"bun",
2280 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
2281 }"#;
2282 assert!(parse_err(missing_entry).contains("entry"));
2283 let missing_icon = r#"{
2284 "name":"stage","version":"1.0.0","app_type":"bun",
2285 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
2286 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
2287 }"#;
2288 assert!(parse_err(missing_icon).contains("icon"));
2289 let uppercase = r#"{
2290 "name":"stage","version":"1.0.0","app_type":"bun",
2291 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2292 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
2293 }"#;
2294 assert!(parse_err(uppercase).contains("lowercase"));
2295 }
2296
2297 #[test]
2298 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
2299 let manifest = |composes: &str| {
2300 format!(
2301 r#"{{
2302 "name":"stage","version":"1.0.0","app_type":"bun",
2303 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
2304 "composes":{composes},
2305 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
2306 }}"#
2307 )
2308 };
2309 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
2310 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
2311 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
2312 }
2313
2314 fn manifest_with_ui() -> AppManifest {
2326 parse_ok(
2327 r#"{
2328 "name":"stage","version":"1.0.0","app_type":"bun",
2329 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2330 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2331 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
2332 }"#,
2333 )
2334 }
2335
2336 fn surface(id: &str, slot: &str) -> AppUiSurface {
2337 AppUiSurface {
2338 id: id.to_string(),
2339 slot: slot.to_string(),
2340 entry: "ui/dist/surfaces/chip.js".to_string(),
2341 title: "Chip".to_string(),
2342 order: 10,
2343 requires: AppUiRequirements {
2344 capabilities: vec!["wallet.balance.get".to_string()],
2345 ..Default::default()
2346 },
2347 }
2348 }
2349
2350 #[test]
2351 fn manifest_without_surfaces_still_parses() {
2352 let manifest = manifest_with_ui();
2353 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
2354 assert!(manifest.validate().is_ok());
2355 }
2356
2357 #[test]
2358 fn surface_in_a_known_slot_is_accepted() {
2359 let mut manifest = manifest_with_ui();
2360 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
2361 assert!(manifest.validate().is_ok());
2362 }
2363
2364 #[test]
2365 fn surface_in_an_unknown_slot_is_rejected() {
2366 let mut manifest = manifest_with_ui();
2367 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
2368 let error = manifest.validate().unwrap_err();
2369 assert!(error.contains("menu-bar"), "unexpected error: {error}");
2370 }
2371
2372 #[test]
2373 fn duplicate_surface_ids_are_rejected() {
2374 let mut manifest = manifest_with_ui();
2375 manifest.ui.as_mut().unwrap().surfaces = vec![
2376 surface("chip", "status-rail"),
2377 surface("chip", "status-rail"),
2378 ];
2379 let error = manifest.validate().unwrap_err();
2380 assert!(error.contains("duplicate"), "unexpected error: {error}");
2381 }
2382
2383 #[test]
2384 fn surface_with_a_blank_id_is_rejected() {
2385 let mut manifest = manifest_with_ui();
2386 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
2387 assert!(manifest.validate().is_err());
2388 }
2389
2390 #[test]
2391 fn surface_with_an_unsafe_entry_path_is_rejected() {
2392 let mut manifest = manifest_with_ui();
2393 let mut bad = surface("chip", "status-rail");
2394 bad.entry = "../../etc/passwd".to_string();
2395 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2396 assert!(manifest.validate().is_err());
2397 }
2398
2399 #[test]
2400 fn surface_entry_missing_from_integrity_is_rejected() {
2401 let mut manifest = manifest_with_ui();
2405 let mut unpinned = surface("chip", "status-rail");
2406 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
2407 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
2408 let error = manifest.validate().unwrap_err();
2409 assert!(
2410 error.contains("ui.integrity must include surface 'chip' entry"),
2411 "unexpected error: {error}"
2412 );
2413 }
2414
2415 #[test]
2416 fn surface_with_a_blank_required_capability_is_rejected() {
2417 let mut manifest = manifest_with_ui();
2418 let mut bad = surface("chip", "status-rail");
2419 bad.requires.capabilities = vec![" ".to_string()];
2420 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2421 assert!(manifest.validate().is_err());
2422 }
2423
2424 #[test]
2425 fn v2_missing_abi_is_error() {
2426 let json = r#"{
2427 "manifest_version": 2,
2428 "name": "example",
2429 "version": "1.0.0",
2430 "app_type": "bun"
2431 }"#;
2432 let err = parse_err(json);
2433 assert!(err.contains("abi"), "expected abi error, got: {}", err);
2434 }
2435
2436 #[test]
2439 fn publisher_prefixed_name_accepted() {
2440 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
2441 assert_eq!(m.name, "alice/weather");
2442 }
2443
2444 #[test]
2445 fn double_slash_name_rejected() {
2446 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
2447 assert!(!err.is_empty());
2448 }
2449
2450 #[test]
2453 fn name_starting_with_digit_rejected() {
2454 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
2455 assert!(!err.is_empty());
2456 }
2457
2458 #[test]
2459 fn name_with_uppercase_rejected() {
2460 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
2461 assert!(!err.is_empty());
2462 }
2463
2464 #[test]
2465 fn empty_name_rejected() {
2466 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
2467 assert!(!err.is_empty());
2468 }
2469
2470 #[test]
2473 fn path_traversal_double_dot_rejected() {
2474 let json = r#"{
2475 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2476 "app_type": "bun", "abi": "v1",
2477 "entrypoint": "../etc/passwd"
2478 }"#;
2479 let err = parse_err(json);
2480 assert!(err.contains(".."), "expected traversal error, got: {}", err);
2481 }
2482
2483 #[test]
2484 fn path_traversal_encoded_dot_not_decoded() {
2485 let json = r#"{
2487 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2488 "app_type": "bun", "abi": "v1",
2489 "entrypoint": "foo/../bar"
2490 }"#;
2491 let err = parse_err(json);
2492 assert!(!err.is_empty(), "should have failed: {}", err);
2493 }
2494
2495 #[test]
2496 fn absolute_path_rejected() {
2497 let json = r#"{
2498 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2499 "app_type": "bun", "abi": "v1",
2500 "entrypoint": "/usr/bin/sh"
2501 }"#;
2502 let err = parse_err(json);
2503 assert!(
2504 err.contains("absolute"),
2505 "expected absolute error, got: {}",
2506 err
2507 );
2508 }
2509
2510 #[test]
2511 fn shell_metachar_in_path_rejected() {
2512 let json = r#"{
2513 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2514 "app_type": "bun", "abi": "v1",
2515 "entrypoint": "dist/index.js;rm -rf /"
2516 }"#;
2517 let err = parse_err(json);
2518 assert!(!err.is_empty());
2519 }
2520
2521 #[test]
2522 fn valid_nested_path_accepted() {
2523 let json = r#"{
2524 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
2525 "app_type": "bun", "abi": "v1",
2526 "entrypoint": "dist/index.js",
2527 "ui_path": "ui/dist"
2528 }"#;
2529 parse_ok(json);
2530 }
2531
2532 #[test]
2535 fn homepage_https_accepted() {
2536 let json = r#"{
2537 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2538 "homepage": "https://example.com"
2539 }"#;
2540 parse_ok(json);
2541 }
2542
2543 #[test]
2544 fn homepage_javascript_scheme_rejected() {
2545 let json = r#"{
2546 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2547 "homepage": "javascript:alert(1)"
2548 }"#;
2549 let err = parse_err(json);
2550 assert!(
2551 err.contains("scheme"),
2552 "expected scheme error, got: {}",
2553 err
2554 );
2555 }
2556
2557 #[test]
2558 fn homepage_file_scheme_rejected() {
2559 let json = r#"{
2560 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2561 "homepage": "file:///etc/passwd"
2562 }"#;
2563 let err = parse_err(json);
2564 assert!(!err.is_empty());
2565 }
2566
2567 #[test]
2570 fn effective_entrypoint_native_default() {
2571 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2572 assert_eq!(m.effective_entrypoint(), "app.so");
2573 }
2574
2575 #[test]
2576 fn effective_entrypoint_bun_default() {
2577 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2578 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2579 }
2580
2581 #[test]
2582 fn effective_hot_reload_native_default_is_experimental() {
2583 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2584 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2585 }
2586
2587 #[test]
2588 fn effective_hot_reload_bun_default_is_supported() {
2589 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2590 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2591 }
2592
2593 #[test]
2594 fn hot_reload_unsupported_explicit() {
2595 let json = r#"{
2596 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2597 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2598 }"#;
2599 let m = parse_ok(json);
2600 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2601 }
2602
2603 #[test]
2606 fn validate_path_simple_valid() {
2607 assert!(validate_manifest_path("dist/index.js").is_ok());
2608 assert!(validate_manifest_path("app.so").is_ok());
2609 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2610 assert!(validate_manifest_path("build_output/main").is_ok());
2611 }
2612
2613 #[test]
2614 fn validate_path_empty_rejected() {
2615 assert!(validate_manifest_path("").is_err());
2616 }
2617
2618 #[test]
2619 fn validate_path_absolute_rejected() {
2620 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2621 }
2622
2623 #[test]
2624 fn validate_path_double_dot_segment_rejected() {
2625 assert!(validate_manifest_path("foo/../bar").is_err());
2626 assert!(validate_manifest_path("../etc/passwd").is_err());
2627 }
2628
2629 #[test]
2630 fn validate_path_leading_dot_rejected() {
2631 assert!(validate_manifest_path(".hidden").is_err());
2632 }
2633
2634 #[test]
2635 fn validate_path_null_byte_rejected() {
2636 let path = "foo\0bar";
2638 assert!(validate_manifest_path(path).is_err());
2639 }
2640
2641 #[test]
2642 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2643 let path = std::path::Path::new("/tmp/node-app/example.sock");
2644 assert!(validate_standalone_socket_path(path).is_err());
2645 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2646 assert!(validate_standalone_socket_path_with_policy(
2647 std::path::Path::new("/tmp/node-app/../escape.sock"),
2648 true,
2649 )
2650 .is_err());
2651 }
2652
2653 #[test]
2656 fn manifest_capabilities_defaults_to_empty() {
2657 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2658 assert!(m.capabilities.requires.is_empty());
2659 assert!(m.capabilities.provides.is_empty());
2660 }
2661
2662 #[test]
2665 fn resolved_capability_provides_v1_only() {
2666 let json = r#"{
2667 "name": "example", "version": "1.0.0", "app_type": "bun",
2668 "provides": { "core.example.run": { "description": "Run example job" } }
2669 }"#;
2670 let m = parse_ok(json);
2671 let out = m.resolved_capability_provides();
2672 assert_eq!(out.len(), 1);
2673 assert_eq!(
2674 out.get("core.example.run").unwrap().description,
2675 "Run example job"
2676 );
2677 }
2678
2679 #[test]
2680 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2681 let json = r#"{
2682 "manifest_version": 2, "name": "example", "version": "1.0.0",
2683 "app_type": "bun", "abi": "v1",
2684 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2685 }"#;
2686 let m = parse_ok(json);
2687 let out = m.resolved_capability_provides();
2688 assert_eq!(out.len(), 2);
2689 assert_eq!(out.get("core.example.run").unwrap().description, "");
2690 assert!(out.get("core.example.run").unwrap().schema.is_none());
2691 assert!(out.contains_key("core.example.other"));
2693 assert!(!out.contains_key("core.example.other:extra"));
2694 }
2695
2696 #[test]
2697 fn resolved_capability_provides_v1_wins_on_conflict() {
2698 let json = r#"{
2699 "manifest_version": 2, "name": "example", "version": "1.0.0",
2700 "app_type": "bun", "abi": "v1",
2701 "provides": { "core.example.run": { "description": "v1 wins" } },
2702 "capabilities": { "requires": [], "provides": ["core.example.run"] }
2703 }"#;
2704 let m = parse_ok(json);
2705 let out = m.resolved_capability_provides();
2706 assert_eq!(out.len(), 1);
2707 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2708 }
2709
2710 #[test]
2711 fn resolved_capability_provides_blank_v2_name_skipped() {
2712 let json = r#"{
2713 "manifest_version": 2, "name": "example", "version": "1.0.0",
2714 "app_type": "bun", "abi": "v1",
2715 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
2716 }"#;
2717 let m = parse_ok(json);
2718 let out = m.resolved_capability_provides();
2719 assert_eq!(out.len(), 1);
2720 assert!(out.contains_key("core.example.run"));
2721 }
2722
2723 #[test]
2724 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2725 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2726 assert!(m.resolved_capability_provides().is_empty());
2727 }
2728
2729 #[test]
2732 fn abi_v1_is_supported() {
2733 assert!(AbiVersion::V1.is_supported());
2734 }
2735
2736 #[test]
2739 fn app_tier_display() {
2740 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2741 assert_eq!(AppTier::Optional.to_string(), "optional");
2742 assert_eq!(AppTier::Development.to_string(), "development");
2743 }
2744
2745 #[test]
2746 fn app_tier_serde_roundtrip() {
2747 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2749 let json = serde_json::to_string(&tier).unwrap();
2750 let back: AppTier = serde_json::from_str(&json).unwrap();
2751 assert_eq!(
2752 tier, back,
2753 "roundtrip failed for {:?}: serialized as {}",
2754 tier, json
2755 );
2756 }
2757 assert_eq!(
2758 serde_json::to_string(&AppTier::Development).unwrap(),
2759 "\"development\""
2760 );
2761 }
2762
2763 #[test]
2766 fn app_type_display() {
2767 assert_eq!(AppType::Native.to_string(), "native");
2768 assert_eq!(AppType::Bun.to_string(), "bun");
2769 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2770 }
2771
2772 #[test]
2773 fn platform_runtime_is_a_supported_packaging_type() {
2774 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2775 "manifest_version": 2,
2776 "abi": "v1",
2777 "name": "bun-runtime",
2778 "version": "1.0.0",
2779 "app_type": "platform-runtime",
2780 "entrypoint": "bun"
2781 }))
2782 .expect("platform runtime manifest should parse");
2783
2784 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2785 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2786 }
2787
2788 #[test]
2791 fn governor_manifest_memory_budget_defaults_to_none() {
2792 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2793 assert_eq!(parsed.memory_budget_kb, None);
2794 }
2795
2796 #[test]
2797 fn governor_manifest_parses_declared_memory_budget() {
2798 let parsed: GovernorManifest =
2799 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2800 assert_eq!(parsed.memory_budget_kb, Some(40_960));
2801 }
2802
2803 #[test]
2806 fn latency_class_parses_and_defaults_none() {
2807 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2808 assert_eq!(parsed.latency_class, None);
2809 }
2810
2811 #[test]
2812 fn latency_class_parses_declared_interactive() {
2813 let json = r#"{
2814 "name": "example",
2815 "version": "1.0.0",
2816 "app_type": "bun",
2817 "governor": {"latency_class": "interactive"}
2818 }"#;
2819 let m = parse_ok(json);
2820 let governor = m.governor.expect("governor block should parse");
2821 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2822 }
2823
2824 #[test]
2825 fn latency_class_parses_declared_background() {
2826 let parsed: GovernorManifest =
2827 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2828 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2829 }
2830
2831 #[test]
2832 fn latency_class_invalid_value_is_parse_error() {
2833 let result: Result<GovernorManifest, _> =
2834 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2835 assert!(result.is_err(), "unknown latency_class value must fail to parse");
2836 }
2837
2838 #[test]
2839 fn latency_class_as_str_and_from_str_roundtrip() {
2840 for class in [LatencyClass::Interactive, LatencyClass::Background] {
2841 let s = class.as_str();
2842 assert_eq!(LatencyClass::from_str(s), Ok(class));
2843 }
2844 assert!(LatencyClass::from_str("urgent").is_err());
2845 }
2846
2847 fn ui_only_manifest() -> serde_json::Value {
2850 serde_json::json!({
2851 "manifest_version": 2, "abi": "v1", "name": "burger-runtime", "version": "1.0.0",
2852 "auto_start": false, "has_ui": true, "ui_path": "ui/dist",
2853 "ui": {
2854 "kind": "stage", "entry": "ui/dist/main.js", "title": "Burger", "icon": "ui/dist/icon.svg",
2855 "nav": { "section": "system", "order": 90 }, "ui_api": 1,
2856 "requires": {
2857 "capabilities": ["core.runtime.burger_snapshot", "core.runtime.burger_logs"],
2858 "queries": [], "streams": ["app.burger_metrics"]
2859 },
2860 "data": {
2861 "namespace": "burger-runtime", "offline": "online-only", "sync": "snapshot",
2862 "queries": [{ "name": "burger-runtime.snapshot.v1", "capability": "core.runtime.burger_snapshot", "kind": "snapshot" }],
2863 "streams": [{ "name": "burger-runtime.metrics.v1", "kind": "events" }]
2864 },
2865 "integrity": { "ui/dist/main.js": "a".repeat(64), "ui/dist/icon.svg": "b".repeat(64) }
2866 }
2867 })
2868 }
2869
2870 fn ui_only_with(key: &str, value: serde_json::Value) -> String {
2871 let mut manifest = ui_only_manifest();
2872 manifest[key] = value;
2873 manifest.to_string()
2874 }
2875
2876 #[test]
2877 fn a_stage_manifest_without_app_type_or_entrypoint_is_ui_only() {
2878 let m = parse_ok(&ui_only_manifest().to_string());
2879 assert_eq!(m.app_type, AppType::UiOnly);
2880 assert_eq!(m.app_type.as_str(), "ui-only");
2881 assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
2882 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2883 assert!(m.has_ui);
2884 let ui = m.ui.as_ref().expect("a ui block");
2885 assert_eq!(ui.kind, AppUiKind::Stage);
2886 let data = ui.data.as_ref().expect("the F5 ui.data block validates");
2887 assert_eq!(data.namespace, "burger-runtime");
2888 assert_eq!(data.offline, AppDataOfflinePolicy::OnlineOnly);
2889 assert_eq!(data.queries[0].capability, "core.runtime.burger_snapshot");
2890 assert_eq!(data.streams[0].name, "burger-runtime.metrics.v1");
2891 }
2892
2893 #[test]
2894 fn a_ui_only_manifest_round_trips_through_its_serialized_form() {
2895 let m = parse_ok(&ui_only_manifest().to_string());
2896 let wire = serde_json::to_string(&m).unwrap();
2897 assert!(wire.contains(r#""app_type":"ui-only""#), "{wire}");
2898 assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly);
2899 }
2900
2901 #[test]
2902 fn a_ui_only_manifest_refuses_everything_that_implies_a_process() {
2903 for (key, value) in [
2904 (
2905 "provides",
2906 serde_json::json!({ "burger.runtime.peek": { "description": "x" } }),
2907 ),
2908 (
2909 "capabilities",
2910 serde_json::json!({ "provides": ["burger.runtime.peek"] }),
2911 ),
2912 ("tcp", serde_json::json!({ "preferred_port": 7010 })),
2913 ("resources", serde_json::json!({ "memory_mb": 16 })),
2914 (
2915 "standalone",
2916 serde_json::json!({ "socket_path": "/run/node/x.sock" }),
2917 ),
2918 ("subscribes", serde_json::json!(["system.network.changed"])),
2919 ("depends_on", serde_json::json!(["cron"])),
2920 ] {
2921 let err = parse_err(&ui_only_with(key, value));
2922 assert!(err.contains("ui-only"), "{key}: {err}");
2923 }
2924 let mut explicit_with_entry = ui_only_manifest();
2925 explicit_with_entry["app_type"] = serde_json::json!("ui-only");
2926 explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
2927 assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
2928 }
2929
2930 #[test]
2931 fn a_ui_only_manifest_must_declare_a_stage() {
2932 let mut widget = ui_only_manifest();
2933 widget["ui"]["kind"] = serde_json::json!("widget");
2934 let widget_ui = widget["ui"].as_object_mut().unwrap();
2935 widget_ui.remove("nav");
2936 widget_ui.remove("data");
2939 assert!(parse_err(&widget.to_string()).contains(r#"kind "stage""#));
2940 let no_ui = r#"{"manifest_version":2,"abi":"v1","name":"x","version":"1.0.0","app_type":"ui-only"}"#;
2941 assert!(parse_err(no_ui).contains(r#"kind "stage""#));
2942 }
2943
2944 #[test]
2945 fn declaring_app_type_or_entrypoint_keeps_the_existing_defaults() {
2946 assert_eq!(
2947 parse_ok(r#"{"name":"cron","version":"1.0.0"}"#).app_type,
2948 AppType::Native
2949 );
2950 let mut with_entry = ui_only_manifest();
2951 with_entry["entrypoint"] = serde_json::json!("app.so");
2952 assert_eq!(parse_ok(&with_entry.to_string()).app_type, AppType::Native);
2953 let mut bun = ui_only_manifest();
2954 bun["app_type"] = serde_json::json!("bun");
2955 assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
2956 }
2957
2958 #[test]
2959 fn declares_ui_only_accepts_the_derived_and_the_explicit_form_only() {
2960 assert!(declares_ui_only(&ui_only_manifest()));
2961 assert!(declares_ui_only(
2962 &serde_json::json!({ "app_type": "ui-only" })
2963 ));
2964 assert!(!declares_ui_only(
2965 &serde_json::json!({ "app_type": "bun", "ui": {} })
2966 ));
2967 assert!(!declares_ui_only(
2968 &serde_json::json!({ "entrypoint": "app.so", "ui": {} })
2969 ));
2970 assert!(!declares_ui_only(&serde_json::json!({ "name": "cron" })));
2971 assert!(!declares_ui_only(&serde_json::json!("ui-only")));
2972 }
2973}
2974
2975#[cfg(test)]
2976mod shared_app_data_corpus_tests {
2977 use super::*;
2978
2979 #[test]
2984 fn shared_app_data_corpus_matches_the_host_validator() {
2985 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2986 let mut checked = 0;
2987 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2988 let path = entry.expect("readable entry").path();
2989 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2990 continue;
2991 }
2992 let fixture: serde_json::Value =
2993 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2994 .expect("valid fixture json");
2995 let name = path
2996 .file_name()
2997 .and_then(|n| n.to_str())
2998 .unwrap_or("?")
2999 .to_string();
3000 let manifest = serde_json::json!({
3004 "manifest_version": 2, "abi": "v1", "name": "notes", "version": "0.1.0",
3005 "app_type": "bun", "entrypoint": "dist/index.js",
3006 "ui": fixture["ui"],
3007 });
3008 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
3009 match fixture["expect"].as_str().expect("expect field") {
3010 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
3011 "reject" => {
3012 let error = result.expect_err(&format!("{name} should be rejected"));
3013 let reason = fixture["reason"]
3014 .as_str()
3015 .expect("reject fixtures need a reason");
3016 assert!(
3017 error.contains(reason),
3018 "{name}: {error:?} should mention {reason:?}"
3019 );
3020 }
3021 other => panic!("{name}: unknown expect {other:?}"),
3022 }
3023 checked += 1;
3024 }
3025 assert!(checked >= 10, "expected the full corpus, walked {checked}");
3027 }
3028}
3029
3030#[cfg(test)]
3031mod burger_manifest_tests {
3032 use super::*;
3033
3034 #[test]
3035 fn burger_app_type_parses_with_bundle_entrypoint_default() {
3036 let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
3037 .expect("burger manifest parses");
3038 assert_eq!(m.app_type, AppType::Burger);
3039 assert_eq!(m.app_type.as_str(), "burger");
3040 assert_eq!(m.effective_entrypoint(), "dist/index.js");
3041 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
3042 assert!(m.resources.is_none());
3043 }
3044
3045 #[test]
3046 fn resources_memory_mb_is_carried() {
3047 let m = AppManifest::from_json(
3048 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
3049 )
3050 .expect("resources block parses");
3051 assert_eq!(
3052 m.resources,
3053 Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
3054 );
3055 }
3056
3057 #[test]
3058 fn resources_callback_deadline_ms_is_carried() {
3059 let m = AppManifest::from_json(
3060 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
3061 )
3062 .expect("callback deadline parses");
3063 assert_eq!(
3064 m.resources,
3065 Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
3066 );
3067 let max = AppManifest::from_json(
3068 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
3069 )
3070 .expect("the maximum is inclusive");
3071 assert_eq!(
3072 max.resources.and_then(|r| r.callback_deadline_ms),
3073 Some(MAX_CALLBACK_DEADLINE_MS)
3074 );
3075 }
3076
3077 #[test]
3078 fn resources_callback_deadline_ms_out_of_range_is_rejected() {
3079 for bad in ["0", "600001"] {
3080 let json = format!(
3081 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3082 );
3083 let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
3084 assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
3085 }
3086 }
3087
3088 #[test]
3089 fn resources_callback_deadline_ms_must_be_a_positive_integer() {
3090 for bad in ["-1", "1.5", "\"5000\""] {
3091 let json = format!(
3092 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3093 );
3094 assert!(
3095 AppManifest::from_json(&json).is_err(),
3096 "callback_deadline_ms={bad} must be rejected"
3097 );
3098 }
3099 }
3100
3101 #[test]
3102 fn resources_memory_mb_zero_is_rejected() {
3103 let err = AppManifest::from_json(
3104 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
3105 )
3106 .expect_err("a zero memory limit is invalid");
3107 assert!(err.contains("resources.memory_mb"), "{err}");
3108 }
3109
3110 #[test]
3111 fn burger_manifest_rejects_a_standalone_block() {
3112 let err = AppManifest::from_json(
3113 r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
3114 )
3115 .expect_err("standalone block is only valid for standalone apps");
3116 assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
3117 }
3118}