Skip to main content

node_app_manifest/
manifest.rs

1//! App manifest domain entity — unified v1/v2 schema.
2//!
3//! This module defines the canonical `AppManifest` used by the Node daemon to
4//! discover, load, and validate mini apps. It is a backward-compatible superset
5//! of the existing `PerAppManifest` (now promoted from `apps/server`).
6//!
7//! # Schema version detection
8//!
9//! - **v1** (legacy): no `manifest_version` field → `manifest_version = 1`.
10//!   All v2-only fields default to their v1-equivalent values. Zero existing
11//!   app manifests are invalidated.
12//! - **v2** (extended): `manifest_version = 2`. Adds `abi`, `entrypoint`,
13//!   `hot_reload`, and the typed `capabilities` block. Requires `abi` to be
14//!   present when `manifest_version == 2`.
15//!
16//! # Path-safety (SEC-H3)
17//!
18//! `entrypoint` and `ui_path` are validated at parse time:
19//! 1. Matches regex `^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$`
20//! 2. Contains no `..` segment
21//! 3. Does not begin with `/`
22//!
23//! The canonicalize-inside-install-dir check (step 4) is performed by
24//! `tier_validator.rs` at load time because the install directory is not known
25//! until the daemon resolves the path.
26
27use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
29
30// ── Enums ────────────────────────────────────────────────────────────────────
31
32/// App execution model — determines how the daemon loads and isolates the app.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum AppType {
36    /// In-process cdylib loaded via dlopen. First-party path only (SEC-H1).
37    Native,
38    /// Isolated subprocess managed by the Bun runtime.
39    Bun,
40    /// Independent systemd-managed service that owns its own Unix domain socket.
41    /// The daemon does not start or supervise the process; it only routes
42    /// capability invocations to the app's socket as JSON-RPC 2.0.
43    /// Requires a `standalone.socket_path` when the manifest declares any
44    /// `provides` / `capabilities.provides` entries.
45    Standalone,
46    /// Packaging-only runtime dependency (for example the shared Bun runtime).
47    /// It is installed and versioned like an app package but is never loaded,
48    /// registered as a capability provider, or hot-reloaded as an app.
49    #[serde(rename = "platform-runtime")]
50    PlatformRuntime,
51}
52
53impl AppType {
54    pub fn as_str(self) -> &'static str {
55        match self {
56            AppType::Native => "native",
57            AppType::Bun => "bun",
58            AppType::Standalone => "standalone",
59            AppType::PlatformRuntime => "platform-runtime",
60        }
61    }
62}
63
64impl std::fmt::Display for AppType {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "{}", self.as_str())
67    }
68}
69
70/// Trust/distribution tier — derived at load time from the install path
71/// AND (per FR-028 cycle 4) the manifest sidecar's GPG signature.
72///
73/// This is **not** stored in the manifest; it is computed by `tier_validator`.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum AppTier {
77    /// App installed at the bundled path (`/usr/share/node/builtin-apps/`)
78    /// OR at the apt path with a manifest sidecar signed by a node project key.
79    /// May be `Native` or `Bun`. Highest trust.
80    FirstParty,
81    /// App installed at the optional apt path (`/usr/lib/node/apps/`) with
82    /// no/invalid project signature. MUST be `Bun`; `Native` at this tier
83    /// triggers `TierError` (FR-028).
84    Optional,
85    /// App loaded from a developer's local dev directory (`NODE_DEV_APPS_DIR`),
86    /// via `node-app-build dev` or manual sideload. Bypasses signature checks
87    /// because the dev directory is owned by the developer (security gate is
88    /// the file-system path: only the dev user can write to it). Permitted
89    /// for `Native` apps so cdylib developers can iterate without per-build
90    /// GPG signing.
91    ///
92    /// Daemon logs every Development-tier load at `info!` so operators of a
93    /// real node can see when a non-prod app is active. UI badges this tier
94    /// distinctly (amber/red, never green).
95    Development,
96}
97
98impl AppTier {
99    pub fn as_str(self) -> &'static str {
100        match self {
101            AppTier::FirstParty => "first_party",
102            AppTier::Optional => "optional",
103            AppTier::Development => "development",
104        }
105    }
106}
107
108impl std::fmt::Display for AppTier {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        write!(f, "{}", self.as_str())
111    }
112}
113
114/// Host ABI compatibility version declared by the app.
115///
116/// The runtime's currently supported set is `[V1]`. Apps declaring an
117/// unsupported version are rejected with `AbiIncompatible` (FR-018).
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum AbiVersion {
121    V1,
122}
123
124impl AbiVersion {
125    pub fn as_str(&self) -> &'static str {
126        match self {
127            AbiVersion::V1 => "v1",
128        }
129    }
130
131    /// Returns true if this ABI version is supported by the current runtime.
132    pub fn is_supported(&self) -> bool {
133        matches!(self, AbiVersion::V1)
134    }
135}
136
137impl std::fmt::Display for AbiVersion {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        write!(f, "{}", self.as_str())
140    }
141}
142
143/// How in-process (native) app reload is expected to behave.
144///
145/// Per research.md §R10, native hot-reload is inherently unreliable due to
146/// `dlclose` semantics. The manifest field sets correct user expectations.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum HotReloadKind {
150    /// Reload is reliable (Bun subprocess restart). Default for `Bun` apps.
151    Supported,
152    /// Reload attempted but may fail; expect `RequiresRestart` on failure.
153    /// Default for `Native` apps (FR-024).
154    Experimental,
155    /// App must be restarted to pick up changes.
156    Unsupported,
157}
158
159impl HotReloadKind {
160    pub fn default_for(app_type: AppType) -> Self {
161        match app_type {
162            AppType::Native => HotReloadKind::Experimental,
163            AppType::Bun => HotReloadKind::Supported,
164            // Standalone apps are restarted by systemd, not the daemon —
165            // from the daemon's perspective they are never hot-reloaded.
166            AppType::Standalone => HotReloadKind::Unsupported,
167            AppType::PlatformRuntime => HotReloadKind::Unsupported,
168        }
169    }
170}
171
172// ── Sub-types ─────────────────────────────────────────────────────────────────
173
174/// Capability declarations from the v2 manifest `capabilities` block.
175///
176/// Semantic equivalent of the existing `permissions` + `provides` fields;
177/// v2 manifests may use either or both (backward compat preserved).
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ManifestCapabilities {
180    /// Capabilities this app requests from the host or other apps.
181    /// Format: `"core.lightning.payment.send:max=1000sat/day"` (see §1.2).
182    #[serde(default)]
183    pub requires: Vec<String>,
184
185    /// Capabilities this app provides to other apps.
186    /// Format: `"core.cron.register"`.
187    #[serde(default)]
188    pub provides: Vec<String>,
189}
190
191/// A single scope provided by an app (existing v1 model, preserved verbatim).
192#[derive(Debug, Clone, Serialize, Deserialize, Default)]
193pub struct ProvidedScope {
194    pub scope: String,
195    pub description: String,
196    pub resource_pattern: String,
197}
198
199/// Declarative per-endpoint access policy (existing v1 model, preserved verbatim).
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct EndpointPolicy {
202    pub method: String,
203    pub path: String,
204    pub required_permissions: Vec<String>,
205}
206
207/// Capability provider declaration (existing v1 model, preserved verbatim).
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ProvidedCapability {
210    #[serde(default)]
211    pub description: String,
212    #[serde(default)]
213    pub schema: Option<serde_json::Value>,
214}
215
216/// Configuration for `AppType::Standalone` apps.
217///
218/// Carried only by manifests whose `app_type == "standalone"`. The daemon uses
219/// `socket_path` to route capability invocations as line-delimited JSON-RPC 2.0
220/// over the standalone daemon's own Unix domain socket.
221///
222/// Path-safety rules (validated by `AppManifest::validate`):
223/// - Absolute path.
224/// - Lives under `/run/`.
225/// - No `..` segments.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct StandaloneConfig {
228    pub socket_path: std::path::PathBuf,
229}
230
231/// Browser UI unit shipped by an app package.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum AppUiKind {
235    Stage,
236    Widget,
237}
238
239fn default_app_ui_kind() -> AppUiKind {
240    AppUiKind::Stage
241}
242
243fn default_nav_section() -> String {
244    "default".to_string()
245}
246
247/// Shell-owned navigation metadata for a top-level stage.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct AppUiNav {
250    #[serde(default = "default_nav_section")]
251    pub section: String,
252    #[serde(default)]
253    pub order: i32,
254}
255
256/// Optional stage metadata carried by the canonical app manifest.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct AppUiManifest {
259    #[serde(default = "default_app_ui_kind")]
260    pub kind: AppUiKind,
261    pub entry: String,
262    pub title: String,
263    #[serde(default)]
264    pub icon: Option<String>,
265    #[serde(default)]
266    pub nav: Option<AppUiNav>,
267    #[serde(default)]
268    pub composes: Vec<String>,
269    pub ui_api: u8,
270    #[serde(default)]
271    pub integrity: BTreeMap<String, String>,
272}
273
274// ── Path-safety helpers ───────────────────────────────────────────────────────
275
276/// Validate a relative file path declared in a manifest (`entrypoint`, `ui_path`).
277///
278/// Rules (SEC-H3):
279/// 1. Matches `^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$` — rejects shell metacharacters,
280///    leading `.`, leading `/`, etc.
281/// 2. No `..` segment anywhere.
282/// 3. Does not begin with `/` (absolute paths).
283///
284/// Returns `Ok(())` if valid, `Err(reason)` describing the violation.
285pub fn validate_manifest_path(path: &str) -> Result<(), String> {
286    if path.is_empty() {
287        return Err("path must not be empty".to_string());
288    }
289
290    // Rule 3: no absolute paths
291    if path.starts_with('/') {
292        return Err(format!(
293            "path '{}' must not be absolute (starts with /)",
294            path
295        ));
296    }
297
298    // Rule 1: allowed character set
299    // ^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$
300    let first = path.chars().next().unwrap();
301    if !first.is_ascii_alphanumeric() && first != '_' {
302        return Err(format!(
303            "path '{}' must begin with an alphanumeric character or underscore",
304            path
305        ));
306    }
307    for ch in path.chars().skip(1) {
308        if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
309            return Err(format!(
310                "path '{}' contains disallowed character '{}'",
311                path, ch
312            ));
313        }
314    }
315
316    // Rule 2: no `..` segment
317    for segment in path.split('/') {
318        if segment == ".." {
319            return Err(format!(
320                "path '{}' contains a '..' segment (path traversal rejected)",
321                path
322            ));
323        }
324    }
325
326    Ok(())
327}
328
329// ── AppManifest ───────────────────────────────────────────────────────────────
330
331/// Canonical manifest entity — unified v1/v2 format.
332///
333/// Deserializes both old (v1, no `manifest_version`) and new (v2) manifests.
334/// All v2-only fields use `#[serde(default)]` so that v1 manifests parse
335/// correctly without any field changes.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct AppManifest {
338    /// Schema version. Absent or 1 = legacy v1; 2 = extended v2.
339    #[serde(default = "default_manifest_version", rename = "manifest_version")]
340    pub manifest_version: u8,
341
342    pub name: String,
343    pub version: String,
344
345    #[serde(default = "default_app_type_native")]
346    pub app_type: AppType,
347
348    #[serde(default)]
349    pub description: String,
350
351    // ── v2-only additions (all optional, v1-compatible defaults) ─────────────
352    /// Host ABI compatibility version. Required when `manifest_version == 2`.
353    pub abi: Option<AbiVersion>,
354
355    /// Payload entry point relative to the app directory.
356    /// Default: `app.so` for Native, `dist/index.js` for Bun.
357    pub entrypoint: Option<String>,
358
359    /// Hot-reload behaviour classification.
360    /// Default: `experimental` for Native, `supported` for Bun.
361    pub hot_reload: Option<HotReloadKind>,
362
363    // ── Existing v1 fields (preserved verbatim — DO NOT RENAME) ──────────────
364    #[serde(default)]
365    pub critical: bool,
366
367    #[serde(
368        default = "default_auto_start",
369        deserialize_with = "deserialize_auto_start"
370    )]
371    pub auto_start: bool,
372
373    #[serde(default)]
374    pub has_ui: bool,
375
376    #[serde(default = "default_ui_path")]
377    pub ui_path: String,
378
379    #[serde(default)]
380    pub permissions: Vec<String>,
381
382    /// Capability requirements in the v2 top-level vocabulary. This is an
383    /// alias for `capabilities.requires`, not a second permission system.
384    #[serde(default)]
385    pub requires: Vec<String>,
386
387    #[serde(default)]
388    pub optional_permissions: Vec<String>,
389
390    #[serde(default)]
391    pub provides_scopes: Vec<ProvidedScope>,
392
393    #[serde(default)]
394    pub endpoint_policies: Vec<EndpointPolicy>,
395
396    #[serde(default)]
397    pub capability_scopes: HashMap<String, String>,
398
399    #[serde(default)]
400    pub provides: HashMap<String, ProvidedCapability>,
401
402    // ── v2 capabilities block (semantic alias for permissions + provides) ─────
403    #[serde(default)]
404    pub capabilities: ManifestCapabilities,
405
406    /// App-delivered browser UI metadata. Legacy `has_ui`/`ui_path` remains
407    /// readable but does not synthesize this block.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub ui: Option<AppUiManifest>,
410
411    // ── Optional metadata fields ──────────────────────────────────────────────
412    #[serde(default)]
413    pub author: Option<String>,
414
415    #[serde(default)]
416    pub homepage: Option<String>,
417
418    #[serde(default)]
419    pub depends_on: Option<Vec<String>>,
420
421    #[serde(default)]
422    pub boot_priority: Option<u32>,
423
424    /// App-governor idle-termination policy (issue #811 SP1). Absent means
425    /// the app is subject to the default eligibility rules with no explicit
426    /// opt-out and no minimum-idle override.
427    #[serde(default)]
428    pub governor: Option<GovernorManifest>,
429
430    /// Event-bus topics this app listens for while lazily started. Only
431    /// meaningful for apps holding the `EVENT_LISTENER` capability — a
432    /// listener with no declared `subscribes` topics is exempt from idle
433    /// termination because the governor cannot know what would need to wake
434    /// it back up (see `node-app-host::governor_eligibility`).
435    #[serde(default)]
436    pub subscribes: Vec<String>,
437
438    /// Required when `app_type == "standalone"` and the manifest declares any
439    /// `provides` / `capabilities.provides` entries. Carries the Unix domain
440    /// socket path the daemon dispatches capability calls to.
441    #[serde(default)]
442    pub standalone: Option<StandaloneConfig>,
443
444    /// Optional TCP-binding block — feature 470 (port registry).
445    /// Absence means the app does not bind a TCP port the registry manages.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub tcp: Option<TcpManifest>,
448}
449
450/// Idle-termination policy for a lazily-started app (issue #811 SP1 — the
451/// app governor). Nested under `AppManifest::governor`.
452#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
453pub struct GovernorManifest {
454    /// Explicit opt-out. `Some(false)` exempts the app from idle termination
455    /// regardless of any other eligibility rule. `None`/`Some(true)` defers
456    /// to the other eligibility rules.
457    #[serde(default)]
458    pub terminable: Option<bool>,
459
460    /// Minimum idle duration, in seconds, before the governor may terminate
461    /// this app — overrides the governor's default sweep threshold. `None`
462    /// defers to the default.
463    #[serde(default)]
464    pub min_idle_secs: Option<u64>,
465}
466
467/// TCP port preferences for standalone apps that bind their own port.
468/// Consumed by the port registry (`system/server/src/services/port_registry/`)
469/// at install time.
470#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
471pub struct TcpManifest {
472    /// The TCP port the app would like to bind. Honored when free;
473    /// otherwise the registry assigns the next free port from the pool
474    /// (default 7000–7099). Absent → registry picks any free pool slot.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub preferred_port: Option<u16>,
477
478    /// When `true`, the platform UI shell builds iframe URLs as direct LAN
479    /// connections to the assigned port rather than routing via the
480    /// `/api/v2/node-apps/{name}/ui/` reverse-proxy. Intended only for apps
481    /// that must outlive a platform restart (e.g. OTA self-upgrade). Remote
482    /// users may see a degraded experience — owned by the consuming app's UI,
483    /// not this spec (see `specs/470-port-registry/spec.md` Clarifications Q5b).
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub direct_bind: Option<bool>,
486}
487
488fn default_manifest_version() -> u8 {
489    1
490}
491
492fn default_auto_start() -> bool {
493    true
494}
495
496/// Deserialize `auto_start` from either a bool (manifest v1) or a load-mode
497/// string (v2, e.g. `"lazy"`/`"eager"`/`"active"`). Eager-start modes map to
498/// `true`; `"lazy"` and other on-demand/inactive states map to `false` (the app
499/// is started on first capability use, not at boot). This keeps both manifest
500/// schema generations parseable by `AppManifest::from_json`.
501fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
502where
503    D: serde::Deserializer<'de>,
504{
505    #[derive(Deserialize)]
506    #[serde(untagged)]
507    enum BoolOrStr {
508        Bool(bool),
509        Str(String),
510    }
511    Ok(match BoolOrStr::deserialize(deserializer)? {
512        BoolOrStr::Bool(b) => b,
513        BoolOrStr::Str(s) => matches!(
514            s.trim().to_ascii_lowercase().as_str(),
515            "true" | "eager" | "active" | "auto" | "on" | "1"
516        ),
517    })
518}
519
520fn default_ui_path() -> String {
521    "dist".to_string()
522}
523
524fn default_app_type_native() -> AppType {
525    AppType::Native
526}
527
528impl AppManifest {
529    /// Resolve top-level `requires` and `capabilities.requires` into one
530    /// canonical declaration list. Equal aliases are accepted regardless of
531    /// order or duplicates; differing aliases are rejected.
532    pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
533        let top = normalized_requirements(&self.requires)?;
534        let nested = normalized_requirements(&self.capabilities.requires)?;
535        if !top.is_empty()
536            && !nested.is_empty()
537            && top.iter().cloned().collect::<BTreeSet<_>>()
538                != nested.iter().cloned().collect::<BTreeSet<_>>()
539        {
540            return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
541        }
542        Ok(if !top.is_empty() { top } else { nested })
543    }
544
545    /// Returns the effective `HotReloadKind` — explicit field or the default
546    /// for the app type.
547    pub fn effective_hot_reload(&self) -> HotReloadKind {
548        self.hot_reload
549            .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
550    }
551
552    /// Returns the effective entrypoint — explicit field or the type-specific default.
553    ///
554    /// Standalone apps have no daemon-managed entrypoint (systemd owns the
555    /// lifecycle); the empty string signals "not applicable".
556    pub fn effective_entrypoint(&self) -> &str {
557        if let Some(ref ep) = self.entrypoint {
558            ep.as_str()
559        } else {
560            match self.app_type {
561                AppType::Native => "app.so",
562                AppType::Bun => "dist/index.js",
563                AppType::Standalone => "",
564                AppType::PlatformRuntime => "bun",
565            }
566        }
567    }
568
569    /// True iff this manifest declares at least one capability provider
570    /// (via either the v1 `provides` map or the v2 `capabilities.provides` list).
571    pub fn has_capability_providers(&self) -> bool {
572        !self.provides.is_empty() || !self.capabilities.provides.is_empty()
573    }
574
575    /// Merges the v1 `provides` map and the v2 `capabilities.provides` name
576    /// list into a single capability→declaration map (composition-root
577    /// cleanup Round 4 T28 — extracted from
578    /// `control_ipc::handlers::handle_app_register_standalone`, which uses
579    /// this to shape a standalone app's declared providers for capability
580    /// registration).
581    ///
582    /// - v1 entries (the `provides` map) carry their real
583    ///   description/schema and always win on a name conflict.
584    /// - v2-only names (declared only via `capabilities.provides`, format
585    ///   `"name"` or `"name:extra"` — only the part before the first `:` is
586    ///   used) get a blank declaration, inserted only if the name is not
587    ///   already present from v1. Blank/whitespace-only names are skipped.
588    pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
589        let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
590        for raw in &self.capabilities.provides {
591            let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
592            if name.is_empty() {
593                continue;
594            }
595            out.entry(name).or_insert(ProvidedCapability {
596                description: String::new(),
597                schema: None,
598            });
599        }
600        out
601    }
602
603    /// Validate the manifest for structural correctness.
604    ///
605    /// Returns `Ok(())` on success, or a human-readable error string.
606    /// Called by the manifest parser after deserialization.
607    pub fn validate(&self) -> Result<(), String> {
608        self.validate_with_socket_path_policy(false)
609    }
610
611    /// Validate this manifest with an explicit standalone socket-path policy.
612    ///
613    /// Runtime adapters may opt into non-`/run` paths for development without
614    /// making the domain model read process configuration.
615    pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
616        // v2 requires abi field
617        if self.manifest_version == 2 && self.abi.is_none() {
618            return Err("manifest_version 2 requires an 'abi' field".to_string());
619        }
620
621        // Name validation: ^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$
622        // (publisher/name form accepted but not yet semantically used — FR-019)
623        validate_app_name(&self.name)?;
624        self.resolved_requires()?;
625
626        // Path-safety on entrypoint and ui_path
627        if let Some(ref ep) = self.entrypoint {
628            validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
629        }
630        // ui_path is only meaningful when has_ui is true, but validate always
631        if !self.ui_path.is_empty() && self.ui_path != "dist" {
632            validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
633        }
634
635        if let Some(ui) = &self.ui {
636            validate_app_ui(&self.name, ui)?;
637        }
638
639        // Homepage scheme validation (if present)
640        if let Some(ref hp) = self.homepage {
641            if !hp.starts_with("https://") && !hp.starts_with("http://") {
642                return Err(format!(
643                    "homepage '{}' must use https:// or http:// scheme",
644                    hp
645                ));
646            }
647        }
648
649        // Standalone-app rules:
650        // - When `app_type == "standalone"` AND the manifest declares any
651        //   capability providers, `standalone.socket_path` is required and
652        //   must be an absolute path under `/run/` with no `..` segments.
653        // - Non-standalone manifests MUST NOT carry a `standalone` block
654        //   (rejected to surface accidental schema misuse).
655        match self.app_type {
656            AppType::Standalone => {
657                if self.has_capability_providers() {
658                    let cfg = self.standalone.as_ref().ok_or_else(|| {
659                        "standalone apps that declare 'provides' require a \
660                         'standalone.socket_path' field"
661                            .to_string()
662                    })?;
663                    validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
664                }
665            }
666            AppType::Native | AppType::Bun | AppType::PlatformRuntime => {
667                if self.standalone.is_some() {
668                    return Err(format!(
669                        "'standalone' block is only valid when app_type == 'standalone' \
670                         (found app_type='{}')",
671                        self.app_type
672                    ));
673                }
674            }
675        }
676
677        if self.app_type == AppType::PlatformRuntime
678            && !self.resolved_capability_provides().is_empty()
679        {
680            return Err(
681                "platform-runtime packages cannot provide runtime capabilities".to_string(),
682            );
683        }
684
685        Ok(())
686    }
687
688    /// Parse from a JSON string, validate, and return the manifest.
689    pub fn from_json(json: &str) -> Result<Self, String> {
690        Self::from_json_with_socket_path_policy(json, false)
691    }
692
693    /// Parse and validate with an explicit standalone socket-path policy.
694    pub fn from_json_with_socket_path_policy(
695        json: &str,
696        allow_non_run: bool,
697    ) -> Result<Self, String> {
698        let mut manifest: Self =
699            serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
700        if manifest.ui.is_some() {
701            manifest.has_ui = true;
702        }
703        manifest.validate_with_socket_path_policy(allow_non_run)?;
704        Ok(manifest)
705    }
706}
707
708fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
709    let mut seen = HashSet::new();
710    let mut resolved = Vec::new();
711    for value in values {
712        let requirement = value.trim();
713        if requirement.is_empty() {
714            return Err("capability requirements must not be blank".to_string());
715        }
716        if seen.insert(requirement.to_string()) {
717            resolved.push(requirement.to_string());
718        }
719    }
720    Ok(resolved)
721}
722
723fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
724    if ui.ui_api != 1 {
725        return Err(format!(
726            "ui.ui_api {} is unsupported; only version 1 is accepted",
727            ui.ui_api
728        ));
729    }
730    if ui.title.trim().is_empty() {
731        return Err("ui.title must not be blank".to_string());
732    }
733    validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
734    if let Some(icon) = &ui.icon {
735        validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
736    }
737    if let Some(nav) = &ui.nav {
738        if ui.kind == AppUiKind::Widget {
739            return Err("widget ui must omit nav metadata".to_string());
740        }
741        if nav.section.trim().is_empty() {
742            return Err("ui.nav.section must not be blank".to_string());
743        }
744    }
745
746    let mut composed = HashSet::new();
747    for name in &ui.composes {
748        validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
749        if name == app_name {
750            return Err("ui.composes must not contain the app itself".to_string());
751        }
752        if !composed.insert(name) {
753            return Err(format!("ui.composes contains duplicate app '{name}'"));
754        }
755    }
756
757    for (path, digest) in &ui.integrity {
758        validate_manifest_path(path)
759            .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
760        if !is_lowercase_sha256(digest) {
761            return Err(format!(
762                "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
763            ));
764        }
765    }
766    if !ui.integrity.contains_key(&ui.entry) {
767        return Err("ui.integrity must include the declared entry".to_string());
768    }
769    if let Some(icon) = &ui.icon {
770        if !ui.integrity.contains_key(icon) {
771            return Err("ui.integrity must include the declared icon".to_string());
772        }
773    }
774    Ok(())
775}
776
777fn is_lowercase_sha256(value: &str) -> bool {
778    value.len() == 64
779        && value
780            .bytes()
781            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
782}
783
784/// Validate a `StandaloneConfig::socket_path`.
785///
786/// Rules:
787/// 1. Absolute path (starts with `/`).
788/// 2. Lives under `/run/` (rejects `/etc/...`, `/tmp/...`, etc. — pins the
789///    socket to a tmpfs path predictably writable by the standalone daemon).
790///    Runtime adapters can explicitly bypass this restriction for development.
791/// 3. No `..` segments anywhere in the path.
792pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
793    validate_standalone_socket_path_with_policy(path, false)
794}
795
796/// Validate a standalone socket path with an explicit runtime policy.
797pub fn validate_standalone_socket_path_with_policy(
798    path: &std::path::Path,
799    allow_non_run: bool,
800) -> Result<(), String> {
801    if !path.is_absolute() {
802        return Err(format!(
803            "standalone.socket_path '{}' must be absolute",
804            path.display()
805        ));
806    }
807    if !allow_non_run && !path.starts_with("/run/") {
808        return Err(format!(
809            "standalone.socket_path '{}' must live under /run/",
810            path.display()
811        ));
812    }
813    if path
814        .components()
815        .any(|c| matches!(c, std::path::Component::ParentDir))
816    {
817        return Err(format!(
818            "standalone.socket_path '{}' must not contain '..' segments",
819            path.display()
820        ));
821    }
822    Ok(())
823}
824
825/// Validate an app name string.
826///
827/// Accepts `app-name` (simple) and `publisher/app-name` (publisher-prefixed, FR-019).
828fn validate_app_name(name: &str) -> Result<(), String> {
829    let (publisher, app) = if let Some(slash) = name.find('/') {
830        let (p, rest) = name.split_at(slash);
831        (Some(p), &rest[1..])
832    } else {
833        (None, name)
834    };
835
836    let valid_segment = |s: &str| -> bool {
837        if s.is_empty() {
838            return false;
839        }
840        let mut chars = s.chars();
841        let first = chars.next().unwrap();
842        if !first.is_ascii_lowercase() {
843            return false;
844        }
845        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
846    };
847
848    if let Some(pub_name) = publisher {
849        if !valid_segment(pub_name) {
850            return Err(format!(
851                "publisher segment '{}' must match [a-z][a-z0-9-]*",
852                pub_name
853            ));
854        }
855    }
856
857    if !valid_segment(app) {
858        return Err(format!(
859            "app name segment '{}' must match [a-z][a-z0-9-]*",
860            app
861        ));
862    }
863
864    Ok(())
865}
866
867// ── Tests ─────────────────────────────────────────────────────────────────────
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872
873    fn parse_ok(json: &str) -> AppManifest {
874        AppManifest::from_json(json).expect("should parse")
875    }
876
877    fn parse_err(json: &str) -> String {
878        AppManifest::from_json(json).expect_err("should fail")
879    }
880
881    // ── v1 manifests ──────────────────────────────────────────────────────────
882
883    #[test]
884    fn v1_minimal_native() {
885        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
886        assert_eq!(m.manifest_version, 1);
887        assert_eq!(m.app_type, AppType::Native);
888        assert!(m.abi.is_none());
889    }
890
891    #[test]
892    fn v1_minimal_bun() {
893        let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
894        assert_eq!(m.app_type, AppType::Bun);
895        assert_eq!(m.effective_entrypoint(), "dist/index.js");
896    }
897
898    #[test]
899    fn v1_no_manifest_version_field_defaults_to_1() {
900        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
901        assert_eq!(m.manifest_version, 1);
902    }
903
904    #[test]
905    fn v1_all_optional_fields_missing() {
906        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
907        assert!(!m.critical);
908        assert!(m.auto_start);
909        assert!(!m.has_ui);
910        assert_eq!(m.ui_path, "dist");
911        assert!(m.permissions.is_empty());
912        assert!(m.optional_permissions.is_empty());
913        // #1556: governor/subscribes absent → today's implicit behavior
914        // (no opt-out, no min-idle override, no declared subscriptions).
915        assert!(m.governor.is_none());
916        assert!(m.subscribes.is_empty());
917    }
918
919    #[test]
920    fn v1_with_permissions_and_provides() {
921        let json = r#"{
922            "name": "example",
923            "version": "1.0.0",
924            "app_type": "bun",
925            "permissions": ["core.storage.kv"],
926            "optional_permissions": ["core.notifications.create"],
927            "provides": {
928                "core.example.run": { "description": "Run example job" }
929            }
930        }"#;
931        let m = parse_ok(json);
932        assert_eq!(m.permissions, vec!["core.storage.kv"]);
933        assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
934        assert!(m.provides.contains_key("core.example.run"));
935    }
936
937    // ── v2 manifests ──────────────────────────────────────────────────────────
938
939    #[test]
940    fn v2_minimal_native() {
941        let json = r#"{
942            "manifest_version": 2,
943            "name": "cron",
944            "version": "1.0.0",
945            "app_type": "native",
946            "abi": "v1",
947            "entrypoint": "app.so",
948            "hot_reload": "experimental"
949        }"#;
950        let m = parse_ok(json);
951        assert_eq!(m.manifest_version, 2);
952        assert_eq!(m.abi, Some(AbiVersion::V1));
953        assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
954        assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
955    }
956
957    #[test]
958    fn v2_minimal_bun_with_capabilities() {
959        let json = r#"{
960            "manifest_version": 2,
961            "name": "example-fullstack",
962            "version": "1.0.0",
963            "app_type": "bun",
964            "abi": "v1",
965            "entrypoint": "dist/index.js",
966            "hot_reload": "supported",
967            "has_ui": true,
968            "ui_path": "ui/dist",
969            "capabilities": {
970                "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
971                "provides": []
972            },
973            "governor": { "terminable": false, "min_idle_secs": 300 },
974            "subscribes": ["core.chat.message.received"]
975        }"#;
976        let m = parse_ok(json);
977        assert_eq!(m.manifest_version, 2);
978        assert_eq!(m.capabilities.requires.len(), 2);
979        // #1556: governor/subscribes present → parsed through verbatim.
980        let governor = m.governor.expect("governor block should parse");
981        assert_eq!(governor.terminable, Some(false));
982        assert_eq!(governor.min_idle_secs, Some(300));
983        assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
984    }
985
986    #[test]
987    fn stage_contract_fixture_parses_with_normalized_requirements() {
988        let json = include_str!(
989            "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
990        );
991        let m = parse_ok(json);
992        assert!(m.has_ui);
993        assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
994        let ui = m.ui.expect("fixture should declare ui");
995        assert_eq!(ui.kind, AppUiKind::Stage);
996        assert_eq!(ui.entry, "ui/main.js");
997        assert_eq!(ui.nav.unwrap().order, 10);
998    }
999
1000    #[test]
1001    fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1002        let m = parse_ok(
1003            r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1004        );
1005        assert!(m.has_ui);
1006        assert_eq!(m.ui_path, "ui/dist");
1007        assert!(m.ui.is_none());
1008    }
1009
1010    #[test]
1011    fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1012        let accepted = parse_ok(
1013            r#"{
1014                "name":"aliases","version":"1.0.0","app_type":"bun",
1015                "requires":["core.chat.read","core.chat.read","core.chat.send"],
1016                "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1017            }"#,
1018        );
1019        assert_eq!(
1020            accepted.resolved_requires().unwrap(),
1021            vec!["core.chat.read", "core.chat.send"]
1022        );
1023
1024        let err = parse_err(
1025            r#"{
1026                "name":"aliases","version":"1.0.0","app_type":"bun",
1027                "requires":["core.chat.read"],
1028                "capabilities":{"requires":["core.wallet.pay"]}
1029            }"#,
1030        );
1031        assert!(err.contains("conflicts"), "unexpected error: {err}");
1032    }
1033
1034    #[test]
1035    fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1036        let base = |ui: &str| {
1037            format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1038        };
1039        let widget = base(
1040            r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1041        );
1042        assert_eq!(parse_ok(&widget).ui.expect("widget ui").kind, AppUiKind::Widget);
1043        let widget_nav = base(
1044            r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1045        );
1046        assert!(parse_err(&widget_nav).contains("must omit nav"));
1047        let api = base(
1048            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1049        );
1050        assert!(parse_err(&api).contains("ui_api"));
1051        let path = base(
1052            r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1053        );
1054        assert!(parse_err(&path).contains("entry"));
1055    }
1056
1057    #[test]
1058    fn stage_requires_integrity_for_entry_and_icon() {
1059        let missing_entry = r#"{
1060            "name":"stage","version":"1.0.0","app_type":"bun",
1061            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1062        }"#;
1063        assert!(parse_err(missing_entry).contains("entry"));
1064        let missing_icon = r#"{
1065            "name":"stage","version":"1.0.0","app_type":"bun",
1066            "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1067            "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1068        }"#;
1069        assert!(parse_err(missing_icon).contains("icon"));
1070        let uppercase = r#"{
1071            "name":"stage","version":"1.0.0","app_type":"bun",
1072            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1073            "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1074        }"#;
1075        assert!(parse_err(uppercase).contains("lowercase"));
1076    }
1077
1078    #[test]
1079    fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1080        let manifest = |composes: &str| {
1081            format!(
1082                r#"{{
1083                    "name":"stage","version":"1.0.0","app_type":"bun",
1084                    "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1085                    "composes":{composes},
1086                    "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1087                }}"#
1088            )
1089        };
1090        assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1091        assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1092        assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1093    }
1094
1095    #[test]
1096    fn v2_missing_abi_is_error() {
1097        let json = r#"{
1098            "manifest_version": 2,
1099            "name": "example",
1100            "version": "1.0.0",
1101            "app_type": "bun"
1102        }"#;
1103        let err = parse_err(json);
1104        assert!(err.contains("abi"), "expected abi error, got: {}", err);
1105    }
1106
1107    // ── Publisher-prefixed name (FR-019) ──────────────────────────────────────
1108
1109    #[test]
1110    fn publisher_prefixed_name_accepted() {
1111        let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1112        assert_eq!(m.name, "alice/weather");
1113    }
1114
1115    #[test]
1116    fn double_slash_name_rejected() {
1117        let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1118        assert!(!err.is_empty());
1119    }
1120
1121    // ── Malformed names ───────────────────────────────────────────────────────
1122
1123    #[test]
1124    fn name_starting_with_digit_rejected() {
1125        let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1126        assert!(!err.is_empty());
1127    }
1128
1129    #[test]
1130    fn name_with_uppercase_rejected() {
1131        let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1132        assert!(!err.is_empty());
1133    }
1134
1135    #[test]
1136    fn empty_name_rejected() {
1137        let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1138        assert!(!err.is_empty());
1139    }
1140
1141    // ── Path-safety (SEC-H3) ─────────────────────────────────────────────────
1142
1143    #[test]
1144    fn path_traversal_double_dot_rejected() {
1145        let json = r#"{
1146            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1147            "app_type": "bun", "abi": "v1",
1148            "entrypoint": "../etc/passwd"
1149        }"#;
1150        let err = parse_err(json);
1151        assert!(err.contains(".."), "expected traversal error, got: {}", err);
1152    }
1153
1154    #[test]
1155    fn path_traversal_encoded_dot_not_decoded() {
1156        // The regex rejects '%' so encoded traversal fails at char check
1157        let json = r#"{
1158            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1159            "app_type": "bun", "abi": "v1",
1160            "entrypoint": "foo/../bar"
1161        }"#;
1162        let err = parse_err(json);
1163        assert!(!err.is_empty(), "should have failed: {}", err);
1164    }
1165
1166    #[test]
1167    fn absolute_path_rejected() {
1168        let json = r#"{
1169            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1170            "app_type": "bun", "abi": "v1",
1171            "entrypoint": "/usr/bin/sh"
1172        }"#;
1173        let err = parse_err(json);
1174        assert!(
1175            err.contains("absolute"),
1176            "expected absolute error, got: {}",
1177            err
1178        );
1179    }
1180
1181    #[test]
1182    fn shell_metachar_in_path_rejected() {
1183        let json = r#"{
1184            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1185            "app_type": "bun", "abi": "v1",
1186            "entrypoint": "dist/index.js;rm -rf /"
1187        }"#;
1188        let err = parse_err(json);
1189        assert!(!err.is_empty());
1190    }
1191
1192    #[test]
1193    fn valid_nested_path_accepted() {
1194        let json = r#"{
1195            "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1196            "app_type": "bun", "abi": "v1",
1197            "entrypoint": "dist/index.js",
1198            "ui_path": "ui/dist"
1199        }"#;
1200        parse_ok(json);
1201    }
1202
1203    // ── Homepage scheme ───────────────────────────────────────────────────────
1204
1205    #[test]
1206    fn homepage_https_accepted() {
1207        let json = r#"{
1208            "name": "my-app", "version": "1.0.0", "app_type": "bun",
1209            "homepage": "https://example.com"
1210        }"#;
1211        parse_ok(json);
1212    }
1213
1214    #[test]
1215    fn homepage_javascript_scheme_rejected() {
1216        let json = r#"{
1217            "name": "my-app", "version": "1.0.0", "app_type": "bun",
1218            "homepage": "javascript:alert(1)"
1219        }"#;
1220        let err = parse_err(json);
1221        assert!(
1222            err.contains("scheme"),
1223            "expected scheme error, got: {}",
1224            err
1225        );
1226    }
1227
1228    #[test]
1229    fn homepage_file_scheme_rejected() {
1230        let json = r#"{
1231            "name": "my-app", "version": "1.0.0", "app_type": "bun",
1232            "homepage": "file:///etc/passwd"
1233        }"#;
1234        let err = parse_err(json);
1235        assert!(!err.is_empty());
1236    }
1237
1238    // ── Effective defaults ────────────────────────────────────────────────────
1239
1240    #[test]
1241    fn effective_entrypoint_native_default() {
1242        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1243        assert_eq!(m.effective_entrypoint(), "app.so");
1244    }
1245
1246    #[test]
1247    fn effective_entrypoint_bun_default() {
1248        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1249        assert_eq!(m.effective_entrypoint(), "dist/index.js");
1250    }
1251
1252    #[test]
1253    fn effective_hot_reload_native_default_is_experimental() {
1254        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1255        assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
1256    }
1257
1258    #[test]
1259    fn effective_hot_reload_bun_default_is_supported() {
1260        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1261        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
1262    }
1263
1264    #[test]
1265    fn hot_reload_unsupported_explicit() {
1266        let json = r#"{
1267            "manifest_version": 2, "name": "myapp", "version": "1.0.0",
1268            "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
1269        }"#;
1270        let m = parse_ok(json);
1271        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
1272    }
1273
1274    // ── validate_manifest_path unit tests ─────────────────────────────────────
1275
1276    #[test]
1277    fn validate_path_simple_valid() {
1278        assert!(validate_manifest_path("dist/index.js").is_ok());
1279        assert!(validate_manifest_path("app.so").is_ok());
1280        assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
1281        assert!(validate_manifest_path("build_output/main").is_ok());
1282    }
1283
1284    #[test]
1285    fn validate_path_empty_rejected() {
1286        assert!(validate_manifest_path("").is_err());
1287    }
1288
1289    #[test]
1290    fn validate_path_absolute_rejected() {
1291        assert!(validate_manifest_path("/usr/bin/sh").is_err());
1292    }
1293
1294    #[test]
1295    fn validate_path_double_dot_segment_rejected() {
1296        assert!(validate_manifest_path("foo/../bar").is_err());
1297        assert!(validate_manifest_path("../etc/passwd").is_err());
1298    }
1299
1300    #[test]
1301    fn validate_path_leading_dot_rejected() {
1302        assert!(validate_manifest_path(".hidden").is_err());
1303    }
1304
1305    #[test]
1306    fn validate_path_null_byte_rejected() {
1307        // null byte is non-ASCII, rejected by char check
1308        let path = "foo\0bar";
1309        assert!(validate_manifest_path(path).is_err());
1310    }
1311
1312    #[test]
1313    fn standalone_socket_path_development_override_is_explicit_and_pure() {
1314        let path = std::path::Path::new("/tmp/node-app/example.sock");
1315        assert!(validate_standalone_socket_path(path).is_err());
1316        assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
1317        assert!(validate_standalone_socket_path_with_policy(
1318            std::path::Path::new("/tmp/node-app/../escape.sock"),
1319            true,
1320        )
1321        .is_err());
1322    }
1323
1324    // ── ManifestCapabilities defaults ─────────────────────────────────────────
1325
1326    #[test]
1327    fn manifest_capabilities_defaults_to_empty() {
1328        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1329        assert!(m.capabilities.requires.is_empty());
1330        assert!(m.capabilities.provides.is_empty());
1331    }
1332
1333    // ── resolved_capability_provides (T28 standalone-registration shaping) ────
1334
1335    #[test]
1336    fn resolved_capability_provides_v1_only() {
1337        let json = r#"{
1338            "name": "example", "version": "1.0.0", "app_type": "bun",
1339            "provides": { "core.example.run": { "description": "Run example job" } }
1340        }"#;
1341        let m = parse_ok(json);
1342        let out = m.resolved_capability_provides();
1343        assert_eq!(out.len(), 1);
1344        assert_eq!(
1345            out.get("core.example.run").unwrap().description,
1346            "Run example job"
1347        );
1348    }
1349
1350    #[test]
1351    fn resolved_capability_provides_v2_names_get_blank_declaration() {
1352        let json = r#"{
1353            "manifest_version": 2, "name": "example", "version": "1.0.0",
1354            "app_type": "bun", "abi": "v1",
1355            "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
1356        }"#;
1357        let m = parse_ok(json);
1358        let out = m.resolved_capability_provides();
1359        assert_eq!(out.len(), 2);
1360        assert_eq!(out.get("core.example.run").unwrap().description, "");
1361        assert!(out.get("core.example.run").unwrap().schema.is_none());
1362        // Only the part before the first ':' is used as the name.
1363        assert!(out.contains_key("core.example.other"));
1364        assert!(!out.contains_key("core.example.other:extra"));
1365    }
1366
1367    #[test]
1368    fn resolved_capability_provides_v1_wins_on_conflict() {
1369        let json = r#"{
1370            "manifest_version": 2, "name": "example", "version": "1.0.0",
1371            "app_type": "bun", "abi": "v1",
1372            "provides": { "core.example.run": { "description": "v1 wins" } },
1373            "capabilities": { "requires": [], "provides": ["core.example.run"] }
1374        }"#;
1375        let m = parse_ok(json);
1376        let out = m.resolved_capability_provides();
1377        assert_eq!(out.len(), 1);
1378        assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
1379    }
1380
1381    #[test]
1382    fn resolved_capability_provides_blank_v2_name_skipped() {
1383        let json = r#"{
1384            "manifest_version": 2, "name": "example", "version": "1.0.0",
1385            "app_type": "bun", "abi": "v1",
1386            "capabilities": { "requires": [], "provides": ["  ", "core.example.run"] }
1387        }"#;
1388        let m = parse_ok(json);
1389        let out = m.resolved_capability_provides();
1390        assert_eq!(out.len(), 1);
1391        assert!(out.contains_key("core.example.run"));
1392    }
1393
1394    #[test]
1395    fn resolved_capability_provides_empty_manifest_yields_empty_map() {
1396        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
1397        assert!(m.resolved_capability_provides().is_empty());
1398    }
1399
1400    // ── ABI version ───────────────────────────────────────────────────────────
1401
1402    #[test]
1403    fn abi_v1_is_supported() {
1404        assert!(AbiVersion::V1.is_supported());
1405    }
1406
1407    // ── AppTier display ───────────────────────────────────────────────────────
1408
1409    #[test]
1410    fn app_tier_display() {
1411        assert_eq!(AppTier::FirstParty.to_string(), "first_party");
1412        assert_eq!(AppTier::Optional.to_string(), "optional");
1413        assert_eq!(AppTier::Development.to_string(), "development");
1414    }
1415
1416    #[test]
1417    fn app_tier_serde_roundtrip() {
1418        // Wire format must stay snake_case for the existing API contract.
1419        for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
1420            let json = serde_json::to_string(&tier).unwrap();
1421            let back: AppTier = serde_json::from_str(&json).unwrap();
1422            assert_eq!(
1423                tier, back,
1424                "roundtrip failed for {:?}: serialized as {}",
1425                tier, json
1426            );
1427        }
1428        assert_eq!(
1429            serde_json::to_string(&AppTier::Development).unwrap(),
1430            "\"development\""
1431        );
1432    }
1433
1434    // ── AppType display ───────────────────────────────────────────────────────
1435
1436    #[test]
1437    fn app_type_display() {
1438        assert_eq!(AppType::Native.to_string(), "native");
1439        assert_eq!(AppType::Bun.to_string(), "bun");
1440        assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
1441    }
1442
1443    #[test]
1444    fn platform_runtime_is_a_supported_packaging_type() {
1445        let manifest: AppManifest = serde_json::from_value(serde_json::json!({
1446            "manifest_version": 2,
1447            "abi": "v1",
1448            "name": "bun-runtime",
1449            "version": "1.0.0",
1450            "app_type": "platform-runtime",
1451            "entrypoint": "bun"
1452        }))
1453        .expect("platform runtime manifest should parse");
1454
1455        assert_eq!(manifest.app_type, AppType::PlatformRuntime);
1456        assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
1457    }
1458}