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, Copy, PartialEq, Eq, Serialize, Deserialize)]
700#[serde(rename_all = "lowercase")]
701pub enum LatencyClass {
702 Interactive,
705 Background,
708}
709
710impl LatencyClass {
711 pub fn as_str(&self) -> &'static str {
712 match self {
713 LatencyClass::Interactive => "interactive",
714 LatencyClass::Background => "background",
715 }
716 }
717
718 #[allow(clippy::should_implement_trait)]
719 pub fn from_str(s: &str) -> Result<Self, String> {
720 match s {
721 "interactive" => Ok(LatencyClass::Interactive),
722 "background" => Ok(LatencyClass::Background),
723 _ => Err(format!("Invalid LatencyClass: {}", s)),
724 }
725 }
726}
727
728impl std::fmt::Display for LatencyClass {
729 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
730 write!(f, "{}", self.as_str())
731 }
732}
733
734#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
737pub struct GovernorManifest {
738 #[serde(default)]
742 pub terminable: Option<bool>,
743
744 #[serde(default)]
748 pub min_idle_secs: Option<u64>,
749
750 #[serde(default)]
783 pub memory_budget_kb: Option<u64>,
784
785 #[serde(default)]
790 pub latency_class: Option<LatencyClass>,
791}
792
793#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
797pub struct TcpManifest {
798 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub preferred_port: Option<u16>,
803
804 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub direct_bind: Option<bool>,
812}
813
814fn default_manifest_version() -> u8 {
815 1
816}
817
818fn default_auto_start() -> bool {
819 true
820}
821
822fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
828where
829 D: serde::Deserializer<'de>,
830{
831 #[derive(Deserialize)]
832 #[serde(untagged)]
833 enum BoolOrStr {
834 Bool(bool),
835 Str(String),
836 }
837 Ok(match BoolOrStr::deserialize(deserializer)? {
838 BoolOrStr::Bool(b) => b,
839 BoolOrStr::Str(s) => matches!(
840 s.trim().to_ascii_lowercase().as_str(),
841 "true" | "eager" | "active" | "auto" | "on" | "1"
842 ),
843 })
844}
845
846fn default_ui_path() -> String {
847 "dist".to_string()
848}
849
850fn default_app_type_native() -> AppType {
851 AppType::Native
852}
853
854impl AppManifest {
855 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
859 let top = normalized_requirements(&self.requires)?;
860 let nested = normalized_requirements(&self.capabilities.requires)?;
861 if !top.is_empty()
862 && !nested.is_empty()
863 && top.iter().cloned().collect::<BTreeSet<_>>()
864 != nested.iter().cloned().collect::<BTreeSet<_>>()
865 {
866 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
867 }
868 Ok(if !top.is_empty() { top } else { nested })
869 }
870
871 pub fn effective_hot_reload(&self) -> HotReloadKind {
874 self.hot_reload
875 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
876 }
877
878 pub fn effective_entrypoint(&self) -> &str {
883 if let Some(ref ep) = self.entrypoint {
884 ep.as_str()
885 } else {
886 match self.app_type {
887 AppType::Native => "app.so",
888 AppType::Bun => "dist/index.js",
889 AppType::Standalone => "",
890 AppType::PlatformRuntime => "bun",
891 AppType::ManagedV1 => "llmc-generated-app",
892 }
893 }
894 }
895
896 pub fn has_capability_providers(&self) -> bool {
899 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
900 }
901
902 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
916 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
917 for raw in &self.capabilities.provides {
918 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
919 if name.is_empty() {
920 continue;
921 }
922 out.entry(name).or_insert(ProvidedCapability {
923 description: String::new(),
924 schema: None,
925 });
926 }
927 out
928 }
929
930 pub fn validate(&self) -> Result<(), String> {
935 self.validate_with_socket_path_policy(false)
936 }
937
938 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
943 if self.manifest_version == 2 && self.abi.is_none() {
945 return Err("manifest_version 2 requires an 'abi' field".to_string());
946 }
947
948 validate_app_name(&self.name)?;
951 self.resolved_requires()?;
952
953 if let Some(ref ep) = self.entrypoint {
955 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
956 }
957 if !self.ui_path.is_empty() && self.ui_path != "dist" {
959 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
960 }
961
962 if let Some(ui) = &self.ui {
963 validate_app_ui(&self.name, ui)?;
964 }
965
966 if let Some(ref hp) = self.homepage {
968 if !hp.starts_with("https://") && !hp.starts_with("http://") {
969 return Err(format!(
970 "homepage '{}' must use https:// or http:// scheme",
971 hp
972 ));
973 }
974 }
975
976 match self.app_type {
983 AppType::Standalone => {
984 if self.has_capability_providers() {
985 let cfg = self.standalone.as_ref().ok_or_else(|| {
986 "standalone apps that declare 'provides' require a \
987 'standalone.socket_path' field"
988 .to_string()
989 })?;
990 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
991 }
992 }
993 AppType::Native | AppType::Bun | AppType::PlatformRuntime | AppType::ManagedV1 => {
994 if self.standalone.is_some() {
995 return Err(format!(
996 "'standalone' block is only valid when app_type == 'standalone' \
997 (found app_type='{}')",
998 self.app_type
999 ));
1000 }
1001 }
1002 }
1003
1004 if self.app_type == AppType::PlatformRuntime
1005 && !self.resolved_capability_provides().is_empty()
1006 {
1007 return Err(
1008 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1009 );
1010 }
1011
1012 Ok(())
1013 }
1014
1015 pub fn from_json(json: &str) -> Result<Self, String> {
1017 Self::from_json_with_socket_path_policy(json, false)
1018 }
1019
1020 pub fn from_json_with_socket_path_policy(
1022 json: &str,
1023 allow_non_run: bool,
1024 ) -> Result<Self, String> {
1025 let mut manifest: Self =
1026 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1027 if manifest.ui.is_some() {
1028 manifest.has_ui = true;
1029 }
1030 manifest.validate_with_socket_path_policy(allow_non_run)?;
1031 Ok(manifest)
1032 }
1033}
1034
1035fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1036 let mut seen = HashSet::new();
1037 let mut resolved = Vec::new();
1038 for value in values {
1039 let requirement = value.trim();
1040 if requirement.is_empty() {
1041 return Err("capability requirements must not be blank".to_string());
1042 }
1043 if seen.insert(requirement.to_string()) {
1044 resolved.push(requirement.to_string());
1045 }
1046 }
1047 Ok(resolved)
1048}
1049
1050fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1051 if ui.ui_api != 1 && ui.ui_api != 2 {
1052 return Err(format!(
1053 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1054 ui.ui_api
1055 ));
1056 }
1057 if ui.title.trim().is_empty() {
1058 return Err("ui.title must not be blank".to_string());
1059 }
1060 ui.requires.resolved()?;
1061 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1062 if let Some(icon) = &ui.icon {
1063 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1064 }
1065 if let Some(nav) = &ui.nav {
1066 if ui.kind == AppUiKind::Widget {
1067 return Err("widget ui must omit nav metadata".to_string());
1068 }
1069 if nav.section.trim().is_empty() {
1070 return Err("ui.nav.section must not be blank".to_string());
1071 }
1072 }
1073 if let Some(data) = &ui.data {
1074 if ui.kind != AppUiKind::Stage {
1075 return Err("widget ui must omit app data declarations".to_string());
1076 }
1077 validate_app_data(data, &ui.requires.resolved()?)?;
1078 }
1079
1080 let mut composed = HashSet::new();
1081 for name in &ui.composes {
1082 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1083 if name == app_name {
1084 return Err("ui.composes must not contain the app itself".to_string());
1085 }
1086 if !composed.insert(name) {
1087 return Err(format!("ui.composes contains duplicate app '{name}'"));
1088 }
1089 }
1090
1091 let mut surface_ids = HashSet::new();
1092 for surface in &ui.surfaces {
1093 let id = surface.id.trim();
1094 if id.is_empty() {
1095 return Err("ui.surfaces entry id must not be blank".to_string());
1096 }
1097 if !surface_ids.insert(id.to_string()) {
1098 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1099 }
1100 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1101 return Err(format!(
1102 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1103 surface.slot,
1104 KNOWN_SURFACE_SLOTS.join(", ")
1105 ));
1106 }
1107 if surface.title.trim().is_empty() {
1108 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1109 }
1110 validate_manifest_path(&surface.entry)
1111 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1112 if !ui.integrity.contains_key(&surface.entry) {
1121 return Err(format!("ui.integrity must include surface '{id}' entry"));
1122 }
1123 surface
1124 .requires
1125 .resolved()
1126 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1127 }
1128
1129 for (path, digest) in &ui.integrity {
1130 validate_manifest_path(path)
1131 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1132 if !is_lowercase_sha256(digest) {
1133 return Err(format!(
1134 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1135 ));
1136 }
1137 }
1138 if !ui.integrity.contains_key(&ui.entry) {
1139 return Err("ui.integrity must include the declared entry".to_string());
1140 }
1141 if let Some(icon) = &ui.icon {
1142 if !ui.integrity.contains_key(icon) {
1143 return Err("ui.integrity must include the declared icon".to_string());
1144 }
1145 }
1146 Ok(())
1147}
1148
1149fn validate_app_data(data: &AppDataManifest, resolved_requires: &[String]) -> Result<(), String> {
1150 validate_app_data_namespace(&data.namespace)?;
1151 validate_app_data_sync_policy(&data.sync)?;
1152 if data.queries.is_empty() {
1153 return Err("ui.data.queries must declare at least one query".to_string());
1154 }
1155
1156 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1157 let mut names = BTreeSet::new();
1158 for query in &data.queries {
1159 validate_namespaced_data_name(&query.name, &data.namespace)
1160 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1161 validate_capability_name(&query.capability).map_err(|error| {
1162 format!(
1163 "ui.data query '{}' capability '{}' invalid: {error}",
1164 query.name, query.capability
1165 )
1166 })?;
1167 if !requires.contains(query.capability.as_str()) {
1168 return Err(format!(
1169 "ui.data query '{}' capability '{}' must be declared in requires",
1170 query.name, query.capability
1171 ));
1172 }
1173 if !names.insert(query.name.as_str()) {
1174 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1175 }
1176 }
1177
1178 for stream in &data.streams {
1179 validate_namespaced_data_name(&stream.name, &data.namespace)
1180 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1181 if !names.insert(stream.name.as_str()) {
1182 return Err(format!(
1183 "ui.data contains duplicate declaration '{}'",
1184 stream.name
1185 ));
1186 }
1187 }
1188
1189 Ok(())
1190}
1191
1192fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1193 if !is_safe_name_segment(namespace) {
1194 return Err(format!(
1195 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1196 namespace
1197 ));
1198 }
1199 if matches!(
1200 namespace,
1201 "core" | "internal" | "node" | "platform" | "system"
1202 ) {
1203 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1204 }
1205 Ok(())
1206}
1207
1208fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1209 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1210 validate_optional_range(
1211 "full_refresh_interval_secs",
1212 sync.full_refresh_interval_secs,
1213 60,
1214 604_800,
1215 )?;
1216 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1217 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1218 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1219 }
1220 Ok(())
1221}
1222
1223fn validate_optional_range(
1224 field: &str,
1225 value: Option<u32>,
1226 min: u32,
1227 max: u32,
1228) -> Result<(), String> {
1229 if let Some(value) = value {
1230 if value < min || value > max {
1231 return Err(format!(
1232 "ui.data.sync {field} must be between {min} and {max} seconds"
1233 ));
1234 }
1235 }
1236 Ok(())
1237}
1238
1239fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1240 validate_capability_name(name)?;
1241 let Some(rest) = name
1242 .strip_prefix(namespace)
1243 .and_then(|suffix| suffix.strip_prefix('.'))
1244 else {
1245 return Err(format!("name must use namespace '{namespace}'"));
1246 };
1247 if rest.is_empty() {
1248 return Err("name must include a value after its namespace".to_string());
1249 }
1250 if !has_version_suffix(name) {
1251 return Err("name must end with a .vN version suffix".to_string());
1252 }
1253 Ok(())
1254}
1255
1256fn validate_capability_name(name: &str) -> Result<(), String> {
1257 if name.is_empty() {
1258 return Err("name must not be empty".to_string());
1259 }
1260 if name.contains('/') || name.contains("..") {
1261 return Err("name must not contain path separators or traversal".to_string());
1262 }
1263 if !name.split('.').all(is_safe_declaration_segment) {
1264 return Err("name must contain only lowercase dot-separated segments".to_string());
1265 }
1266 Ok(())
1267}
1268
1269fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1270 if allow_wildcard && name.ends_with(".*") {
1271 return validate_capability_name(&name[..name.len() - 2]);
1272 }
1273 validate_capability_name(name)
1274}
1275
1276fn has_version_suffix(name: &str) -> bool {
1277 let Some(version) = name.rsplit('.').next() else {
1278 return false;
1279 };
1280 let Some(digits) = version.strip_prefix('v') else {
1281 return false;
1282 };
1283 !digits.is_empty()
1284 && !digits.starts_with('0')
1285 && digits.bytes().all(|byte| byte.is_ascii_digit())
1286}
1287
1288fn is_safe_name_segment(segment: &str) -> bool {
1289 if segment.is_empty() {
1290 return false;
1291 }
1292 let mut chars = segment.chars();
1293 let Some(first) = chars.next() else {
1294 return false;
1295 };
1296 first.is_ascii_lowercase()
1297 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1298}
1299
1300fn is_safe_declaration_segment(segment: &str) -> bool {
1313 let mut chars = segment.chars();
1314 let Some(first) = chars.next() else {
1315 return false;
1316 };
1317 first.is_ascii_lowercase()
1318 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1319}
1320
1321fn is_lowercase_sha256(value: &str) -> bool {
1322 value.len() == 64
1323 && value
1324 .bytes()
1325 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1326}
1327
1328pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1337 validate_standalone_socket_path_with_policy(path, false)
1338}
1339
1340pub fn validate_standalone_socket_path_with_policy(
1342 path: &std::path::Path,
1343 allow_non_run: bool,
1344) -> Result<(), String> {
1345 if !path.is_absolute() {
1346 return Err(format!(
1347 "standalone.socket_path '{}' must be absolute",
1348 path.display()
1349 ));
1350 }
1351 if !allow_non_run && !path.starts_with("/run/") {
1352 return Err(format!(
1353 "standalone.socket_path '{}' must live under /run/",
1354 path.display()
1355 ));
1356 }
1357 if path
1358 .components()
1359 .any(|c| matches!(c, std::path::Component::ParentDir))
1360 {
1361 return Err(format!(
1362 "standalone.socket_path '{}' must not contain '..' segments",
1363 path.display()
1364 ));
1365 }
1366 Ok(())
1367}
1368
1369fn validate_app_name(name: &str) -> Result<(), String> {
1373 let (publisher, app) = if let Some(slash) = name.find('/') {
1374 let (p, rest) = name.split_at(slash);
1375 (Some(p), &rest[1..])
1376 } else {
1377 (None, name)
1378 };
1379
1380 let valid_segment = |s: &str| -> bool {
1381 if s.is_empty() {
1382 return false;
1383 }
1384 let mut chars = s.chars();
1385 let first = chars.next().unwrap();
1386 if !first.is_ascii_lowercase() {
1387 return false;
1388 }
1389 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1390 };
1391
1392 if let Some(pub_name) = publisher {
1393 if !valid_segment(pub_name) {
1394 return Err(format!(
1395 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1396 pub_name
1397 ));
1398 }
1399 }
1400
1401 if !valid_segment(app) {
1402 return Err(format!(
1403 "app name segment '{}' must match [a-z][a-z0-9-]*",
1404 app
1405 ));
1406 }
1407
1408 Ok(())
1409}
1410
1411#[cfg(test)]
1414mod tests {
1415 use super::*;
1416
1417 fn parse_ok(json: &str) -> AppManifest {
1418 AppManifest::from_json(json).expect("should parse")
1419 }
1420
1421 fn parse_err(json: &str) -> String {
1422 AppManifest::from_json(json).expect_err("should fail")
1423 }
1424
1425 #[test]
1428 fn v1_minimal_native() {
1429 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1430 assert_eq!(m.manifest_version, 1);
1431 assert_eq!(m.app_type, AppType::Native);
1432 assert!(m.abi.is_none());
1433 }
1434
1435 #[test]
1436 fn v1_minimal_bun() {
1437 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1438 assert_eq!(m.app_type, AppType::Bun);
1439 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1440 }
1441
1442 #[test]
1443 fn v1_no_manifest_version_field_defaults_to_1() {
1444 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1445 assert_eq!(m.manifest_version, 1);
1446 }
1447
1448 #[test]
1449 fn v1_all_optional_fields_missing() {
1450 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1451 assert!(!m.critical);
1452 assert!(m.auto_start);
1453 assert!(!m.has_ui);
1454 assert_eq!(m.ui_path, "dist");
1455 assert!(m.permissions.is_empty());
1456 assert!(m.optional_permissions.is_empty());
1457 assert!(m.governor.is_none());
1460 assert!(m.subscribes.is_empty());
1461 }
1462
1463 #[test]
1464 fn v1_with_permissions_and_provides() {
1465 let json = r#"{
1466 "name": "example",
1467 "version": "1.0.0",
1468 "app_type": "bun",
1469 "permissions": ["core.storage.kv"],
1470 "optional_permissions": ["core.notifications.create"],
1471 "provides": {
1472 "core.example.run": { "description": "Run example job" }
1473 }
1474 }"#;
1475 let m = parse_ok(json);
1476 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1477 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1478 assert!(m.provides.contains_key("core.example.run"));
1479 }
1480
1481 #[test]
1484 fn v2_minimal_native() {
1485 let json = r#"{
1486 "manifest_version": 2,
1487 "name": "cron",
1488 "version": "1.0.0",
1489 "app_type": "native",
1490 "abi": "v1",
1491 "entrypoint": "app.so",
1492 "hot_reload": "experimental"
1493 }"#;
1494 let m = parse_ok(json);
1495 assert_eq!(m.manifest_version, 2);
1496 assert_eq!(m.abi, Some(AbiVersion::V1));
1497 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1498 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1499 }
1500
1501 #[test]
1502 fn v2_minimal_bun_with_capabilities() {
1503 let json = r#"{
1504 "manifest_version": 2,
1505 "name": "example-fullstack",
1506 "version": "1.0.0",
1507 "app_type": "bun",
1508 "abi": "v1",
1509 "entrypoint": "dist/index.js",
1510 "hot_reload": "supported",
1511 "has_ui": true,
1512 "ui_path": "ui/dist",
1513 "capabilities": {
1514 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1515 "provides": []
1516 },
1517 "governor": { "terminable": false, "min_idle_secs": 300 },
1518 "subscribes": ["core.chat.message.received"]
1519 }"#;
1520 let m = parse_ok(json);
1521 assert_eq!(m.manifest_version, 2);
1522 assert_eq!(m.capabilities.requires.len(), 2);
1523 let governor = m.governor.expect("governor block should parse");
1525 assert_eq!(governor.terminable, Some(false));
1526 assert_eq!(governor.min_idle_secs, Some(300));
1527 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1528 }
1529
1530 #[test]
1531 fn stage_contract_fixture_parses_with_normalized_requirements() {
1532 let json = include_str!(
1533 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1534 );
1535 let m = parse_ok(json);
1536 assert!(m.has_ui);
1537 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1538 let ui = m.ui.expect("fixture should declare ui");
1539 assert_eq!(ui.kind, AppUiKind::Stage);
1540 assert_eq!(ui.entry, "ui/main.js");
1541 assert_eq!(ui.nav.unwrap().order, 10);
1542 }
1543
1544 #[test]
1545 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1546 let m = parse_ok(
1547 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1548 );
1549 assert!(m.has_ui);
1550 assert_eq!(m.ui_path, "ui/dist");
1551 assert!(m.ui.is_none());
1552 }
1553
1554 #[test]
1555 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1556 let manifest = parse_ok(
1557 r#"{
1558 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1559 "requires":["core.cron.register"],
1560 "ui":{
1561 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1562 "requires":{
1563 "capabilities":["ui.snapshot.v1"],
1564 "queries":["ui.query.v1"],
1565 "streams":["ui.event.v1"]
1566 },
1567 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1568 }
1569 }"#,
1570 );
1571
1572 assert_eq!(
1573 manifest.resolved_requires().unwrap(),
1574 vec!["core.cron.register"]
1575 );
1576 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1577 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1578 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1579 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1580 assert_eq!(
1581 ui.requires.resolved().unwrap(),
1582 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1583 );
1584 let serialized = serde_json::to_value(ui).unwrap();
1585 assert_eq!(
1586 serialized["requires"]["queries"],
1587 serde_json::json!(["ui.query.v1"])
1588 );
1589 }
1590
1591 #[test]
1592 fn ui_requirements_reject_blank_entries() {
1593 let error = parse_err(
1594 r#"{
1595 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1596 "ui":{
1597 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1598 "requires":{"capabilities":[""],"queries":[],"streams":[]},
1599 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1600 }
1601 }"#,
1602 );
1603 assert!(error.contains("ui.requires entries must not be blank"));
1604 }
1605
1606 #[test]
1607 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1608 assert!(validate_app_data_namespace("obs-viewer").is_ok());
1615
1616 let error = validate_app_data_namespace("obs_viewer")
1617 .expect_err("underscore must not be admitted into a storage namespace");
1618 assert!(
1619 error.contains("must match [a-z][a-z0-9-]*"),
1620 "unexpected error: {error}"
1621 );
1622 }
1623
1624 #[test]
1625 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1626 let error = parse_err(
1627 r#"{
1628 "name":"ui-data","version":"1.0.0","app_type":"bun",
1629 "requires":["ui.snapshot.v1"],
1630 "ui":{
1631 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1632 "requires":{"capabilities":[],"queries":[],"streams":[]},
1633 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1634 "data":{
1635 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1636 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1637 "streams":[]
1638 }
1639 }
1640 }"#,
1641 );
1642 assert!(error.contains("must be declared in requires"));
1643 }
1644
1645 #[test]
1646 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1647 let accepted = parse_ok(
1648 r#"{
1649 "name":"aliases","version":"1.0.0","app_type":"bun",
1650 "requires":["core.chat.read","core.chat.read","core.chat.send"],
1651 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1652 }"#,
1653 );
1654 assert_eq!(
1655 accepted.resolved_requires().unwrap(),
1656 vec!["core.chat.read", "core.chat.send"]
1657 );
1658
1659 let err = parse_err(
1660 r#"{
1661 "name":"aliases","version":"1.0.0","app_type":"bun",
1662 "requires":["core.chat.read"],
1663 "capabilities":{"requires":["core.wallet.pay"]}
1664 }"#,
1665 );
1666 assert!(err.contains("conflicts"), "unexpected error: {err}");
1667 }
1668
1669 #[test]
1670 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1671 let base = |ui: &str| {
1672 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1673 };
1674 let widget = base(
1675 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1676 );
1677 assert_eq!(
1678 parse_ok(&widget).ui.expect("widget ui").kind,
1679 AppUiKind::Widget
1680 );
1681 let widget_nav = base(
1682 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1683 );
1684 assert!(parse_err(&widget_nav).contains("must omit nav"));
1685 let api = base(
1686 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1687 );
1688 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1689 let unsupported_api = base(
1690 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1691 );
1692 assert!(parse_err(&unsupported_api).contains("ui_api"));
1693 let path = base(
1694 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1695 );
1696 assert!(parse_err(&path).contains("entry"));
1697 }
1698
1699 #[test]
1700 fn stage_requires_integrity_for_entry_and_icon() {
1701 let missing_entry = r#"{
1702 "name":"stage","version":"1.0.0","app_type":"bun",
1703 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1704 }"#;
1705 assert!(parse_err(missing_entry).contains("entry"));
1706 let missing_icon = r#"{
1707 "name":"stage","version":"1.0.0","app_type":"bun",
1708 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1709 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1710 }"#;
1711 assert!(parse_err(missing_icon).contains("icon"));
1712 let uppercase = r#"{
1713 "name":"stage","version":"1.0.0","app_type":"bun",
1714 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1715 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1716 }"#;
1717 assert!(parse_err(uppercase).contains("lowercase"));
1718 }
1719
1720 #[test]
1721 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1722 let manifest = |composes: &str| {
1723 format!(
1724 r#"{{
1725 "name":"stage","version":"1.0.0","app_type":"bun",
1726 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1727 "composes":{composes},
1728 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1729 }}"#
1730 )
1731 };
1732 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1733 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1734 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1735 }
1736
1737 fn manifest_with_ui() -> AppManifest {
1749 parse_ok(
1750 r#"{
1751 "name":"stage","version":"1.0.0","app_type":"bun",
1752 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1753 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1754 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
1755 }"#,
1756 )
1757 }
1758
1759 fn surface(id: &str, slot: &str) -> AppUiSurface {
1760 AppUiSurface {
1761 id: id.to_string(),
1762 slot: slot.to_string(),
1763 entry: "ui/dist/surfaces/chip.js".to_string(),
1764 title: "Chip".to_string(),
1765 order: 10,
1766 requires: AppUiRequirements {
1767 capabilities: vec!["wallet.balance.get".to_string()],
1768 ..Default::default()
1769 },
1770 }
1771 }
1772
1773 #[test]
1774 fn manifest_without_surfaces_still_parses() {
1775 let manifest = manifest_with_ui();
1776 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
1777 assert!(manifest.validate().is_ok());
1778 }
1779
1780 #[test]
1781 fn surface_in_a_known_slot_is_accepted() {
1782 let mut manifest = manifest_with_ui();
1783 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
1784 assert!(manifest.validate().is_ok());
1785 }
1786
1787 #[test]
1788 fn surface_in_an_unknown_slot_is_rejected() {
1789 let mut manifest = manifest_with_ui();
1790 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
1791 let error = manifest.validate().unwrap_err();
1792 assert!(error.contains("menu-bar"), "unexpected error: {error}");
1793 }
1794
1795 #[test]
1796 fn duplicate_surface_ids_are_rejected() {
1797 let mut manifest = manifest_with_ui();
1798 manifest.ui.as_mut().unwrap().surfaces = vec![
1799 surface("chip", "status-rail"),
1800 surface("chip", "status-rail"),
1801 ];
1802 let error = manifest.validate().unwrap_err();
1803 assert!(error.contains("duplicate"), "unexpected error: {error}");
1804 }
1805
1806 #[test]
1807 fn surface_with_a_blank_id_is_rejected() {
1808 let mut manifest = manifest_with_ui();
1809 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
1810 assert!(manifest.validate().is_err());
1811 }
1812
1813 #[test]
1814 fn surface_with_an_unsafe_entry_path_is_rejected() {
1815 let mut manifest = manifest_with_ui();
1816 let mut bad = surface("chip", "status-rail");
1817 bad.entry = "../../etc/passwd".to_string();
1818 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1819 assert!(manifest.validate().is_err());
1820 }
1821
1822 #[test]
1823 fn surface_entry_missing_from_integrity_is_rejected() {
1824 let mut manifest = manifest_with_ui();
1828 let mut unpinned = surface("chip", "status-rail");
1829 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
1830 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
1831 let error = manifest.validate().unwrap_err();
1832 assert!(
1833 error.contains("ui.integrity must include surface 'chip' entry"),
1834 "unexpected error: {error}"
1835 );
1836 }
1837
1838 #[test]
1839 fn surface_with_a_blank_required_capability_is_rejected() {
1840 let mut manifest = manifest_with_ui();
1841 let mut bad = surface("chip", "status-rail");
1842 bad.requires.capabilities = vec![" ".to_string()];
1843 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1844 assert!(manifest.validate().is_err());
1845 }
1846
1847 #[test]
1848 fn v2_missing_abi_is_error() {
1849 let json = r#"{
1850 "manifest_version": 2,
1851 "name": "example",
1852 "version": "1.0.0",
1853 "app_type": "bun"
1854 }"#;
1855 let err = parse_err(json);
1856 assert!(err.contains("abi"), "expected abi error, got: {}", err);
1857 }
1858
1859 #[test]
1862 fn publisher_prefixed_name_accepted() {
1863 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1864 assert_eq!(m.name, "alice/weather");
1865 }
1866
1867 #[test]
1868 fn double_slash_name_rejected() {
1869 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1870 assert!(!err.is_empty());
1871 }
1872
1873 #[test]
1876 fn name_starting_with_digit_rejected() {
1877 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1878 assert!(!err.is_empty());
1879 }
1880
1881 #[test]
1882 fn name_with_uppercase_rejected() {
1883 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1884 assert!(!err.is_empty());
1885 }
1886
1887 #[test]
1888 fn empty_name_rejected() {
1889 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1890 assert!(!err.is_empty());
1891 }
1892
1893 #[test]
1896 fn path_traversal_double_dot_rejected() {
1897 let json = r#"{
1898 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1899 "app_type": "bun", "abi": "v1",
1900 "entrypoint": "../etc/passwd"
1901 }"#;
1902 let err = parse_err(json);
1903 assert!(err.contains(".."), "expected traversal error, got: {}", err);
1904 }
1905
1906 #[test]
1907 fn path_traversal_encoded_dot_not_decoded() {
1908 let json = r#"{
1910 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1911 "app_type": "bun", "abi": "v1",
1912 "entrypoint": "foo/../bar"
1913 }"#;
1914 let err = parse_err(json);
1915 assert!(!err.is_empty(), "should have failed: {}", err);
1916 }
1917
1918 #[test]
1919 fn absolute_path_rejected() {
1920 let json = r#"{
1921 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1922 "app_type": "bun", "abi": "v1",
1923 "entrypoint": "/usr/bin/sh"
1924 }"#;
1925 let err = parse_err(json);
1926 assert!(
1927 err.contains("absolute"),
1928 "expected absolute error, got: {}",
1929 err
1930 );
1931 }
1932
1933 #[test]
1934 fn shell_metachar_in_path_rejected() {
1935 let json = r#"{
1936 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1937 "app_type": "bun", "abi": "v1",
1938 "entrypoint": "dist/index.js;rm -rf /"
1939 }"#;
1940 let err = parse_err(json);
1941 assert!(!err.is_empty());
1942 }
1943
1944 #[test]
1945 fn valid_nested_path_accepted() {
1946 let json = r#"{
1947 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1948 "app_type": "bun", "abi": "v1",
1949 "entrypoint": "dist/index.js",
1950 "ui_path": "ui/dist"
1951 }"#;
1952 parse_ok(json);
1953 }
1954
1955 #[test]
1958 fn homepage_https_accepted() {
1959 let json = r#"{
1960 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1961 "homepage": "https://example.com"
1962 }"#;
1963 parse_ok(json);
1964 }
1965
1966 #[test]
1967 fn homepage_javascript_scheme_rejected() {
1968 let json = r#"{
1969 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1970 "homepage": "javascript:alert(1)"
1971 }"#;
1972 let err = parse_err(json);
1973 assert!(
1974 err.contains("scheme"),
1975 "expected scheme error, got: {}",
1976 err
1977 );
1978 }
1979
1980 #[test]
1981 fn homepage_file_scheme_rejected() {
1982 let json = r#"{
1983 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1984 "homepage": "file:///etc/passwd"
1985 }"#;
1986 let err = parse_err(json);
1987 assert!(!err.is_empty());
1988 }
1989
1990 #[test]
1993 fn effective_entrypoint_native_default() {
1994 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1995 assert_eq!(m.effective_entrypoint(), "app.so");
1996 }
1997
1998 #[test]
1999 fn effective_entrypoint_bun_default() {
2000 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2001 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2002 }
2003
2004 #[test]
2005 fn effective_hot_reload_native_default_is_experimental() {
2006 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2007 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2008 }
2009
2010 #[test]
2011 fn effective_hot_reload_bun_default_is_supported() {
2012 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2013 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2014 }
2015
2016 #[test]
2017 fn hot_reload_unsupported_explicit() {
2018 let json = r#"{
2019 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2020 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2021 }"#;
2022 let m = parse_ok(json);
2023 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2024 }
2025
2026 #[test]
2029 fn validate_path_simple_valid() {
2030 assert!(validate_manifest_path("dist/index.js").is_ok());
2031 assert!(validate_manifest_path("app.so").is_ok());
2032 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2033 assert!(validate_manifest_path("build_output/main").is_ok());
2034 }
2035
2036 #[test]
2037 fn validate_path_empty_rejected() {
2038 assert!(validate_manifest_path("").is_err());
2039 }
2040
2041 #[test]
2042 fn validate_path_absolute_rejected() {
2043 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2044 }
2045
2046 #[test]
2047 fn validate_path_double_dot_segment_rejected() {
2048 assert!(validate_manifest_path("foo/../bar").is_err());
2049 assert!(validate_manifest_path("../etc/passwd").is_err());
2050 }
2051
2052 #[test]
2053 fn validate_path_leading_dot_rejected() {
2054 assert!(validate_manifest_path(".hidden").is_err());
2055 }
2056
2057 #[test]
2058 fn validate_path_null_byte_rejected() {
2059 let path = "foo\0bar";
2061 assert!(validate_manifest_path(path).is_err());
2062 }
2063
2064 #[test]
2065 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2066 let path = std::path::Path::new("/tmp/node-app/example.sock");
2067 assert!(validate_standalone_socket_path(path).is_err());
2068 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2069 assert!(validate_standalone_socket_path_with_policy(
2070 std::path::Path::new("/tmp/node-app/../escape.sock"),
2071 true,
2072 )
2073 .is_err());
2074 }
2075
2076 #[test]
2079 fn manifest_capabilities_defaults_to_empty() {
2080 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2081 assert!(m.capabilities.requires.is_empty());
2082 assert!(m.capabilities.provides.is_empty());
2083 }
2084
2085 #[test]
2088 fn resolved_capability_provides_v1_only() {
2089 let json = r#"{
2090 "name": "example", "version": "1.0.0", "app_type": "bun",
2091 "provides": { "core.example.run": { "description": "Run example job" } }
2092 }"#;
2093 let m = parse_ok(json);
2094 let out = m.resolved_capability_provides();
2095 assert_eq!(out.len(), 1);
2096 assert_eq!(
2097 out.get("core.example.run").unwrap().description,
2098 "Run example job"
2099 );
2100 }
2101
2102 #[test]
2103 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2104 let json = r#"{
2105 "manifest_version": 2, "name": "example", "version": "1.0.0",
2106 "app_type": "bun", "abi": "v1",
2107 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2108 }"#;
2109 let m = parse_ok(json);
2110 let out = m.resolved_capability_provides();
2111 assert_eq!(out.len(), 2);
2112 assert_eq!(out.get("core.example.run").unwrap().description, "");
2113 assert!(out.get("core.example.run").unwrap().schema.is_none());
2114 assert!(out.contains_key("core.example.other"));
2116 assert!(!out.contains_key("core.example.other:extra"));
2117 }
2118
2119 #[test]
2120 fn resolved_capability_provides_v1_wins_on_conflict() {
2121 let json = r#"{
2122 "manifest_version": 2, "name": "example", "version": "1.0.0",
2123 "app_type": "bun", "abi": "v1",
2124 "provides": { "core.example.run": { "description": "v1 wins" } },
2125 "capabilities": { "requires": [], "provides": ["core.example.run"] }
2126 }"#;
2127 let m = parse_ok(json);
2128 let out = m.resolved_capability_provides();
2129 assert_eq!(out.len(), 1);
2130 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2131 }
2132
2133 #[test]
2134 fn resolved_capability_provides_blank_v2_name_skipped() {
2135 let json = r#"{
2136 "manifest_version": 2, "name": "example", "version": "1.0.0",
2137 "app_type": "bun", "abi": "v1",
2138 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
2139 }"#;
2140 let m = parse_ok(json);
2141 let out = m.resolved_capability_provides();
2142 assert_eq!(out.len(), 1);
2143 assert!(out.contains_key("core.example.run"));
2144 }
2145
2146 #[test]
2147 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2148 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2149 assert!(m.resolved_capability_provides().is_empty());
2150 }
2151
2152 #[test]
2155 fn abi_v1_is_supported() {
2156 assert!(AbiVersion::V1.is_supported());
2157 }
2158
2159 #[test]
2162 fn app_tier_display() {
2163 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2164 assert_eq!(AppTier::Optional.to_string(), "optional");
2165 assert_eq!(AppTier::Development.to_string(), "development");
2166 }
2167
2168 #[test]
2169 fn app_tier_serde_roundtrip() {
2170 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2172 let json = serde_json::to_string(&tier).unwrap();
2173 let back: AppTier = serde_json::from_str(&json).unwrap();
2174 assert_eq!(
2175 tier, back,
2176 "roundtrip failed for {:?}: serialized as {}",
2177 tier, json
2178 );
2179 }
2180 assert_eq!(
2181 serde_json::to_string(&AppTier::Development).unwrap(),
2182 "\"development\""
2183 );
2184 }
2185
2186 #[test]
2189 fn app_type_display() {
2190 assert_eq!(AppType::Native.to_string(), "native");
2191 assert_eq!(AppType::Bun.to_string(), "bun");
2192 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2193 }
2194
2195 #[test]
2196 fn platform_runtime_is_a_supported_packaging_type() {
2197 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2198 "manifest_version": 2,
2199 "abi": "v1",
2200 "name": "bun-runtime",
2201 "version": "1.0.0",
2202 "app_type": "platform-runtime",
2203 "entrypoint": "bun"
2204 }))
2205 .expect("platform runtime manifest should parse");
2206
2207 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2208 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2209 }
2210
2211 #[test]
2214 fn governor_manifest_memory_budget_defaults_to_none() {
2215 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2216 assert_eq!(parsed.memory_budget_kb, None);
2217 }
2218
2219 #[test]
2220 fn governor_manifest_parses_declared_memory_budget() {
2221 let parsed: GovernorManifest =
2222 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2223 assert_eq!(parsed.memory_budget_kb, Some(40_960));
2224 }
2225
2226 #[test]
2229 fn latency_class_parses_and_defaults_none() {
2230 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2231 assert_eq!(parsed.latency_class, None);
2232 }
2233
2234 #[test]
2235 fn latency_class_parses_declared_interactive() {
2236 let json = r#"{
2237 "name": "example",
2238 "version": "1.0.0",
2239 "app_type": "bun",
2240 "governor": {"latency_class": "interactive"}
2241 }"#;
2242 let m = parse_ok(json);
2243 let governor = m.governor.expect("governor block should parse");
2244 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2245 }
2246
2247 #[test]
2248 fn latency_class_parses_declared_background() {
2249 let parsed: GovernorManifest =
2250 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2251 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2252 }
2253
2254 #[test]
2255 fn latency_class_invalid_value_is_parse_error() {
2256 let result: Result<GovernorManifest, _> =
2257 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2258 assert!(result.is_err(), "unknown latency_class value must fail to parse");
2259 }
2260
2261 #[test]
2262 fn latency_class_as_str_and_from_str_roundtrip() {
2263 for class in [LatencyClass::Interactive, LatencyClass::Background] {
2264 let s = class.as_str();
2265 assert_eq!(LatencyClass::from_str(s), Ok(class));
2266 }
2267 assert!(LatencyClass::from_str("urgent").is_err());
2268 }
2269}
2270
2271#[cfg(test)]
2272mod shared_app_data_corpus_tests {
2273 use super::*;
2274
2275 #[test]
2280 fn shared_app_data_corpus_matches_the_host_validator() {
2281 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2282 let mut checked = 0;
2283 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2284 let path = entry.expect("readable entry").path();
2285 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2286 continue;
2287 }
2288 let fixture: serde_json::Value =
2289 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2290 .expect("valid fixture json");
2291 let name = path
2292 .file_name()
2293 .and_then(|n| n.to_str())
2294 .unwrap_or("?")
2295 .to_string();
2296 let manifest = serde_json::json!({
2297 "manifest_version": 2, "abi": "v1", "name": "fixture", "version": "0.1.0",
2298 "app_type": "bun", "entrypoint": "dist/index.js",
2299 "ui": fixture["ui"],
2300 });
2301 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2302 match fixture["expect"].as_str().expect("expect field") {
2303 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2304 "reject" => {
2305 let error = result.expect_err(&format!("{name} should be rejected"));
2306 let reason = fixture["reason"]
2307 .as_str()
2308 .expect("reject fixtures need a reason");
2309 assert!(
2310 error.contains(reason),
2311 "{name}: {error:?} should mention {reason:?}"
2312 );
2313 }
2314 other => panic!("{name}: unknown expect {other:?}"),
2315 }
2316 checked += 1;
2317 }
2318 assert!(checked >= 10, "expected the full corpus, walked {checked}");
2320 }
2321}