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
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "kebab-case")]
265pub enum AppDataOfflinePolicy {
266 LastKnown,
268 OnlineOnly,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(rename_all = "kebab-case")]
275pub enum AppDataQueryKind {
276 Collection,
277 Detail,
278 Snapshot,
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "kebab-case")]
284pub enum AppDataStreamKind {
285 Changes,
286 Events,
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "kebab-case")]
292pub enum AppDataSyncKind {
293 Cursor,
294 Snapshot,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct AppDataSyncPolicy {
300 pub kind: AppDataSyncKind,
301 pub cursor_ttl_secs: Option<u32>,
302 pub full_refresh_interval_secs: Option<u32>,
303 pub retention_secs: Option<u32>,
304}
305
306impl Serialize for AppDataSyncPolicy {
307 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
308 where
309 S: serde::Serializer,
310 {
311 use serde::ser::SerializeStruct;
312
313 if self.cursor_ttl_secs.is_none()
314 && self.full_refresh_interval_secs.is_none()
315 && self.retention_secs.is_none()
316 {
317 return self.kind.serialize(serializer);
318 }
319
320 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
321 state.serialize_field("kind", &self.kind)?;
322 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
323 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
324 }
325 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
326 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
327 }
328 if let Some(retention_secs) = self.retention_secs {
329 state.serialize_field("retention_secs", &retention_secs)?;
330 }
331 state.end()
332 }
333}
334
335impl<'de> Deserialize<'de> for AppDataSyncPolicy {
336 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
337 where
338 D: serde::Deserializer<'de>,
339 {
340 #[derive(Deserialize)]
341 #[serde(deny_unknown_fields)]
342 struct ObjectPolicy {
343 kind: AppDataSyncKind,
344 #[serde(default)]
345 cursor_ttl_secs: Option<u32>,
346 #[serde(default)]
347 full_refresh_interval_secs: Option<u32>,
348 #[serde(default)]
349 retention_secs: Option<u32>,
350 }
351
352 #[derive(Deserialize)]
353 #[serde(untagged)]
354 enum WirePolicy {
355 Kind(AppDataSyncKind),
356 Object(ObjectPolicy),
357 }
358
359 match WirePolicy::deserialize(deserializer)? {
360 WirePolicy::Kind(kind) => Ok(Self {
361 kind,
362 cursor_ttl_secs: None,
363 full_refresh_interval_secs: None,
364 retention_secs: None,
365 }),
366 WirePolicy::Object(policy) => Ok(Self {
367 kind: policy.kind,
368 cursor_ttl_secs: policy.cursor_ttl_secs,
369 full_refresh_interval_secs: policy.full_refresh_interval_secs,
370 retention_secs: policy.retention_secs,
371 }),
372 }
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub struct AppDataQueryDeclaration {
380 pub name: String,
381 pub capability: String,
382 pub kind: AppDataQueryKind,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(deny_unknown_fields)]
388pub struct AppDataStreamDeclaration {
389 pub name: String,
390 pub kind: AppDataStreamKind,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(deny_unknown_fields)]
396pub struct AppDataManifest {
397 pub namespace: String,
398 pub offline: AppDataOfflinePolicy,
399 pub sync: AppDataSyncPolicy,
400 #[serde(default)]
401 pub queries: Vec<AppDataQueryDeclaration>,
402 #[serde(default)]
403 pub streams: Vec<AppDataStreamDeclaration>,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
411#[serde(deny_unknown_fields)]
412pub struct AppUiRequirements {
413 #[serde(default)]
414 pub capabilities: Vec<String>,
415 #[serde(default)]
416 pub queries: Vec<String>,
417 #[serde(default)]
418 pub streams: Vec<String>,
419}
420
421impl AppUiRequirements {
422 pub fn resolved(&self) -> Result<Vec<String>, String> {
426 let mut resolved = Vec::new();
427 let mut seen = HashSet::new();
428 for (values, allow_wildcard) in [
429 (&self.capabilities, true),
430 (&self.queries, false),
431 (&self.streams, false),
432 ] {
433 for value in values {
434 let requirement = value.trim();
435 if requirement.is_empty() {
436 return Err("ui.requires entries must not be blank".to_string());
437 }
438 validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
439 format!("ui.requires entry '{requirement}' invalid: {error}")
440 })?;
441 if seen.insert(requirement.to_string()) {
442 resolved.push(requirement.to_string());
443 }
444 }
445 }
446 Ok(resolved)
447 }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452pub struct AppUiManifest {
453 #[serde(default = "default_app_ui_kind")]
454 pub kind: AppUiKind,
455 pub entry: String,
456 pub title: String,
457 #[serde(default)]
458 pub icon: Option<String>,
459 #[serde(default)]
460 pub nav: Option<AppUiNav>,
461 #[serde(default)]
462 pub composes: Vec<String>,
463 pub ui_api: u8,
464 #[serde(default)]
465 pub integrity: BTreeMap<String, String>,
466 #[serde(default)]
470 pub description: Option<String>,
471 #[serde(default)]
474 pub keywords: Vec<String>,
475 #[serde(default)]
478 pub requires: AppUiRequirements,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub data: Option<AppDataManifest>,
481}
482
483pub fn validate_manifest_path(path: &str) -> Result<(), String> {
495 if path.is_empty() {
496 return Err("path must not be empty".to_string());
497 }
498
499 if path.starts_with('/') {
501 return Err(format!(
502 "path '{}' must not be absolute (starts with /)",
503 path
504 ));
505 }
506
507 let first = path.chars().next().unwrap();
510 if !first.is_ascii_alphanumeric() && first != '_' {
511 return Err(format!(
512 "path '{}' must begin with an alphanumeric character or underscore",
513 path
514 ));
515 }
516 for ch in path.chars().skip(1) {
517 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
518 return Err(format!(
519 "path '{}' contains disallowed character '{}'",
520 path, ch
521 ));
522 }
523 }
524
525 for segment in path.split('/') {
527 if segment == ".." {
528 return Err(format!(
529 "path '{}' contains a '..' segment (path traversal rejected)",
530 path
531 ));
532 }
533 }
534
535 Ok(())
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct AppManifest {
547 #[serde(default = "default_manifest_version", rename = "manifest_version")]
549 pub manifest_version: u8,
550
551 pub name: String,
552 pub version: String,
553
554 #[serde(default = "default_app_type_native")]
555 pub app_type: AppType,
556
557 #[serde(default)]
558 pub description: String,
559
560 pub abi: Option<AbiVersion>,
563
564 pub entrypoint: Option<String>,
567
568 pub hot_reload: Option<HotReloadKind>,
571
572 #[serde(default)]
574 pub critical: bool,
575
576 #[serde(
577 default = "default_auto_start",
578 deserialize_with = "deserialize_auto_start"
579 )]
580 pub auto_start: bool,
581
582 #[serde(default)]
583 pub has_ui: bool,
584
585 #[serde(default = "default_ui_path")]
586 pub ui_path: String,
587
588 #[serde(default)]
589 pub permissions: Vec<String>,
590
591 #[serde(default)]
594 pub requires: Vec<String>,
595
596 #[serde(default)]
597 pub optional_permissions: Vec<String>,
598
599 #[serde(default)]
600 pub provides_scopes: Vec<ProvidedScope>,
601
602 #[serde(default)]
603 pub endpoint_policies: Vec<EndpointPolicy>,
604
605 #[serde(default)]
606 pub capability_scopes: HashMap<String, String>,
607
608 #[serde(default)]
609 pub provides: HashMap<String, ProvidedCapability>,
610
611 #[serde(default)]
613 pub capabilities: ManifestCapabilities,
614
615 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub ui: Option<AppUiManifest>,
619
620 #[serde(default)]
622 pub author: Option<String>,
623
624 #[serde(default)]
625 pub homepage: Option<String>,
626
627 #[serde(default)]
628 pub depends_on: Option<Vec<String>>,
629
630 #[serde(default)]
631 pub boot_priority: Option<u32>,
632
633 #[serde(default)]
637 pub governor: Option<GovernorManifest>,
638
639 #[serde(default)]
645 pub subscribes: Vec<String>,
646
647 #[serde(default)]
651 pub standalone: Option<StandaloneConfig>,
652
653 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub tcp: Option<TcpManifest>,
657}
658
659#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
662pub struct GovernorManifest {
663 #[serde(default)]
667 pub terminable: Option<bool>,
668
669 #[serde(default)]
673 pub min_idle_secs: Option<u64>,
674
675 #[serde(default)]
708 pub memory_budget_kb: Option<u64>,
709}
710
711#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
715pub struct TcpManifest {
716 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub preferred_port: Option<u16>,
721
722 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub direct_bind: Option<bool>,
730}
731
732fn default_manifest_version() -> u8 {
733 1
734}
735
736fn default_auto_start() -> bool {
737 true
738}
739
740fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
746where
747 D: serde::Deserializer<'de>,
748{
749 #[derive(Deserialize)]
750 #[serde(untagged)]
751 enum BoolOrStr {
752 Bool(bool),
753 Str(String),
754 }
755 Ok(match BoolOrStr::deserialize(deserializer)? {
756 BoolOrStr::Bool(b) => b,
757 BoolOrStr::Str(s) => matches!(
758 s.trim().to_ascii_lowercase().as_str(),
759 "true" | "eager" | "active" | "auto" | "on" | "1"
760 ),
761 })
762}
763
764fn default_ui_path() -> String {
765 "dist".to_string()
766}
767
768fn default_app_type_native() -> AppType {
769 AppType::Native
770}
771
772impl AppManifest {
773 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
777 let top = normalized_requirements(&self.requires)?;
778 let nested = normalized_requirements(&self.capabilities.requires)?;
779 if !top.is_empty()
780 && !nested.is_empty()
781 && top.iter().cloned().collect::<BTreeSet<_>>()
782 != nested.iter().cloned().collect::<BTreeSet<_>>()
783 {
784 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
785 }
786 Ok(if !top.is_empty() { top } else { nested })
787 }
788
789 pub fn effective_hot_reload(&self) -> HotReloadKind {
792 self.hot_reload
793 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
794 }
795
796 pub fn effective_entrypoint(&self) -> &str {
801 if let Some(ref ep) = self.entrypoint {
802 ep.as_str()
803 } else {
804 match self.app_type {
805 AppType::Native => "app.so",
806 AppType::Bun => "dist/index.js",
807 AppType::Standalone => "",
808 AppType::PlatformRuntime => "bun",
809 AppType::ManagedV1 => "llmc-generated-app",
810 }
811 }
812 }
813
814 pub fn has_capability_providers(&self) -> bool {
817 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
818 }
819
820 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
834 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
835 for raw in &self.capabilities.provides {
836 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
837 if name.is_empty() {
838 continue;
839 }
840 out.entry(name).or_insert(ProvidedCapability {
841 description: String::new(),
842 schema: None,
843 });
844 }
845 out
846 }
847
848 pub fn validate(&self) -> Result<(), String> {
853 self.validate_with_socket_path_policy(false)
854 }
855
856 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
861 if self.manifest_version == 2 && self.abi.is_none() {
863 return Err("manifest_version 2 requires an 'abi' field".to_string());
864 }
865
866 validate_app_name(&self.name)?;
869 self.resolved_requires()?;
870
871 if let Some(ref ep) = self.entrypoint {
873 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
874 }
875 if !self.ui_path.is_empty() && self.ui_path != "dist" {
877 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
878 }
879
880 if let Some(ui) = &self.ui {
881 validate_app_ui(&self.name, ui)?;
882 }
883
884 if let Some(ref hp) = self.homepage {
886 if !hp.starts_with("https://") && !hp.starts_with("http://") {
887 return Err(format!(
888 "homepage '{}' must use https:// or http:// scheme",
889 hp
890 ));
891 }
892 }
893
894 match self.app_type {
901 AppType::Standalone => {
902 if self.has_capability_providers() {
903 let cfg = self.standalone.as_ref().ok_or_else(|| {
904 "standalone apps that declare 'provides' require a \
905 'standalone.socket_path' field"
906 .to_string()
907 })?;
908 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
909 }
910 }
911 AppType::Native | AppType::Bun | AppType::PlatformRuntime | AppType::ManagedV1 => {
912 if self.standalone.is_some() {
913 return Err(format!(
914 "'standalone' block is only valid when app_type == 'standalone' \
915 (found app_type='{}')",
916 self.app_type
917 ));
918 }
919 }
920 }
921
922 if self.app_type == AppType::PlatformRuntime
923 && !self.resolved_capability_provides().is_empty()
924 {
925 return Err(
926 "platform-runtime packages cannot provide runtime capabilities".to_string(),
927 );
928 }
929
930 Ok(())
931 }
932
933 pub fn from_json(json: &str) -> Result<Self, String> {
935 Self::from_json_with_socket_path_policy(json, false)
936 }
937
938 pub fn from_json_with_socket_path_policy(
940 json: &str,
941 allow_non_run: bool,
942 ) -> Result<Self, String> {
943 let mut manifest: Self =
944 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
945 if manifest.ui.is_some() {
946 manifest.has_ui = true;
947 }
948 manifest.validate_with_socket_path_policy(allow_non_run)?;
949 Ok(manifest)
950 }
951}
952
953fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
954 let mut seen = HashSet::new();
955 let mut resolved = Vec::new();
956 for value in values {
957 let requirement = value.trim();
958 if requirement.is_empty() {
959 return Err("capability requirements must not be blank".to_string());
960 }
961 if seen.insert(requirement.to_string()) {
962 resolved.push(requirement.to_string());
963 }
964 }
965 Ok(resolved)
966}
967
968fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
969 if ui.ui_api != 1 && ui.ui_api != 2 {
970 return Err(format!(
971 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
972 ui.ui_api
973 ));
974 }
975 if ui.title.trim().is_empty() {
976 return Err("ui.title must not be blank".to_string());
977 }
978 ui.requires.resolved()?;
979 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
980 if let Some(icon) = &ui.icon {
981 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
982 }
983 if let Some(nav) = &ui.nav {
984 if ui.kind == AppUiKind::Widget {
985 return Err("widget ui must omit nav metadata".to_string());
986 }
987 if nav.section.trim().is_empty() {
988 return Err("ui.nav.section must not be blank".to_string());
989 }
990 }
991 if let Some(data) = &ui.data {
992 if ui.kind != AppUiKind::Stage {
993 return Err("widget ui must omit app data declarations".to_string());
994 }
995 validate_app_data(data, &ui.requires.resolved()?)?;
996 }
997
998 let mut composed = HashSet::new();
999 for name in &ui.composes {
1000 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1001 if name == app_name {
1002 return Err("ui.composes must not contain the app itself".to_string());
1003 }
1004 if !composed.insert(name) {
1005 return Err(format!("ui.composes contains duplicate app '{name}'"));
1006 }
1007 }
1008
1009 for (path, digest) in &ui.integrity {
1010 validate_manifest_path(path)
1011 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1012 if !is_lowercase_sha256(digest) {
1013 return Err(format!(
1014 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1015 ));
1016 }
1017 }
1018 if !ui.integrity.contains_key(&ui.entry) {
1019 return Err("ui.integrity must include the declared entry".to_string());
1020 }
1021 if let Some(icon) = &ui.icon {
1022 if !ui.integrity.contains_key(icon) {
1023 return Err("ui.integrity must include the declared icon".to_string());
1024 }
1025 }
1026 Ok(())
1027}
1028
1029fn validate_app_data(data: &AppDataManifest, resolved_requires: &[String]) -> Result<(), String> {
1030 validate_app_data_namespace(&data.namespace)?;
1031 validate_app_data_sync_policy(&data.sync)?;
1032 if data.queries.is_empty() {
1033 return Err("ui.data.queries must declare at least one query".to_string());
1034 }
1035
1036 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1037 let mut names = BTreeSet::new();
1038 for query in &data.queries {
1039 validate_namespaced_data_name(&query.name, &data.namespace)
1040 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1041 validate_capability_name(&query.capability).map_err(|error| {
1042 format!(
1043 "ui.data query '{}' capability '{}' invalid: {error}",
1044 query.name, query.capability
1045 )
1046 })?;
1047 if !requires.contains(query.capability.as_str()) {
1048 return Err(format!(
1049 "ui.data query '{}' capability '{}' must be declared in requires",
1050 query.name, query.capability
1051 ));
1052 }
1053 if !names.insert(query.name.as_str()) {
1054 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1055 }
1056 }
1057
1058 for stream in &data.streams {
1059 validate_namespaced_data_name(&stream.name, &data.namespace)
1060 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1061 if !names.insert(stream.name.as_str()) {
1062 return Err(format!(
1063 "ui.data contains duplicate declaration '{}'",
1064 stream.name
1065 ));
1066 }
1067 }
1068
1069 Ok(())
1070}
1071
1072fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1073 if !is_safe_name_segment(namespace) {
1074 return Err(format!(
1075 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1076 namespace
1077 ));
1078 }
1079 if matches!(
1080 namespace,
1081 "core" | "internal" | "node" | "platform" | "system"
1082 ) {
1083 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1084 }
1085 Ok(())
1086}
1087
1088fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1089 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1090 validate_optional_range(
1091 "full_refresh_interval_secs",
1092 sync.full_refresh_interval_secs,
1093 60,
1094 604_800,
1095 )?;
1096 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1097 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1098 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1099 }
1100 Ok(())
1101}
1102
1103fn validate_optional_range(
1104 field: &str,
1105 value: Option<u32>,
1106 min: u32,
1107 max: u32,
1108) -> Result<(), String> {
1109 if let Some(value) = value {
1110 if value < min || value > max {
1111 return Err(format!(
1112 "ui.data.sync {field} must be between {min} and {max} seconds"
1113 ));
1114 }
1115 }
1116 Ok(())
1117}
1118
1119fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1120 validate_capability_name(name)?;
1121 let Some(rest) = name
1122 .strip_prefix(namespace)
1123 .and_then(|suffix| suffix.strip_prefix('.'))
1124 else {
1125 return Err(format!("name must use namespace '{namespace}'"));
1126 };
1127 if rest.is_empty() {
1128 return Err("name must include a value after its namespace".to_string());
1129 }
1130 if !has_version_suffix(name) {
1131 return Err("name must end with a .vN version suffix".to_string());
1132 }
1133 Ok(())
1134}
1135
1136fn validate_capability_name(name: &str) -> Result<(), String> {
1137 if name.is_empty() {
1138 return Err("name must not be empty".to_string());
1139 }
1140 if name.contains('/') || name.contains("..") {
1141 return Err("name must not contain path separators or traversal".to_string());
1142 }
1143 if !name.split('.').all(is_safe_declaration_segment) {
1144 return Err("name must contain only lowercase dot-separated segments".to_string());
1145 }
1146 Ok(())
1147}
1148
1149fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1150 if allow_wildcard && name.ends_with(".*") {
1151 return validate_capability_name(&name[..name.len() - 2]);
1152 }
1153 validate_capability_name(name)
1154}
1155
1156fn has_version_suffix(name: &str) -> bool {
1157 let Some(version) = name.rsplit('.').next() else {
1158 return false;
1159 };
1160 let Some(digits) = version.strip_prefix('v') else {
1161 return false;
1162 };
1163 !digits.is_empty()
1164 && !digits.starts_with('0')
1165 && digits.bytes().all(|byte| byte.is_ascii_digit())
1166}
1167
1168fn is_safe_name_segment(segment: &str) -> bool {
1169 if segment.is_empty() {
1170 return false;
1171 }
1172 let mut chars = segment.chars();
1173 let Some(first) = chars.next() else {
1174 return false;
1175 };
1176 first.is_ascii_lowercase()
1177 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1178}
1179
1180fn is_safe_declaration_segment(segment: &str) -> bool {
1193 let mut chars = segment.chars();
1194 let Some(first) = chars.next() else {
1195 return false;
1196 };
1197 first.is_ascii_lowercase()
1198 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1199}
1200
1201fn is_lowercase_sha256(value: &str) -> bool {
1202 value.len() == 64
1203 && value
1204 .bytes()
1205 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1206}
1207
1208pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1217 validate_standalone_socket_path_with_policy(path, false)
1218}
1219
1220pub fn validate_standalone_socket_path_with_policy(
1222 path: &std::path::Path,
1223 allow_non_run: bool,
1224) -> Result<(), String> {
1225 if !path.is_absolute() {
1226 return Err(format!(
1227 "standalone.socket_path '{}' must be absolute",
1228 path.display()
1229 ));
1230 }
1231 if !allow_non_run && !path.starts_with("/run/") {
1232 return Err(format!(
1233 "standalone.socket_path '{}' must live under /run/",
1234 path.display()
1235 ));
1236 }
1237 if path
1238 .components()
1239 .any(|c| matches!(c, std::path::Component::ParentDir))
1240 {
1241 return Err(format!(
1242 "standalone.socket_path '{}' must not contain '..' segments",
1243 path.display()
1244 ));
1245 }
1246 Ok(())
1247}
1248
1249fn validate_app_name(name: &str) -> Result<(), String> {
1253 let (publisher, app) = if let Some(slash) = name.find('/') {
1254 let (p, rest) = name.split_at(slash);
1255 (Some(p), &rest[1..])
1256 } else {
1257 (None, name)
1258 };
1259
1260 let valid_segment = |s: &str| -> bool {
1261 if s.is_empty() {
1262 return false;
1263 }
1264 let mut chars = s.chars();
1265 let first = chars.next().unwrap();
1266 if !first.is_ascii_lowercase() {
1267 return false;
1268 }
1269 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1270 };
1271
1272 if let Some(pub_name) = publisher {
1273 if !valid_segment(pub_name) {
1274 return Err(format!(
1275 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1276 pub_name
1277 ));
1278 }
1279 }
1280
1281 if !valid_segment(app) {
1282 return Err(format!(
1283 "app name segment '{}' must match [a-z][a-z0-9-]*",
1284 app
1285 ));
1286 }
1287
1288 Ok(())
1289}
1290
1291#[cfg(test)]
1294mod tests {
1295 use super::*;
1296
1297 fn parse_ok(json: &str) -> AppManifest {
1298 AppManifest::from_json(json).expect("should parse")
1299 }
1300
1301 fn parse_err(json: &str) -> String {
1302 AppManifest::from_json(json).expect_err("should fail")
1303 }
1304
1305 #[test]
1308 fn v1_minimal_native() {
1309 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1310 assert_eq!(m.manifest_version, 1);
1311 assert_eq!(m.app_type, AppType::Native);
1312 assert!(m.abi.is_none());
1313 }
1314
1315 #[test]
1316 fn v1_minimal_bun() {
1317 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1318 assert_eq!(m.app_type, AppType::Bun);
1319 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1320 }
1321
1322 #[test]
1323 fn v1_no_manifest_version_field_defaults_to_1() {
1324 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1325 assert_eq!(m.manifest_version, 1);
1326 }
1327
1328 #[test]
1329 fn v1_all_optional_fields_missing() {
1330 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1331 assert!(!m.critical);
1332 assert!(m.auto_start);
1333 assert!(!m.has_ui);
1334 assert_eq!(m.ui_path, "dist");
1335 assert!(m.permissions.is_empty());
1336 assert!(m.optional_permissions.is_empty());
1337 assert!(m.governor.is_none());
1340 assert!(m.subscribes.is_empty());
1341 }
1342
1343 #[test]
1344 fn v1_with_permissions_and_provides() {
1345 let json = r#"{
1346 "name": "example",
1347 "version": "1.0.0",
1348 "app_type": "bun",
1349 "permissions": ["core.storage.kv"],
1350 "optional_permissions": ["core.notifications.create"],
1351 "provides": {
1352 "core.example.run": { "description": "Run example job" }
1353 }
1354 }"#;
1355 let m = parse_ok(json);
1356 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1357 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1358 assert!(m.provides.contains_key("core.example.run"));
1359 }
1360
1361 #[test]
1364 fn v2_minimal_native() {
1365 let json = r#"{
1366 "manifest_version": 2,
1367 "name": "cron",
1368 "version": "1.0.0",
1369 "app_type": "native",
1370 "abi": "v1",
1371 "entrypoint": "app.so",
1372 "hot_reload": "experimental"
1373 }"#;
1374 let m = parse_ok(json);
1375 assert_eq!(m.manifest_version, 2);
1376 assert_eq!(m.abi, Some(AbiVersion::V1));
1377 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1378 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1379 }
1380
1381 #[test]
1382 fn v2_minimal_bun_with_capabilities() {
1383 let json = r#"{
1384 "manifest_version": 2,
1385 "name": "example-fullstack",
1386 "version": "1.0.0",
1387 "app_type": "bun",
1388 "abi": "v1",
1389 "entrypoint": "dist/index.js",
1390 "hot_reload": "supported",
1391 "has_ui": true,
1392 "ui_path": "ui/dist",
1393 "capabilities": {
1394 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1395 "provides": []
1396 },
1397 "governor": { "terminable": false, "min_idle_secs": 300 },
1398 "subscribes": ["core.chat.message.received"]
1399 }"#;
1400 let m = parse_ok(json);
1401 assert_eq!(m.manifest_version, 2);
1402 assert_eq!(m.capabilities.requires.len(), 2);
1403 let governor = m.governor.expect("governor block should parse");
1405 assert_eq!(governor.terminable, Some(false));
1406 assert_eq!(governor.min_idle_secs, Some(300));
1407 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1408 }
1409
1410 #[test]
1411 fn stage_contract_fixture_parses_with_normalized_requirements() {
1412 let json = include_str!(
1413 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1414 );
1415 let m = parse_ok(json);
1416 assert!(m.has_ui);
1417 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1418 let ui = m.ui.expect("fixture should declare ui");
1419 assert_eq!(ui.kind, AppUiKind::Stage);
1420 assert_eq!(ui.entry, "ui/main.js");
1421 assert_eq!(ui.nav.unwrap().order, 10);
1422 }
1423
1424 #[test]
1425 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1426 let m = parse_ok(
1427 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1428 );
1429 assert!(m.has_ui);
1430 assert_eq!(m.ui_path, "ui/dist");
1431 assert!(m.ui.is_none());
1432 }
1433
1434 #[test]
1435 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1436 let manifest = parse_ok(
1437 r#"{
1438 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1439 "requires":["core.cron.register"],
1440 "ui":{
1441 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1442 "requires":{
1443 "capabilities":["ui.snapshot.v1"],
1444 "queries":["ui.query.v1"],
1445 "streams":["ui.event.v1"]
1446 },
1447 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1448 }
1449 }"#,
1450 );
1451
1452 assert_eq!(
1453 manifest.resolved_requires().unwrap(),
1454 vec!["core.cron.register"]
1455 );
1456 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1457 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1458 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1459 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1460 assert_eq!(
1461 ui.requires.resolved().unwrap(),
1462 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1463 );
1464 let serialized = serde_json::to_value(ui).unwrap();
1465 assert_eq!(
1466 serialized["requires"]["queries"],
1467 serde_json::json!(["ui.query.v1"])
1468 );
1469 }
1470
1471 #[test]
1472 fn ui_requirements_reject_blank_entries() {
1473 let error = parse_err(
1474 r#"{
1475 "name":"ui-contract","version":"1.0.0","app_type":"bun",
1476 "ui":{
1477 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1478 "requires":{"capabilities":[""],"queries":[],"streams":[]},
1479 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1480 }
1481 }"#,
1482 );
1483 assert!(error.contains("ui.requires entries must not be blank"));
1484 }
1485
1486 #[test]
1487 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1488 assert!(validate_app_data_namespace("obs-viewer").is_ok());
1495
1496 let error = validate_app_data_namespace("obs_viewer")
1497 .expect_err("underscore must not be admitted into a storage namespace");
1498 assert!(
1499 error.contains("must match [a-z][a-z0-9-]*"),
1500 "unexpected error: {error}"
1501 );
1502 }
1503
1504 #[test]
1505 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1506 let error = parse_err(
1507 r#"{
1508 "name":"ui-data","version":"1.0.0","app_type":"bun",
1509 "requires":["ui.snapshot.v1"],
1510 "ui":{
1511 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1512 "requires":{"capabilities":[],"queries":[],"streams":[]},
1513 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1514 "data":{
1515 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1516 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1517 "streams":[]
1518 }
1519 }
1520 }"#,
1521 );
1522 assert!(error.contains("must be declared in requires"));
1523 }
1524
1525 #[test]
1526 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1527 let accepted = parse_ok(
1528 r#"{
1529 "name":"aliases","version":"1.0.0","app_type":"bun",
1530 "requires":["core.chat.read","core.chat.read","core.chat.send"],
1531 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1532 }"#,
1533 );
1534 assert_eq!(
1535 accepted.resolved_requires().unwrap(),
1536 vec!["core.chat.read", "core.chat.send"]
1537 );
1538
1539 let err = parse_err(
1540 r#"{
1541 "name":"aliases","version":"1.0.0","app_type":"bun",
1542 "requires":["core.chat.read"],
1543 "capabilities":{"requires":["core.wallet.pay"]}
1544 }"#,
1545 );
1546 assert!(err.contains("conflicts"), "unexpected error: {err}");
1547 }
1548
1549 #[test]
1550 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1551 let base = |ui: &str| {
1552 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1553 };
1554 let widget = base(
1555 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1556 );
1557 assert_eq!(
1558 parse_ok(&widget).ui.expect("widget ui").kind,
1559 AppUiKind::Widget
1560 );
1561 let widget_nav = base(
1562 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1563 );
1564 assert!(parse_err(&widget_nav).contains("must omit nav"));
1565 let api = base(
1566 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1567 );
1568 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1569 let unsupported_api = base(
1570 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1571 );
1572 assert!(parse_err(&unsupported_api).contains("ui_api"));
1573 let path = base(
1574 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1575 );
1576 assert!(parse_err(&path).contains("entry"));
1577 }
1578
1579 #[test]
1580 fn stage_requires_integrity_for_entry_and_icon() {
1581 let missing_entry = r#"{
1582 "name":"stage","version":"1.0.0","app_type":"bun",
1583 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1584 }"#;
1585 assert!(parse_err(missing_entry).contains("entry"));
1586 let missing_icon = r#"{
1587 "name":"stage","version":"1.0.0","app_type":"bun",
1588 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1589 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1590 }"#;
1591 assert!(parse_err(missing_icon).contains("icon"));
1592 let uppercase = r#"{
1593 "name":"stage","version":"1.0.0","app_type":"bun",
1594 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1595 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1596 }"#;
1597 assert!(parse_err(uppercase).contains("lowercase"));
1598 }
1599
1600 #[test]
1601 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1602 let manifest = |composes: &str| {
1603 format!(
1604 r#"{{
1605 "name":"stage","version":"1.0.0","app_type":"bun",
1606 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1607 "composes":{composes},
1608 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1609 }}"#
1610 )
1611 };
1612 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1613 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1614 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1615 }
1616
1617 #[test]
1618 fn v2_missing_abi_is_error() {
1619 let json = r#"{
1620 "manifest_version": 2,
1621 "name": "example",
1622 "version": "1.0.0",
1623 "app_type": "bun"
1624 }"#;
1625 let err = parse_err(json);
1626 assert!(err.contains("abi"), "expected abi error, got: {}", err);
1627 }
1628
1629 #[test]
1632 fn publisher_prefixed_name_accepted() {
1633 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1634 assert_eq!(m.name, "alice/weather");
1635 }
1636
1637 #[test]
1638 fn double_slash_name_rejected() {
1639 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1640 assert!(!err.is_empty());
1641 }
1642
1643 #[test]
1646 fn name_starting_with_digit_rejected() {
1647 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1648 assert!(!err.is_empty());
1649 }
1650
1651 #[test]
1652 fn name_with_uppercase_rejected() {
1653 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1654 assert!(!err.is_empty());
1655 }
1656
1657 #[test]
1658 fn empty_name_rejected() {
1659 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1660 assert!(!err.is_empty());
1661 }
1662
1663 #[test]
1666 fn path_traversal_double_dot_rejected() {
1667 let json = r#"{
1668 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1669 "app_type": "bun", "abi": "v1",
1670 "entrypoint": "../etc/passwd"
1671 }"#;
1672 let err = parse_err(json);
1673 assert!(err.contains(".."), "expected traversal error, got: {}", err);
1674 }
1675
1676 #[test]
1677 fn path_traversal_encoded_dot_not_decoded() {
1678 let json = r#"{
1680 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1681 "app_type": "bun", "abi": "v1",
1682 "entrypoint": "foo/../bar"
1683 }"#;
1684 let err = parse_err(json);
1685 assert!(!err.is_empty(), "should have failed: {}", err);
1686 }
1687
1688 #[test]
1689 fn absolute_path_rejected() {
1690 let json = r#"{
1691 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1692 "app_type": "bun", "abi": "v1",
1693 "entrypoint": "/usr/bin/sh"
1694 }"#;
1695 let err = parse_err(json);
1696 assert!(
1697 err.contains("absolute"),
1698 "expected absolute error, got: {}",
1699 err
1700 );
1701 }
1702
1703 #[test]
1704 fn shell_metachar_in_path_rejected() {
1705 let json = r#"{
1706 "manifest_version": 2, "name": "evil", "version": "1.0.0",
1707 "app_type": "bun", "abi": "v1",
1708 "entrypoint": "dist/index.js;rm -rf /"
1709 }"#;
1710 let err = parse_err(json);
1711 assert!(!err.is_empty());
1712 }
1713
1714 #[test]
1715 fn valid_nested_path_accepted() {
1716 let json = r#"{
1717 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1718 "app_type": "bun", "abi": "v1",
1719 "entrypoint": "dist/index.js",
1720 "ui_path": "ui/dist"
1721 }"#;
1722 parse_ok(json);
1723 }
1724
1725 #[test]
1728 fn homepage_https_accepted() {
1729 let json = r#"{
1730 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1731 "homepage": "https://example.com"
1732 }"#;
1733 parse_ok(json);
1734 }
1735
1736 #[test]
1737 fn homepage_javascript_scheme_rejected() {
1738 let json = r#"{
1739 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1740 "homepage": "javascript:alert(1)"
1741 }"#;
1742 let err = parse_err(json);
1743 assert!(
1744 err.contains("scheme"),
1745 "expected scheme error, got: {}",
1746 err
1747 );
1748 }
1749
1750 #[test]
1751 fn homepage_file_scheme_rejected() {
1752 let json = r#"{
1753 "name": "my-app", "version": "1.0.0", "app_type": "bun",
1754 "homepage": "file:///etc/passwd"
1755 }"#;
1756 let err = parse_err(json);
1757 assert!(!err.is_empty());
1758 }
1759
1760 #[test]
1763 fn effective_entrypoint_native_default() {
1764 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1765 assert_eq!(m.effective_entrypoint(), "app.so");
1766 }
1767
1768 #[test]
1769 fn effective_entrypoint_bun_default() {
1770 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1771 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1772 }
1773
1774 #[test]
1775 fn effective_hot_reload_native_default_is_experimental() {
1776 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1777 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
1778 }
1779
1780 #[test]
1781 fn effective_hot_reload_bun_default_is_supported() {
1782 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1783 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
1784 }
1785
1786 #[test]
1787 fn hot_reload_unsupported_explicit() {
1788 let json = r#"{
1789 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
1790 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
1791 }"#;
1792 let m = parse_ok(json);
1793 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
1794 }
1795
1796 #[test]
1799 fn validate_path_simple_valid() {
1800 assert!(validate_manifest_path("dist/index.js").is_ok());
1801 assert!(validate_manifest_path("app.so").is_ok());
1802 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
1803 assert!(validate_manifest_path("build_output/main").is_ok());
1804 }
1805
1806 #[test]
1807 fn validate_path_empty_rejected() {
1808 assert!(validate_manifest_path("").is_err());
1809 }
1810
1811 #[test]
1812 fn validate_path_absolute_rejected() {
1813 assert!(validate_manifest_path("/usr/bin/sh").is_err());
1814 }
1815
1816 #[test]
1817 fn validate_path_double_dot_segment_rejected() {
1818 assert!(validate_manifest_path("foo/../bar").is_err());
1819 assert!(validate_manifest_path("../etc/passwd").is_err());
1820 }
1821
1822 #[test]
1823 fn validate_path_leading_dot_rejected() {
1824 assert!(validate_manifest_path(".hidden").is_err());
1825 }
1826
1827 #[test]
1828 fn validate_path_null_byte_rejected() {
1829 let path = "foo\0bar";
1831 assert!(validate_manifest_path(path).is_err());
1832 }
1833
1834 #[test]
1835 fn standalone_socket_path_development_override_is_explicit_and_pure() {
1836 let path = std::path::Path::new("/tmp/node-app/example.sock");
1837 assert!(validate_standalone_socket_path(path).is_err());
1838 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
1839 assert!(validate_standalone_socket_path_with_policy(
1840 std::path::Path::new("/tmp/node-app/../escape.sock"),
1841 true,
1842 )
1843 .is_err());
1844 }
1845
1846 #[test]
1849 fn manifest_capabilities_defaults_to_empty() {
1850 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1851 assert!(m.capabilities.requires.is_empty());
1852 assert!(m.capabilities.provides.is_empty());
1853 }
1854
1855 #[test]
1858 fn resolved_capability_provides_v1_only() {
1859 let json = r#"{
1860 "name": "example", "version": "1.0.0", "app_type": "bun",
1861 "provides": { "core.example.run": { "description": "Run example job" } }
1862 }"#;
1863 let m = parse_ok(json);
1864 let out = m.resolved_capability_provides();
1865 assert_eq!(out.len(), 1);
1866 assert_eq!(
1867 out.get("core.example.run").unwrap().description,
1868 "Run example job"
1869 );
1870 }
1871
1872 #[test]
1873 fn resolved_capability_provides_v2_names_get_blank_declaration() {
1874 let json = r#"{
1875 "manifest_version": 2, "name": "example", "version": "1.0.0",
1876 "app_type": "bun", "abi": "v1",
1877 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
1878 }"#;
1879 let m = parse_ok(json);
1880 let out = m.resolved_capability_provides();
1881 assert_eq!(out.len(), 2);
1882 assert_eq!(out.get("core.example.run").unwrap().description, "");
1883 assert!(out.get("core.example.run").unwrap().schema.is_none());
1884 assert!(out.contains_key("core.example.other"));
1886 assert!(!out.contains_key("core.example.other:extra"));
1887 }
1888
1889 #[test]
1890 fn resolved_capability_provides_v1_wins_on_conflict() {
1891 let json = r#"{
1892 "manifest_version": 2, "name": "example", "version": "1.0.0",
1893 "app_type": "bun", "abi": "v1",
1894 "provides": { "core.example.run": { "description": "v1 wins" } },
1895 "capabilities": { "requires": [], "provides": ["core.example.run"] }
1896 }"#;
1897 let m = parse_ok(json);
1898 let out = m.resolved_capability_provides();
1899 assert_eq!(out.len(), 1);
1900 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
1901 }
1902
1903 #[test]
1904 fn resolved_capability_provides_blank_v2_name_skipped() {
1905 let json = r#"{
1906 "manifest_version": 2, "name": "example", "version": "1.0.0",
1907 "app_type": "bun", "abi": "v1",
1908 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
1909 }"#;
1910 let m = parse_ok(json);
1911 let out = m.resolved_capability_provides();
1912 assert_eq!(out.len(), 1);
1913 assert!(out.contains_key("core.example.run"));
1914 }
1915
1916 #[test]
1917 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
1918 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1919 assert!(m.resolved_capability_provides().is_empty());
1920 }
1921
1922 #[test]
1925 fn abi_v1_is_supported() {
1926 assert!(AbiVersion::V1.is_supported());
1927 }
1928
1929 #[test]
1932 fn app_tier_display() {
1933 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
1934 assert_eq!(AppTier::Optional.to_string(), "optional");
1935 assert_eq!(AppTier::Development.to_string(), "development");
1936 }
1937
1938 #[test]
1939 fn app_tier_serde_roundtrip() {
1940 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
1942 let json = serde_json::to_string(&tier).unwrap();
1943 let back: AppTier = serde_json::from_str(&json).unwrap();
1944 assert_eq!(
1945 tier, back,
1946 "roundtrip failed for {:?}: serialized as {}",
1947 tier, json
1948 );
1949 }
1950 assert_eq!(
1951 serde_json::to_string(&AppTier::Development).unwrap(),
1952 "\"development\""
1953 );
1954 }
1955
1956 #[test]
1959 fn app_type_display() {
1960 assert_eq!(AppType::Native.to_string(), "native");
1961 assert_eq!(AppType::Bun.to_string(), "bun");
1962 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
1963 }
1964
1965 #[test]
1966 fn platform_runtime_is_a_supported_packaging_type() {
1967 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
1968 "manifest_version": 2,
1969 "abi": "v1",
1970 "name": "bun-runtime",
1971 "version": "1.0.0",
1972 "app_type": "platform-runtime",
1973 "entrypoint": "bun"
1974 }))
1975 .expect("platform runtime manifest should parse");
1976
1977 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
1978 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
1979 }
1980
1981 #[test]
1984 fn governor_manifest_memory_budget_defaults_to_none() {
1985 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
1986 assert_eq!(parsed.memory_budget_kb, None);
1987 }
1988
1989 #[test]
1990 fn governor_manifest_parses_declared_memory_budget() {
1991 let parsed: GovernorManifest =
1992 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
1993 assert_eq!(parsed.memory_budget_kb, Some(40_960));
1994 }
1995}
1996
1997#[cfg(test)]
1998mod shared_app_data_corpus_tests {
1999 use super::*;
2000
2001 #[test]
2006 fn shared_app_data_corpus_matches_the_host_validator() {
2007 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2008 let mut checked = 0;
2009 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2010 let path = entry.expect("readable entry").path();
2011 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2012 continue;
2013 }
2014 let fixture: serde_json::Value =
2015 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2016 .expect("valid fixture json");
2017 let name = path
2018 .file_name()
2019 .and_then(|n| n.to_str())
2020 .unwrap_or("?")
2021 .to_string();
2022 let manifest = serde_json::json!({
2023 "manifest_version": 2, "abi": "v1", "name": "fixture", "version": "0.1.0",
2024 "app_type": "bun", "entrypoint": "dist/index.js",
2025 "ui": fixture["ui"],
2026 });
2027 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2028 match fixture["expect"].as_str().expect("expect field") {
2029 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2030 "reject" => {
2031 let error = result.expect_err(&format!("{name} should be rejected"));
2032 let reason = fixture["reason"]
2033 .as_str()
2034 .expect("reject fixtures need a reason");
2035 assert!(
2036 error.contains(reason),
2037 "{name}: {error:?} should mention {reason:?}"
2038 );
2039 }
2040 other => panic!("{name}: unknown expect {other:?}"),
2041 }
2042 checked += 1;
2043 }
2044 assert!(checked >= 10, "expected the full corpus, walked {checked}");
2046 }
2047}