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}
56
57impl AppType {
58 pub fn as_str(self) -> &'static str {
59 match self {
60 AppType::Native => "native",
61 AppType::Bun => "bun",
62 AppType::Standalone => "standalone",
63 AppType::PlatformRuntime => "platform-runtime",
64 AppType::ManagedV1 => "managed-v1",
65 }
66 }
67}
68
69impl std::fmt::Display for AppType {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(f, "{}", self.as_str())
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum AppTier {
82 FirstParty,
86 Optional,
90 Development,
101}
102
103impl AppTier {
104 pub fn as_str(self) -> &'static str {
105 match self {
106 AppTier::FirstParty => "first_party",
107 AppTier::Optional => "optional",
108 AppTier::Development => "development",
109 }
110 }
111}
112
113impl std::fmt::Display for AppTier {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 write!(f, "{}", self.as_str())
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "lowercase")]
125pub enum AbiVersion {
126 V1,
127}
128
129impl AbiVersion {
130 pub fn as_str(&self) -> &'static str {
131 match self {
132 AbiVersion::V1 => "v1",
133 }
134 }
135
136 pub fn is_supported(&self) -> bool {
138 matches!(self, AbiVersion::V1)
139 }
140}
141
142impl std::fmt::Display for AbiVersion {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 write!(f, "{}", self.as_str())
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum HotReloadKind {
155 Supported,
157 Experimental,
160 Unsupported,
162}
163
164impl HotReloadKind {
165 pub fn default_for(app_type: AppType) -> Self {
166 match app_type {
167 AppType::Native => HotReloadKind::Experimental,
168 AppType::Bun => HotReloadKind::Supported,
169 AppType::Standalone => HotReloadKind::Unsupported,
172 AppType::PlatformRuntime => HotReloadKind::Unsupported,
173 AppType::ManagedV1 => HotReloadKind::Supported,
174 }
175 }
176}
177
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185pub struct ManifestCapabilities {
186 #[serde(default)]
189 pub requires: Vec<String>,
190
191 #[serde(default)]
194 pub provides: Vec<String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, Default)]
199pub struct ProvidedScope {
200 pub scope: String,
201 pub description: String,
202 pub resource_pattern: String,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct EndpointPolicy {
208 pub method: String,
209 pub path: String,
210 pub required_permissions: Vec<String>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ProvidedCapability {
216 #[serde(default)]
217 pub description: String,
218 #[serde(default)]
219 pub schema: Option<serde_json::Value>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct StandaloneConfig {
234 pub socket_path: std::path::PathBuf,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum AppUiKind {
241 Stage,
242 Widget,
243}
244
245fn default_app_ui_kind() -> AppUiKind {
246 AppUiKind::Stage
247}
248
249fn default_nav_section() -> String {
250 "default".to_string()
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct AppUiNav {
256 #[serde(default = "default_nav_section")]
257 pub section: String,
258 #[serde(default)]
259 pub order: i32,
260}
261
262pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail"];
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct AppUiSurface {
284 pub id: String,
285 pub slot: String,
286 pub entry: String,
287 pub title: String,
288 #[serde(default)]
289 pub order: i32,
290 #[serde(default)]
291 pub requires: AppUiRequirements,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "kebab-case")]
297pub enum AppDataOfflinePolicy {
298 LastKnown,
300 OnlineOnly,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "kebab-case")]
307pub enum AppDataQueryKind {
308 Collection,
309 Detail,
310 Snapshot,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "kebab-case")]
316pub enum AppDataStreamKind {
317 Changes,
318 Events,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "kebab-case")]
324pub enum AppDataSyncKind {
325 Cursor,
326 Snapshot,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct AppDataSyncPolicy {
332 pub kind: AppDataSyncKind,
333 pub cursor_ttl_secs: Option<u32>,
334 pub full_refresh_interval_secs: Option<u32>,
335 pub retention_secs: Option<u32>,
336}
337
338impl Serialize for AppDataSyncPolicy {
339 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
340 where
341 S: serde::Serializer,
342 {
343 use serde::ser::SerializeStruct;
344
345 if self.cursor_ttl_secs.is_none()
346 && self.full_refresh_interval_secs.is_none()
347 && self.retention_secs.is_none()
348 {
349 return self.kind.serialize(serializer);
350 }
351
352 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
353 state.serialize_field("kind", &self.kind)?;
354 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
355 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
356 }
357 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
358 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
359 }
360 if let Some(retention_secs) = self.retention_secs {
361 state.serialize_field("retention_secs", &retention_secs)?;
362 }
363 state.end()
364 }
365}
366
367impl<'de> Deserialize<'de> for AppDataSyncPolicy {
368 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
369 where
370 D: serde::Deserializer<'de>,
371 {
372 #[derive(Deserialize)]
373 #[serde(deny_unknown_fields)]
374 struct ObjectPolicy {
375 kind: AppDataSyncKind,
376 #[serde(default)]
377 cursor_ttl_secs: Option<u32>,
378 #[serde(default)]
379 full_refresh_interval_secs: Option<u32>,
380 #[serde(default)]
381 retention_secs: Option<u32>,
382 }
383
384 #[derive(Deserialize)]
385 #[serde(untagged)]
386 enum WirePolicy {
387 Kind(AppDataSyncKind),
388 Object(ObjectPolicy),
389 }
390
391 match WirePolicy::deserialize(deserializer)? {
392 WirePolicy::Kind(kind) => Ok(Self {
393 kind,
394 cursor_ttl_secs: None,
395 full_refresh_interval_secs: None,
396 retention_secs: None,
397 }),
398 WirePolicy::Object(policy) => Ok(Self {
399 kind: policy.kind,
400 cursor_ttl_secs: policy.cursor_ttl_secs,
401 full_refresh_interval_secs: policy.full_refresh_interval_secs,
402 retention_secs: policy.retention_secs,
403 }),
404 }
405 }
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct AppDataQueryDeclaration {
412 pub name: String,
413 pub capability: String,
414 pub kind: AppDataQueryKind,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct AppDataStreamDeclaration {
421 pub name: String,
422 pub kind: AppDataStreamKind,
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(deny_unknown_fields)]
428pub struct AppDataManifest {
429 pub namespace: String,
430 pub offline: AppDataOfflinePolicy,
431 pub sync: AppDataSyncPolicy,
432 #[serde(default)]
433 pub queries: Vec<AppDataQueryDeclaration>,
434 #[serde(default)]
435 pub streams: Vec<AppDataStreamDeclaration>,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
443#[serde(deny_unknown_fields)]
444pub struct AppUiRequirements {
445 #[serde(default)]
446 pub capabilities: Vec<String>,
447 #[serde(default)]
448 pub queries: Vec<String>,
449 #[serde(default)]
450 pub streams: Vec<String>,
451}
452
453impl AppUiRequirements {
454 pub fn resolved(&self) -> Result<Vec<String>, String> {
458 let mut resolved = Vec::new();
459 let mut seen = HashSet::new();
460 for (values, allow_wildcard) in [
461 (&self.capabilities, true),
462 (&self.queries, false),
463 (&self.streams, false),
464 ] {
465 for value in values {
466 let requirement = value.trim();
467 if requirement.is_empty() {
468 return Err("ui.requires entries must not be blank".to_string());
469 }
470 validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
471 format!("ui.requires entry '{requirement}' invalid: {error}")
472 })?;
473 if seen.insert(requirement.to_string()) {
474 resolved.push(requirement.to_string());
475 }
476 }
477 }
478 Ok(resolved)
479 }
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484pub struct AppUiManifest {
485 #[serde(default = "default_app_ui_kind")]
486 pub kind: AppUiKind,
487 pub entry: String,
488 pub title: String,
489 #[serde(default)]
490 pub icon: Option<String>,
491 #[serde(default)]
492 pub nav: Option<AppUiNav>,
493 #[serde(default)]
494 pub composes: Vec<String>,
495 #[serde(default)]
497 pub surfaces: Vec<AppUiSurface>,
498 pub ui_api: u8,
499 #[serde(default)]
500 pub integrity: BTreeMap<String, String>,
501 #[serde(default)]
505 pub description: Option<String>,
506 #[serde(default)]
509 pub keywords: Vec<String>,
510 #[serde(default)]
513 pub requires: AppUiRequirements,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub data: Option<AppDataManifest>,
516}
517
518pub fn validate_manifest_path(path: &str) -> Result<(), String> {
530 if path.is_empty() {
531 return Err("path must not be empty".to_string());
532 }
533
534 if path.starts_with('/') {
536 return Err(format!(
537 "path '{}' must not be absolute (starts with /)",
538 path
539 ));
540 }
541
542 let first = path.chars().next().unwrap();
545 if !first.is_ascii_alphanumeric() && first != '_' {
546 return Err(format!(
547 "path '{}' must begin with an alphanumeric character or underscore",
548 path
549 ));
550 }
551 for ch in path.chars().skip(1) {
552 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
553 return Err(format!(
554 "path '{}' contains disallowed character '{}'",
555 path, ch
556 ));
557 }
558 }
559
560 for segment in path.split('/') {
562 if segment == ".." {
563 return Err(format!(
564 "path '{}' contains a '..' segment (path traversal rejected)",
565 path
566 ));
567 }
568 }
569
570 Ok(())
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct AppManifest {
582 #[serde(default = "default_manifest_version", rename = "manifest_version")]
584 pub manifest_version: u8,
585
586 pub name: String,
587 pub version: String,
588
589 #[serde(default = "default_app_type_native")]
590 pub app_type: AppType,
591
592 #[serde(default)]
593 pub description: String,
594
595 pub abi: Option<AbiVersion>,
598
599 pub entrypoint: Option<String>,
602
603 pub hot_reload: Option<HotReloadKind>,
606
607 #[serde(default)]
609 pub critical: bool,
610
611 #[serde(
612 default = "default_auto_start",
613 deserialize_with = "deserialize_auto_start"
614 )]
615 pub auto_start: bool,
616
617 #[serde(default)]
618 pub has_ui: bool,
619
620 #[serde(default = "default_ui_path")]
621 pub ui_path: String,
622
623 #[serde(default)]
624 pub permissions: Vec<String>,
625
626 #[serde(default)]
629 pub requires: Vec<String>,
630
631 #[serde(default)]
632 pub optional_permissions: Vec<String>,
633
634 #[serde(default)]
635 pub provides_scopes: Vec<ProvidedScope>,
636
637 #[serde(default)]
638 pub endpoint_policies: Vec<EndpointPolicy>,
639
640 #[serde(default)]
641 pub capability_scopes: HashMap<String, String>,
642
643 #[serde(default)]
644 pub provides: HashMap<String, ProvidedCapability>,
645
646 #[serde(default)]
648 pub capabilities: ManifestCapabilities,
649
650 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub ui: Option<AppUiManifest>,
654
655 #[serde(default)]
657 pub author: Option<String>,
658
659 #[serde(default)]
660 pub homepage: Option<String>,
661
662 #[serde(default)]
663 pub depends_on: Option<Vec<String>>,
664
665 #[serde(default)]
666 pub boot_priority: Option<u32>,
667
668 #[serde(default)]
672 pub governor: Option<GovernorManifest>,
673
674 #[serde(default)]
680 pub subscribes: Vec<String>,
681
682 #[serde(default)]
686 pub standalone: Option<StandaloneConfig>,
687
688 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub tcp: Option<TcpManifest>,
692}
693
694#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
697pub struct GovernorManifest {
698 #[serde(default)]
702 pub terminable: Option<bool>,
703
704 #[serde(default)]
708 pub min_idle_secs: Option<u64>,
709
710 #[serde(default)]
743 pub memory_budget_kb: Option<u64>,
744}
745
746#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
750pub struct TcpManifest {
751 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub preferred_port: Option<u16>,
756
757 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub direct_bind: Option<bool>,
765}
766
767fn default_manifest_version() -> u8 {
768 1
769}
770
771fn default_auto_start() -> bool {
772 true
773}
774
775fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
781where
782 D: serde::Deserializer<'de>,
783{
784 #[derive(Deserialize)]
785 #[serde(untagged)]
786 enum BoolOrStr {
787 Bool(bool),
788 Str(String),
789 }
790 Ok(match BoolOrStr::deserialize(deserializer)? {
791 BoolOrStr::Bool(b) => b,
792 BoolOrStr::Str(s) => matches!(
793 s.trim().to_ascii_lowercase().as_str(),
794 "true" | "eager" | "active" | "auto" | "on" | "1"
795 ),
796 })
797}
798
799fn default_ui_path() -> String {
800 "dist".to_string()
801}
802
803fn default_app_type_native() -> AppType {
804 AppType::Native
805}
806
807impl AppManifest {
808 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
812 let top = normalized_requirements(&self.requires)?;
813 let nested = normalized_requirements(&self.capabilities.requires)?;
814 if !top.is_empty()
815 && !nested.is_empty()
816 && top.iter().cloned().collect::<BTreeSet<_>>()
817 != nested.iter().cloned().collect::<BTreeSet<_>>()
818 {
819 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
820 }
821 Ok(if !top.is_empty() { top } else { nested })
822 }
823
824 pub fn effective_hot_reload(&self) -> HotReloadKind {
827 self.hot_reload
828 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
829 }
830
831 pub fn effective_entrypoint(&self) -> &str {
836 if let Some(ref ep) = self.entrypoint {
837 ep.as_str()
838 } else {
839 match self.app_type {
840 AppType::Native => "app.so",
841 AppType::Bun => "dist/index.js",
842 AppType::Standalone => "",
843 AppType::PlatformRuntime => "bun",
844 AppType::ManagedV1 => "llmc-generated-app",
845 }
846 }
847 }
848
849 pub fn has_capability_providers(&self) -> bool {
852 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
853 }
854
855 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
869 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
870 for raw in &self.capabilities.provides {
871 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
872 if name.is_empty() {
873 continue;
874 }
875 out.entry(name).or_insert(ProvidedCapability {
876 description: String::new(),
877 schema: None,
878 });
879 }
880 out
881 }
882
883 pub fn validate(&self) -> Result<(), String> {
888 self.validate_with_socket_path_policy(false)
889 }
890
891 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
896 if self.manifest_version == 2 && self.abi.is_none() {
898 return Err("manifest_version 2 requires an 'abi' field".to_string());
899 }
900
901 validate_app_name(&self.name)?;
904 self.resolved_requires()?;
905
906 if let Some(ref ep) = self.entrypoint {
908 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
909 }
910 if !self.ui_path.is_empty() && self.ui_path != "dist" {
912 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
913 }
914
915 if let Some(ui) = &self.ui {
916 validate_app_ui(&self.name, ui)?;
917 }
918
919 if let Some(ref hp) = self.homepage {
921 if !hp.starts_with("https://") && !hp.starts_with("http://") {
922 return Err(format!(
923 "homepage '{}' must use https:// or http:// scheme",
924 hp
925 ));
926 }
927 }
928
929 match self.app_type {
936 AppType::Standalone => {
937 if self.has_capability_providers() {
938 let cfg = self.standalone.as_ref().ok_or_else(|| {
939 "standalone apps that declare 'provides' require a \
940 'standalone.socket_path' field"
941 .to_string()
942 })?;
943 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
944 }
945 }
946 AppType::Native | AppType::Bun | AppType::PlatformRuntime | AppType::ManagedV1 => {
947 if self.standalone.is_some() {
948 return Err(format!(
949 "'standalone' block is only valid when app_type == 'standalone' \
950 (found app_type='{}')",
951 self.app_type
952 ));
953 }
954 }
955 }
956
957 if self.app_type == AppType::PlatformRuntime
958 && !self.resolved_capability_provides().is_empty()
959 {
960 return Err(
961 "platform-runtime packages cannot provide runtime capabilities".to_string(),
962 );
963 }
964
965 Ok(())
966 }
967
968 pub fn from_json(json: &str) -> Result<Self, String> {
970 Self::from_json_with_socket_path_policy(json, false)
971 }
972
973 pub fn from_json_with_socket_path_policy(
975 json: &str,
976 allow_non_run: bool,
977 ) -> Result<Self, String> {
978 let mut manifest: Self =
979 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
980 if manifest.ui.is_some() {
981 manifest.has_ui = true;
982 }
983 manifest.validate_with_socket_path_policy(allow_non_run)?;
984 Ok(manifest)
985 }
986}
987
988fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
989 let mut seen = HashSet::new();
990 let mut resolved = Vec::new();
991 for value in values {
992 let requirement = value.trim();
993 if requirement.is_empty() {
994 return Err("capability requirements must not be blank".to_string());
995 }
996 if seen.insert(requirement.to_string()) {
997 resolved.push(requirement.to_string());
998 }
999 }
1000 Ok(resolved)
1001}
1002
1003fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1004 if ui.ui_api != 1 && ui.ui_api != 2 {
1005 return Err(format!(
1006 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1007 ui.ui_api
1008 ));
1009 }
1010 if ui.title.trim().is_empty() {
1011 return Err("ui.title must not be blank".to_string());
1012 }
1013 ui.requires.resolved()?;
1014 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1015 if let Some(icon) = &ui.icon {
1016 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1017 }
1018 if let Some(nav) = &ui.nav {
1019 if ui.kind == AppUiKind::Widget {
1020 return Err("widget ui must omit nav metadata".to_string());
1021 }
1022 if nav.section.trim().is_empty() {
1023 return Err("ui.nav.section must not be blank".to_string());
1024 }
1025 }
1026 if let Some(data) = &ui.data {
1027 if ui.kind != AppUiKind::Stage {
1028 return Err("widget ui must omit app data declarations".to_string());
1029 }
1030 validate_app_data(data, &ui.requires.resolved()?)?;
1031 }
1032
1033 let mut composed = HashSet::new();
1034 for name in &ui.composes {
1035 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1036 if name == app_name {
1037 return Err("ui.composes must not contain the app itself".to_string());
1038 }
1039 if !composed.insert(name) {
1040 return Err(format!("ui.composes contains duplicate app '{name}'"));
1041 }
1042 }
1043
1044 let mut surface_ids = HashSet::new();
1045 for surface in &ui.surfaces {
1046 let id = surface.id.trim();
1047 if id.is_empty() {
1048 return Err("ui.surfaces entry id must not be blank".to_string());
1049 }
1050 if !surface_ids.insert(id.to_string()) {
1051 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1052 }
1053 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1054 return Err(format!(
1055 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1056 surface.slot,
1057 KNOWN_SURFACE_SLOTS.join(", ")
1058 ));
1059 }
1060 if surface.title.trim().is_empty() {
1061 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1062 }
1063 validate_manifest_path(&surface.entry)
1064 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1065 if !ui.integrity.contains_key(&surface.entry) {
1074 return Err(format!("ui.integrity must include surface '{id}' entry"));
1075 }
1076 surface
1077 .requires
1078 .resolved()
1079 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1080 }
1081
1082 for (path, digest) in &ui.integrity {
1083 validate_manifest_path(path)
1084 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1085 if !is_lowercase_sha256(digest) {
1086 return Err(format!(
1087 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1088 ));
1089 }
1090 }
1091 if !ui.integrity.contains_key(&ui.entry) {
1092 return Err("ui.integrity must include the declared entry".to_string());
1093 }
1094 if let Some(icon) = &ui.icon {
1095 if !ui.integrity.contains_key(icon) {
1096 return Err("ui.integrity must include the declared icon".to_string());
1097 }
1098 }
1099 Ok(())
1100}
1101
1102fn validate_app_data(data: &AppDataManifest, resolved_requires: &[String]) -> Result<(), String> {
1103 validate_app_data_namespace(&data.namespace)?;
1104 validate_app_data_sync_policy(&data.sync)?;
1105 if data.queries.is_empty() {
1106 return Err("ui.data.queries must declare at least one query".to_string());
1107 }
1108
1109 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1110 let mut names = BTreeSet::new();
1111 for query in &data.queries {
1112 validate_namespaced_data_name(&query.name, &data.namespace)
1113 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1114 validate_capability_name(&query.capability).map_err(|error| {
1115 format!(
1116 "ui.data query '{}' capability '{}' invalid: {error}",
1117 query.name, query.capability
1118 )
1119 })?;
1120 if !requires.contains(query.capability.as_str()) {
1121 return Err(format!(
1122 "ui.data query '{}' capability '{}' must be declared in requires",
1123 query.name, query.capability
1124 ));
1125 }
1126 if !names.insert(query.name.as_str()) {
1127 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1128 }
1129 }
1130
1131 for stream in &data.streams {
1132 validate_namespaced_data_name(&stream.name, &data.namespace)
1133 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1134 if !names.insert(stream.name.as_str()) {
1135 return Err(format!(
1136 "ui.data contains duplicate declaration '{}'",
1137 stream.name
1138 ));
1139 }
1140 }
1141
1142 Ok(())
1143}
1144
1145fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1146 if !is_safe_name_segment(namespace) {
1147 return Err(format!(
1148 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1149 namespace
1150 ));
1151 }
1152 if matches!(
1153 namespace,
1154 "core" | "internal" | "node" | "platform" | "system"
1155 ) {
1156 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1157 }
1158 Ok(())
1159}
1160
1161fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1162 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1163 validate_optional_range(
1164 "full_refresh_interval_secs",
1165 sync.full_refresh_interval_secs,
1166 60,
1167 604_800,
1168 )?;
1169 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1170 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1171 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1172 }
1173 Ok(())
1174}
1175
1176fn validate_optional_range(
1177 field: &str,
1178 value: Option<u32>,
1179 min: u32,
1180 max: u32,
1181) -> Result<(), String> {
1182 if let Some(value) = value {
1183 if value < min || value > max {
1184 return Err(format!(
1185 "ui.data.sync {field} must be between {min} and {max} seconds"
1186 ));
1187 }
1188 }
1189 Ok(())
1190}
1191
1192fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1193 validate_capability_name(name)?;
1194 let Some(rest) = name
1195 .strip_prefix(namespace)
1196 .and_then(|suffix| suffix.strip_prefix('.'))
1197 else {
1198 return Err(format!("name must use namespace '{namespace}'"));
1199 };
1200 if rest.is_empty() {
1201 return Err("name must include a value after its namespace".to_string());
1202 }
1203 if !has_version_suffix(name) {
1204 return Err("name must end with a .vN version suffix".to_string());
1205 }
1206 Ok(())
1207}
1208
1209fn validate_capability_name(name: &str) -> Result<(), String> {
1210 if name.is_empty() {
1211 return Err("name must not be empty".to_string());
1212 }
1213 if name.contains('/') || name.contains("..") {
1214 return Err("name must not contain path separators or traversal".to_string());
1215 }
1216 if !name.split('.').all(is_safe_declaration_segment) {
1217 return Err("name must contain only lowercase dot-separated segments".to_string());
1218 }
1219 Ok(())
1220}
1221
1222fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1223 if allow_wildcard && name.ends_with(".*") {
1224 return validate_capability_name(&name[..name.len() - 2]);
1225 }
1226 validate_capability_name(name)
1227}
1228
1229fn has_version_suffix(name: &str) -> bool {
1230 let Some(version) = name.rsplit('.').next() else {
1231 return false;
1232 };
1233 let Some(digits) = version.strip_prefix('v') else {
1234 return false;
1235 };
1236 !digits.is_empty()
1237 && !digits.starts_with('0')
1238 && digits.bytes().all(|byte| byte.is_ascii_digit())
1239}
1240
1241fn is_safe_name_segment(segment: &str) -> bool {
1242 if segment.is_empty() {
1243 return false;
1244 }
1245 let mut chars = segment.chars();
1246 let Some(first) = chars.next() else {
1247 return false;
1248 };
1249 first.is_ascii_lowercase()
1250 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1251}
1252
1253fn is_safe_declaration_segment(segment: &str) -> bool {
1266 let mut chars = segment.chars();
1267 let Some(first) = chars.next() else {
1268 return false;
1269 };
1270 first.is_ascii_lowercase()
1271 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1272}
1273
1274fn is_lowercase_sha256(value: &str) -> bool {
1275 value.len() == 64
1276 && value
1277 .bytes()
1278 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1279}
1280
1281pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1290 validate_standalone_socket_path_with_policy(path, false)
1291}
1292
1293pub fn validate_standalone_socket_path_with_policy(
1295 path: &std::path::Path,
1296 allow_non_run: bool,
1297) -> Result<(), String> {
1298 if !path.is_absolute() {
1299 return Err(format!(
1300 "standalone.socket_path '{}' must be absolute",
1301 path.display()
1302 ));
1303 }
1304 if !allow_non_run && !path.starts_with("/run/") {
1305 return Err(format!(
1306 "standalone.socket_path '{}' must live under /run/",
1307 path.display()
1308 ));
1309 }
1310 if path
1311 .components()
1312 .any(|c| matches!(c, std::path::Component::ParentDir))
1313 {
1314 return Err(format!(
1315 "standalone.socket_path '{}' must not contain '..' segments",
1316 path.display()
1317 ));
1318 }
1319 Ok(())
1320}
1321
1322fn validate_app_name(name: &str) -> Result<(), String> {
1326 let (publisher, app) = if let Some(slash) = name.find('/') {
1327 let (p, rest) = name.split_at(slash);
1328 (Some(p), &rest[1..])
1329 } else {
1330 (None, name)
1331 };
1332
1333 let valid_segment = |s: &str| -> bool {
1334 if s.is_empty() {
1335 return false;
1336 }
1337 let mut chars = s.chars();
1338 let first = chars.next().unwrap();
1339 if !first.is_ascii_lowercase() {
1340 return false;
1341 }
1342 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1343 };
1344
1345 if let Some(pub_name) = publisher {
1346 if !valid_segment(pub_name) {
1347 return Err(format!(
1348 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1349 pub_name
1350 ));
1351 }
1352 }
1353
1354 if !valid_segment(app) {
1355 return Err(format!(
1356 "app name segment '{}' must match [a-z][a-z0-9-]*",
1357 app
1358 ));
1359 }
1360
1361 Ok(())
1362}
1363
1364#[cfg(test)]
1367mod tests {
1368 use super::*;
1369
1370 fn parse_ok(json: &str) -> AppManifest {
1371 AppManifest::from_json(json).expect("should parse")
1372 }
1373
1374 fn parse_err(json: &str) -> String {
1375 AppManifest::from_json(json).expect_err("should fail")
1376 }
1377
1378 #[test]
1381 fn v1_minimal_native() {
1382 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1383 assert_eq!(m.manifest_version, 1);
1384 assert_eq!(m.app_type, AppType::Native);
1385 assert!(m.abi.is_none());
1386 }
1387
1388 #[test]
1389 fn v1_minimal_bun() {
1390 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1391 assert_eq!(m.app_type, AppType::Bun);
1392 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1393 }
1394
1395 #[test]
1396 fn v1_no_manifest_version_field_defaults_to_1() {
1397 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1398 assert_eq!(m.manifest_version, 1);
1399 }
1400
1401 #[test]
1402 fn v1_all_optional_fields_missing() {
1403 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1404 assert!(!m.critical);
1405 assert!(m.auto_start);
1406 assert!(!m.has_ui);
1407 assert_eq!(m.ui_path, "dist");
1408 assert!(m.permissions.is_empty());
1409 assert!(m.optional_permissions.is_empty());
1410 assert!(m.governor.is_none());
1413 assert!(m.subscribes.is_empty());
1414 }
1415
1416 #[test]
1417 fn v1_with_permissions_and_provides() {
1418 let json = r#"{
1419 "name": "example",
1420 "version": "1.0.0",
1421 "app_type": "bun",
1422 "permissions": ["core.storage.kv"],
1423 "optional_permissions": ["core.notifications.create"],
1424 "provides": {
1425 "core.example.run": { "description": "Run example job" }
1426 }
1427 }"#;
1428 let m = parse_ok(json);
1429 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1430 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1431 assert!(m.provides.contains_key("core.example.run"));
1432 }
1433
1434 #[test]
1437 fn v2_minimal_native() {
1438 let json = r#"{
1439 "manifest_version": 2,
1440 "name": "cron",
1441 "version": "1.0.0",
1442 "app_type": "native",
1443 "abi": "v1",
1444 "entrypoint": "app.so",
1445 "hot_reload": "experimental"
1446 }"#;
1447 let m = parse_ok(json);
1448 assert_eq!(m.manifest_version, 2);
1449 assert_eq!(m.abi, Some(AbiVersion::V1));
1450 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1451 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1452 }
1453
1454 #[test]
1455 fn v2_minimal_bun_with_capabilities() {
1456 let json = r#"{
1457 "manifest_version": 2,
1458 "name": "example-fullstack",
1459 "version": "1.0.0",
1460 "app_type": "bun",
1461 "abi": "v1",
1462 "entrypoint": "dist/index.js",
1463 "hot_reload": "supported",
1464 "has_ui": true,
1465 "ui_path": "ui/dist",
1466 "capabilities": {
1467 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1468 "provides": []
1469 },
1470 "governor": { "terminable": false, "min_idle_secs": 300 },
1471 "subscribes": ["core.chat.message.received"]
1472 }"#;
1473 let m = parse_ok(json);
1474 assert_eq!(m.manifest_version, 2);
1475 assert_eq!(m.capabilities.requires.len(), 2);
1476 let governor = m.governor.expect("governor block should parse");
1478 assert_eq!(governor.terminable, Some(false));
1479 assert_eq!(governor.min_idle_secs, Some(300));
1480 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1481 }
1482
1483 #[test]
1484 fn stage_contract_fixture_parses_with_normalized_requirements() {
1485 let json = include_str!(
1486 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1487 );
1488 let m = parse_ok(json);
1489 assert!(m.has_ui);
1490 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1491 let ui = m.ui.expect("fixture should declare ui");
1492 assert_eq!(ui.kind, AppUiKind::Stage);
1493 assert_eq!(ui.entry, "ui/main.js");
1494 assert_eq!(ui.nav.unwrap().order, 10);
1495 }
1496
1497 #[test]
1498 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1499 let m = parse_ok(
1500 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1501 );
1502 assert!(m.has_ui);
1503 assert_eq!(m.ui_path, "ui/dist");
1504 assert!(m.ui.is_none());
1505 }
1506
1507 #[test]
1508 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1509 let manifest = parse_ok(
1510 r#"{
1511 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1512 "requires":["core.cron.register"],
1513 "ui":{
1514 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1515 "requires":{
1516 "capabilities":["ui.snapshot.v1"],
1517 "queries":["ui.query.v1"],
1518 "streams":["ui.event.v1"]
1519 },
1520 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1521 }
1522 }"#,
1523 );
1524
1525 assert_eq!(
1526 manifest.resolved_requires().unwrap(),
1527 vec!["core.cron.register"]
1528 );
1529 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1530 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1531 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1532 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1533 assert_eq!(
1534 ui.requires.resolved().unwrap(),
1535 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1536 );
1537 let serialized = serde_json::to_value(ui).unwrap();
1538 assert_eq!(
1539 serialized["requires"]["queries"],
1540 serde_json::json!(["ui.query.v1"])
1541 );
1542 }
1543
1544 #[test]
1545 fn ui_requirements_reject_blank_entries() {
1546 let error = parse_err(
1547 r#"{
1548 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1549 "ui":{
1550 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1551 "requires":{"capabilities":[""],"queries":[],"streams":[]},
1552 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1553 }
1554 }"#,
1555 );
1556 assert!(error.contains("ui.requires entries must not be blank"));
1557 }
1558
1559 #[test]
1560 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1561 assert!(validate_app_data_namespace("obs-viewer").is_ok());
1568
1569 let error = validate_app_data_namespace("obs_viewer")
1570 .expect_err("underscore must not be admitted into a storage namespace");
1571 assert!(
1572 error.contains("must match [a-z][a-z0-9-]*"),
1573 "unexpected error: {error}"
1574 );
1575 }
1576
1577 #[test]
1578 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1579 let error = parse_err(
1580 r#"{
1581 "name":"ui-data","version":"1.0.0","app_type":"bun",
1582 "requires":["ui.snapshot.v1"],
1583 "ui":{
1584 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1585 "requires":{"capabilities":[],"queries":[],"streams":[]},
1586 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1587 "data":{
1588 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1589 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1590 "streams":[]
1591 }
1592 }
1593 }"#,
1594 );
1595 assert!(error.contains("must be declared in requires"));
1596 }
1597
1598 #[test]
1599 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1600 let accepted = parse_ok(
1601 r#"{
1602 "name":"aliases","version":"1.0.0","app_type":"bun",
1603 "requires":["core.chat.read","core.chat.read","core.chat.send"],
1604 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1605 }"#,
1606 );
1607 assert_eq!(
1608 accepted.resolved_requires().unwrap(),
1609 vec!["core.chat.read", "core.chat.send"]
1610 );
1611
1612 let err = parse_err(
1613 r#"{
1614 "name":"aliases","version":"1.0.0","app_type":"bun",
1615 "requires":["core.chat.read"],
1616 "capabilities":{"requires":["core.wallet.pay"]}
1617 }"#,
1618 );
1619 assert!(err.contains("conflicts"), "unexpected error: {err}");
1620 }
1621
1622 #[test]
1623 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1624 let base = |ui: &str| {
1625 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1626 };
1627 let widget = base(
1628 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1629 );
1630 assert_eq!(
1631 parse_ok(&widget).ui.expect("widget ui").kind,
1632 AppUiKind::Widget
1633 );
1634 let widget_nav = base(
1635 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1636 );
1637 assert!(parse_err(&widget_nav).contains("must omit nav"));
1638 let api = base(
1639 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1640 );
1641 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1642 let unsupported_api = base(
1643 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1644 );
1645 assert!(parse_err(&unsupported_api).contains("ui_api"));
1646 let path = base(
1647 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1648 );
1649 assert!(parse_err(&path).contains("entry"));
1650 }
1651
1652 #[test]
1653 fn stage_requires_integrity_for_entry_and_icon() {
1654 let missing_entry = r#"{
1655 "name":"stage","version":"1.0.0","app_type":"bun",
1656 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1657 }"#;
1658 assert!(parse_err(missing_entry).contains("entry"));
1659 let missing_icon = r#"{
1660 "name":"stage","version":"1.0.0","app_type":"bun",
1661 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1662 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1663 }"#;
1664 assert!(parse_err(missing_icon).contains("icon"));
1665 let uppercase = r#"{
1666 "name":"stage","version":"1.0.0","app_type":"bun",
1667 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1668 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1669 }"#;
1670 assert!(parse_err(uppercase).contains("lowercase"));
1671 }
1672
1673 #[test]
1674 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1675 let manifest = |composes: &str| {
1676 format!(
1677 r#"{{
1678 "name":"stage","version":"1.0.0","app_type":"bun",
1679 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1680 "composes":{composes},
1681 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1682 }}"#
1683 )
1684 };
1685 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1686 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1687 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1688 }
1689
1690 fn manifest_with_ui() -> AppManifest {
1702 parse_ok(
1703 r#"{
1704 "name":"stage","version":"1.0.0","app_type":"bun",
1705 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1706 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1707 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
1708 }"#,
1709 )
1710 }
1711
1712 fn surface(id: &str, slot: &str) -> AppUiSurface {
1713 AppUiSurface {
1714 id: id.to_string(),
1715 slot: slot.to_string(),
1716 entry: "ui/dist/surfaces/chip.js".to_string(),
1717 title: "Chip".to_string(),
1718 order: 10,
1719 requires: AppUiRequirements {
1720 capabilities: vec!["wallet.balance.get".to_string()],
1721 ..Default::default()
1722 },
1723 }
1724 }
1725
1726 #[test]
1727 fn manifest_without_surfaces_still_parses() {
1728 let manifest = manifest_with_ui();
1729 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
1730 assert!(manifest.validate().is_ok());
1731 }
1732
1733 #[test]
1734 fn surface_in_a_known_slot_is_accepted() {
1735 let mut manifest = manifest_with_ui();
1736 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
1737 assert!(manifest.validate().is_ok());
1738 }
1739
1740 #[test]
1741 fn surface_in_an_unknown_slot_is_rejected() {
1742 let mut manifest = manifest_with_ui();
1743 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
1744 let error = manifest.validate().unwrap_err();
1745 assert!(error.contains("menu-bar"), "unexpected error: {error}");
1746 }
1747
1748 #[test]
1749 fn duplicate_surface_ids_are_rejected() {
1750 let mut manifest = manifest_with_ui();
1751 manifest.ui.as_mut().unwrap().surfaces = vec![
1752 surface("chip", "status-rail"),
1753 surface("chip", "status-rail"),
1754 ];
1755 let error = manifest.validate().unwrap_err();
1756 assert!(error.contains("duplicate"), "unexpected error: {error}");
1757 }
1758
1759 #[test]
1760 fn surface_with_a_blank_id_is_rejected() {
1761 let mut manifest = manifest_with_ui();
1762 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
1763 assert!(manifest.validate().is_err());
1764 }
1765
1766 #[test]
1767 fn surface_with_an_unsafe_entry_path_is_rejected() {
1768 let mut manifest = manifest_with_ui();
1769 let mut bad = surface("chip", "status-rail");
1770 bad.entry = "../../etc/passwd".to_string();
1771 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1772 assert!(manifest.validate().is_err());
1773 }
1774
1775 #[test]
1776 fn surface_entry_missing_from_integrity_is_rejected() {
1777 let mut manifest = manifest_with_ui();
1781 let mut unpinned = surface("chip", "status-rail");
1782 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
1783 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
1784 let error = manifest.validate().unwrap_err();
1785 assert!(
1786 error.contains("ui.integrity must include surface 'chip' entry"),
1787 "unexpected error: {error}"
1788 );
1789 }
1790
1791 #[test]
1792 fn surface_with_a_blank_required_capability_is_rejected() {
1793 let mut manifest = manifest_with_ui();
1794 let mut bad = surface("chip", "status-rail");
1795 bad.requires.capabilities = vec![" ".to_string()];
1796 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1797 assert!(manifest.validate().is_err());
1798 }
1799
1800 #[test]
1801 fn v2_missing_abi_is_error() {
1802 let json = r#"{
1803 "manifest_version": 2,
1804 "name": "example",
1805 "version": "1.0.0",
1806 "app_type": "bun"
1807 }"#;
1808 let err = parse_err(json);
1809 assert!(err.contains("abi"), "expected abi error, got: {}", err);
1810 }
1811
1812 #[test]
1815 fn publisher_prefixed_name_accepted() {
1816 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1817 assert_eq!(m.name, "alice/weather");
1818 }
1819
1820 #[test]
1821 fn double_slash_name_rejected() {
1822 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1823 assert!(!err.is_empty());
1824 }
1825
1826 #[test]
1829 fn name_starting_with_digit_rejected() {
1830 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1831 assert!(!err.is_empty());
1832 }
1833
1834 #[test]
1835 fn name_with_uppercase_rejected() {
1836 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1837 assert!(!err.is_empty());
1838 }
1839
1840 #[test]
1841 fn empty_name_rejected() {
1842 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1843 assert!(!err.is_empty());
1844 }
1845
1846 #[test]
1849 fn path_traversal_double_dot_rejected() {
1850 let json = r#"{
1851 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1852 "app_type": "bun", "abi": "v1",
1853 "entrypoint": "../etc/passwd"
1854 }"#;
1855 let err = parse_err(json);
1856 assert!(err.contains(".."), "expected traversal error, got: {}", err);
1857 }
1858
1859 #[test]
1860 fn path_traversal_encoded_dot_not_decoded() {
1861 let json = r#"{
1863 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1864 "app_type": "bun", "abi": "v1",
1865 "entrypoint": "foo/../bar"
1866 }"#;
1867 let err = parse_err(json);
1868 assert!(!err.is_empty(), "should have failed: {}", err);
1869 }
1870
1871 #[test]
1872 fn absolute_path_rejected() {
1873 let json = r#"{
1874 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1875 "app_type": "bun", "abi": "v1",
1876 "entrypoint": "/usr/bin/sh"
1877 }"#;
1878 let err = parse_err(json);
1879 assert!(
1880 err.contains("absolute"),
1881 "expected absolute error, got: {}",
1882 err
1883 );
1884 }
1885
1886 #[test]
1887 fn shell_metachar_in_path_rejected() {
1888 let json = r#"{
1889 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1890 "app_type": "bun", "abi": "v1",
1891 "entrypoint": "dist/index.js;rm -rf /"
1892 }"#;
1893 let err = parse_err(json);
1894 assert!(!err.is_empty());
1895 }
1896
1897 #[test]
1898 fn valid_nested_path_accepted() {
1899 let json = r#"{
1900 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1901 "app_type": "bun", "abi": "v1",
1902 "entrypoint": "dist/index.js",
1903 "ui_path": "ui/dist"
1904 }"#;
1905 parse_ok(json);
1906 }
1907
1908 #[test]
1911 fn homepage_https_accepted() {
1912 let json = r#"{
1913 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1914 "homepage": "https://example.com"
1915 }"#;
1916 parse_ok(json);
1917 }
1918
1919 #[test]
1920 fn homepage_javascript_scheme_rejected() {
1921 let json = r#"{
1922 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1923 "homepage": "javascript:alert(1)"
1924 }"#;
1925 let err = parse_err(json);
1926 assert!(
1927 err.contains("scheme"),
1928 "expected scheme error, got: {}",
1929 err
1930 );
1931 }
1932
1933 #[test]
1934 fn homepage_file_scheme_rejected() {
1935 let json = r#"{
1936 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1937 "homepage": "file:///etc/passwd"
1938 }"#;
1939 let err = parse_err(json);
1940 assert!(!err.is_empty());
1941 }
1942
1943 #[test]
1946 fn effective_entrypoint_native_default() {
1947 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1948 assert_eq!(m.effective_entrypoint(), "app.so");
1949 }
1950
1951 #[test]
1952 fn effective_entrypoint_bun_default() {
1953 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1954 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1955 }
1956
1957 #[test]
1958 fn effective_hot_reload_native_default_is_experimental() {
1959 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1960 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
1961 }
1962
1963 #[test]
1964 fn effective_hot_reload_bun_default_is_supported() {
1965 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1966 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
1967 }
1968
1969 #[test]
1970 fn hot_reload_unsupported_explicit() {
1971 let json = r#"{
1972 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
1973 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
1974 }"#;
1975 let m = parse_ok(json);
1976 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
1977 }
1978
1979 #[test]
1982 fn validate_path_simple_valid() {
1983 assert!(validate_manifest_path("dist/index.js").is_ok());
1984 assert!(validate_manifest_path("app.so").is_ok());
1985 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
1986 assert!(validate_manifest_path("build_output/main").is_ok());
1987 }
1988
1989 #[test]
1990 fn validate_path_empty_rejected() {
1991 assert!(validate_manifest_path("").is_err());
1992 }
1993
1994 #[test]
1995 fn validate_path_absolute_rejected() {
1996 assert!(validate_manifest_path("/usr/bin/sh").is_err());
1997 }
1998
1999 #[test]
2000 fn validate_path_double_dot_segment_rejected() {
2001 assert!(validate_manifest_path("foo/../bar").is_err());
2002 assert!(validate_manifest_path("../etc/passwd").is_err());
2003 }
2004
2005 #[test]
2006 fn validate_path_leading_dot_rejected() {
2007 assert!(validate_manifest_path(".hidden").is_err());
2008 }
2009
2010 #[test]
2011 fn validate_path_null_byte_rejected() {
2012 let path = "foo\0bar";
2014 assert!(validate_manifest_path(path).is_err());
2015 }
2016
2017 #[test]
2018 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2019 let path = std::path::Path::new("/tmp/node-app/example.sock");
2020 assert!(validate_standalone_socket_path(path).is_err());
2021 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2022 assert!(validate_standalone_socket_path_with_policy(
2023 std::path::Path::new("/tmp/node-app/../escape.sock"),
2024 true,
2025 )
2026 .is_err());
2027 }
2028
2029 #[test]
2032 fn manifest_capabilities_defaults_to_empty() {
2033 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2034 assert!(m.capabilities.requires.is_empty());
2035 assert!(m.capabilities.provides.is_empty());
2036 }
2037
2038 #[test]
2041 fn resolved_capability_provides_v1_only() {
2042 let json = r#"{
2043 "name": "example", "version": "1.0.0", "app_type": "bun",
2044 "provides": { "core.example.run": { "description": "Run example job" } }
2045 }"#;
2046 let m = parse_ok(json);
2047 let out = m.resolved_capability_provides();
2048 assert_eq!(out.len(), 1);
2049 assert_eq!(
2050 out.get("core.example.run").unwrap().description,
2051 "Run example job"
2052 );
2053 }
2054
2055 #[test]
2056 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2057 let json = r#"{
2058 "manifest_version": 2, "name": "example", "version": "1.0.0",
2059 "app_type": "bun", "abi": "v1",
2060 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2061 }"#;
2062 let m = parse_ok(json);
2063 let out = m.resolved_capability_provides();
2064 assert_eq!(out.len(), 2);
2065 assert_eq!(out.get("core.example.run").unwrap().description, "");
2066 assert!(out.get("core.example.run").unwrap().schema.is_none());
2067 assert!(out.contains_key("core.example.other"));
2069 assert!(!out.contains_key("core.example.other:extra"));
2070 }
2071
2072 #[test]
2073 fn resolved_capability_provides_v1_wins_on_conflict() {
2074 let json = r#"{
2075 "manifest_version": 2, "name": "example", "version": "1.0.0",
2076 "app_type": "bun", "abi": "v1",
2077 "provides": { "core.example.run": { "description": "v1 wins" } },
2078 "capabilities": { "requires": [], "provides": ["core.example.run"] }
2079 }"#;
2080 let m = parse_ok(json);
2081 let out = m.resolved_capability_provides();
2082 assert_eq!(out.len(), 1);
2083 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2084 }
2085
2086 #[test]
2087 fn resolved_capability_provides_blank_v2_name_skipped() {
2088 let json = r#"{
2089 "manifest_version": 2, "name": "example", "version": "1.0.0",
2090 "app_type": "bun", "abi": "v1",
2091 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
2092 }"#;
2093 let m = parse_ok(json);
2094 let out = m.resolved_capability_provides();
2095 assert_eq!(out.len(), 1);
2096 assert!(out.contains_key("core.example.run"));
2097 }
2098
2099 #[test]
2100 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2101 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2102 assert!(m.resolved_capability_provides().is_empty());
2103 }
2104
2105 #[test]
2108 fn abi_v1_is_supported() {
2109 assert!(AbiVersion::V1.is_supported());
2110 }
2111
2112 #[test]
2115 fn app_tier_display() {
2116 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2117 assert_eq!(AppTier::Optional.to_string(), "optional");
2118 assert_eq!(AppTier::Development.to_string(), "development");
2119 }
2120
2121 #[test]
2122 fn app_tier_serde_roundtrip() {
2123 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2125 let json = serde_json::to_string(&tier).unwrap();
2126 let back: AppTier = serde_json::from_str(&json).unwrap();
2127 assert_eq!(
2128 tier, back,
2129 "roundtrip failed for {:?}: serialized as {}",
2130 tier, json
2131 );
2132 }
2133 assert_eq!(
2134 serde_json::to_string(&AppTier::Development).unwrap(),
2135 "\"development\""
2136 );
2137 }
2138
2139 #[test]
2142 fn app_type_display() {
2143 assert_eq!(AppType::Native.to_string(), "native");
2144 assert_eq!(AppType::Bun.to_string(), "bun");
2145 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2146 }
2147
2148 #[test]
2149 fn platform_runtime_is_a_supported_packaging_type() {
2150 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2151 "manifest_version": 2,
2152 "abi": "v1",
2153 "name": "bun-runtime",
2154 "version": "1.0.0",
2155 "app_type": "platform-runtime",
2156 "entrypoint": "bun"
2157 }))
2158 .expect("platform runtime manifest should parse");
2159
2160 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2161 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2162 }
2163
2164 #[test]
2167 fn governor_manifest_memory_budget_defaults_to_none() {
2168 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2169 assert_eq!(parsed.memory_budget_kb, None);
2170 }
2171
2172 #[test]
2173 fn governor_manifest_parses_declared_memory_budget() {
2174 let parsed: GovernorManifest =
2175 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2176 assert_eq!(parsed.memory_budget_kb, Some(40_960));
2177 }
2178}
2179
2180#[cfg(test)]
2181mod shared_app_data_corpus_tests {
2182 use super::*;
2183
2184 #[test]
2189 fn shared_app_data_corpus_matches_the_host_validator() {
2190 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2191 let mut checked = 0;
2192 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2193 let path = entry.expect("readable entry").path();
2194 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2195 continue;
2196 }
2197 let fixture: serde_json::Value =
2198 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2199 .expect("valid fixture json");
2200 let name = path
2201 .file_name()
2202 .and_then(|n| n.to_str())
2203 .unwrap_or("?")
2204 .to_string();
2205 let manifest = serde_json::json!({
2206 "manifest_version": 2, "abi": "v1", "name": "fixture", "version": "0.1.0",
2207 "app_type": "bun", "entrypoint": "dist/index.js",
2208 "ui": fixture["ui"],
2209 });
2210 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2211 match fixture["expect"].as_str().expect("expect field") {
2212 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2213 "reject" => {
2214 let error = result.expect_err(&format!("{name} should be rejected"));
2215 let reason = fixture["reason"]
2216 .as_str()
2217 .expect("reject fixtures need a reason");
2218 assert!(
2219 error.contains(reason),
2220 "{name}: {error:?} should mention {reason:?}"
2221 );
2222 }
2223 other => panic!("{name}: unknown expect {other:?}"),
2224 }
2225 checked += 1;
2226 }
2227 assert!(checked >= 10, "expected the full corpus, walked {checked}");
2229 }
2230}