use anyhow::{anyhow, bail, Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppManifest {
#[serde(default = "default_manifest_version")]
pub manifest_version: u8,
pub name: String,
pub version: String,
pub app_type: AppType,
#[serde(default)]
pub abi: Option<String>,
#[serde(default)]
pub entrypoint: Option<String>,
#[serde(default)]
pub hot_reload: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub has_ui: bool,
#[serde(default)]
pub ui_path: Option<String>,
#[serde(default)]
pub capabilities: Option<ManifestCapabilities>,
#[serde(default)]
pub standalone: Option<StandaloneConfig>,
#[serde(default)]
pub tcp: Option<TcpConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneConfig {
pub socket_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TcpConfig {
#[serde(default)]
pub preferred_port: Option<u16>,
}
fn default_manifest_version() -> u8 {
1
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
Native,
Bun,
Standalone,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ManifestCapabilities {
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
}
pub fn parse_manifest(path: &Path) -> Result<AppManifest> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let manifest: AppManifest = serde_json::from_str(&raw)
.with_context(|| format!("parse {} as manifest.json", path.display()))?;
Ok(manifest)
}
pub fn validate_manifest(m: &AppManifest, is_apt_install_target: bool) -> Result<()> {
static NAME_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$").unwrap()
});
if !NAME_RE.is_match(&m.name) {
bail!(
"manifest name '{}' is invalid (expected lowercase + hyphens, optional publisher/ prefix)",
m.name
);
}
if m.name.starts_with("node-app-") {
bail!("manifest name must not start with 'node-app-' (the .deb script adds the prefix)");
}
static SEMVER_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$").unwrap()
});
if !SEMVER_RE.is_match(&m.version) {
bail!("manifest version '{}' is not a valid SemVer", m.version);
}
if m.manifest_version >= 2 {
let abi = m.abi.as_deref().ok_or_else(|| {
anyhow!("manifest_version=2 requires an `abi` field")
})?;
if abi != "v1" {
bail!("unsupported abi '{}'; only 'v1' is recognised by the runtime", abi);
}
}
if let Some(hr) = m.hot_reload.as_deref() {
match hr {
"supported" | "experimental" | "unsupported" => {}
other => bail!(
"hot_reload '{}' is invalid (expected supported|experimental|unsupported)",
other
),
}
}
static PATH_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$").unwrap()
});
let check_path = |label: &str, p: &str| -> Result<()> {
if !PATH_RE.is_match(p) {
bail!("{} path '{}' has illegal characters", label, p);
}
if p.split('/').any(|seg| seg == "..") {
bail!("{} path '{}' contains '..' segment", label, p);
}
if p.starts_with('/') {
bail!("{} path '{}' must be relative", label, p);
}
Ok(())
};
if let Some(ep) = m.entrypoint.as_deref() {
check_path("entrypoint", ep)?;
}
if m.has_ui {
let ui = m.ui_path.as_deref().ok_or_else(|| {
anyhow!("has_ui=true but ui_path is missing")
})?;
check_path("ui_path", ui)?;
}
if let Some(caps) = &m.capabilities {
for c in &caps.requires {
parse_capability_requirement(c)
.with_context(|| format!("invalid capability requirement: '{}'", c))?;
}
for c in &caps.provides {
parse_capability_provides(c)
.with_context(|| format!("invalid capability provides: '{}'", c))?;
}
}
if is_apt_install_target && m.app_type == AppType::Native {
bail!(
"native (cdylib) apps cannot ship at the apt-install path \
(/usr/lib/node/apps/). Native apps must be bundled with `node`. \
Switch app_type to 'bun' or coordinate with the project to add a \
bundled-app slot."
);
}
let provides_count = m
.capabilities
.as_ref()
.map(|c| c.provides.len())
.unwrap_or(0);
match m.app_type {
AppType::Standalone => {
if provides_count > 0 {
let cfg = m.standalone.as_ref().ok_or_else(|| {
anyhow!(
"standalone apps that declare 'provides' require a \
'standalone.socket_path' field"
)
})?;
validate_standalone_socket_path(&cfg.socket_path)
.context("standalone.socket_path invalid")?;
}
}
AppType::Native | AppType::Bun => {
if m.standalone.is_some() {
bail!(
"'standalone' block is only valid when app_type == 'standalone' \
(found app_type='{:?}')",
m.app_type
);
}
}
}
Ok(())
}
fn validate_standalone_socket_path(p: &str) -> Result<()> {
if !p.starts_with('/') {
bail!("socket_path '{}' must be absolute", p);
}
if !p.starts_with("/run/") {
bail!("socket_path '{}' must live under /run/", p);
}
if p.split('/').any(|seg| seg == "..") {
bail!("socket_path '{}' contains '..' segment", p);
}
Ok(())
}
fn parse_capability_requirement(s: &str) -> Result<()> {
let mut parts = s.split(':');
let ns = parts
.next()
.ok_or_else(|| anyhow!("empty capability"))?
.trim();
if ns.is_empty() {
bail!("capability namespace is empty");
}
static NS_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$").unwrap()
});
if !NS_RE.is_match(ns) {
bail!(
"capability namespace '{}' must be dotted lowercase (e.g. core.lightning.payment.send)",
ns
);
}
let mut seen_daily = false;
for part in parts {
if let Some(rest) = part.strip_prefix("max=") {
let (num_unit, period) = rest
.split_once('/')
.ok_or_else(|| anyhow!("max constraint missing /period: '{}'", part))?;
let (num, unit) = num_unit
.strip_suffix("msat")
.map(|n| (n, "msat"))
.or_else(|| num_unit.strip_suffix("sat").map(|n| (n, "sat")))
.ok_or_else(|| anyhow!("max value must end in 'sat' or 'msat': '{}'", num_unit))?;
let _: u64 = num
.parse()
.with_context(|| format!("max value '{}' must be an integer", num))?;
match (unit, period) {
("sat", "day") | ("msat", "day") => {
if seen_daily {
bail!("duplicate max=Nsat/day constraint on '{}'", ns);
}
seen_daily = true;
}
("sat", "tx") | ("msat", "tx") => {}
_ => bail!(
"unsupported constraint period '{}' (expected day or tx)",
period
),
}
} else {
if part.is_empty() {
bail!("empty constraint segment in '{}'", s);
}
}
}
Ok(())
}
fn parse_capability_provides(s: &str) -> Result<()> {
static NS_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$").unwrap()
});
if !NS_RE.is_match(s) {
bail!("capability provides '{}' must be dotted lowercase", s);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn min_v2(name: &str, app_type: AppType) -> AppManifest {
AppManifest {
manifest_version: 2,
name: name.into(),
version: "1.0.0".into(),
app_type,
abi: Some("v1".into()),
entrypoint: Some(match app_type {
AppType::Native => "app.so".into(),
AppType::Bun => "dist/index.js".into(),
AppType::Standalone => "app".into(),
}),
hot_reload: None,
description: None,
has_ui: false,
ui_path: None,
capabilities: None,
standalone: None,
tcp: None,
}
}
#[test]
fn accepts_v1_manifest_without_abi() {
let mut m = min_v2("foo", AppType::Bun);
m.manifest_version = 1;
m.abi = None;
validate_manifest(&m, true).unwrap();
}
#[test]
fn rejects_v2_without_abi() {
let mut m = min_v2("foo", AppType::Bun);
m.abi = None;
let err = validate_manifest(&m, true).unwrap_err();
assert!(err.to_string().contains("requires an `abi`"));
}
#[test]
fn rejects_invalid_name() {
let mut m = min_v2("FOO", AppType::Bun);
let err = validate_manifest(&m, true).unwrap_err();
assert!(err.to_string().contains("invalid"));
m.name = "node-app-bar".into();
let err = validate_manifest(&m, true).unwrap_err();
assert!(err.to_string().contains("must not start with 'node-app-'"));
}
#[test]
fn rejects_traversal_in_entrypoint() {
let mut m = min_v2("foo", AppType::Bun);
m.entrypoint = Some("../etc/passwd".into());
let err = validate_manifest(&m, true).unwrap_err();
assert!(err.to_string().contains(".."));
}
#[test]
fn rejects_native_at_apt_path() {
let m = min_v2("foo", AppType::Native);
let err = validate_manifest(&m, true).unwrap_err();
assert!(err.to_string().contains("native"));
}
#[test]
fn accepts_native_when_bundled() {
let m = min_v2("foo", AppType::Native);
validate_manifest(&m, false).unwrap();
}
#[test]
fn accepts_standalone_at_apt_path() {
let m = min_v2("my-service", AppType::Standalone);
validate_manifest(&m, true).unwrap();
}
#[test]
fn standalone_with_provides_requires_socket_path() {
let mut m = min_v2("led", AppType::Standalone);
m.capabilities = Some(ManifestCapabilities {
requires: vec![],
provides: vec!["led.event.set".into()],
});
let err = validate_manifest(&m, true).unwrap_err();
assert!(err.to_string().contains("socket_path"));
}
#[test]
fn standalone_with_provides_and_socket_path_passes() {
let mut m = min_v2("led", AppType::Standalone);
m.capabilities = Some(ManifestCapabilities {
requires: vec![],
provides: vec!["led.event.set".into()],
});
m.standalone = Some(StandaloneConfig {
socket_path: "/run/node-app-led.sock".into(),
});
validate_manifest(&m, true).unwrap();
}
#[test]
fn rejects_socket_path_outside_run() {
let mut m = min_v2("led", AppType::Standalone);
m.capabilities = Some(ManifestCapabilities {
requires: vec![],
provides: vec!["led.event.set".into()],
});
m.standalone = Some(StandaloneConfig {
socket_path: "/tmp/led.sock".into(),
});
let err = validate_manifest(&m, true).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("/run/"), "expected /run/ in chain: {msg}");
}
#[test]
fn rejects_relative_socket_path() {
let mut m = min_v2("led", AppType::Standalone);
m.capabilities = Some(ManifestCapabilities {
requires: vec![],
provides: vec!["led.event.set".into()],
});
m.standalone = Some(StandaloneConfig {
socket_path: "led.sock".into(),
});
let err = validate_manifest(&m, true).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("absolute"), "expected 'absolute' in chain: {msg}");
}
#[test]
fn rejects_standalone_block_on_non_standalone_app() {
let mut m = min_v2("foo", AppType::Bun);
m.standalone = Some(StandaloneConfig {
socket_path: "/run/foo.sock".into(),
});
let err = validate_manifest(&m, true).unwrap_err();
assert!(err.to_string().contains("standalone"));
}
#[test]
fn standalone_without_provides_skips_socket_check() {
let m = min_v2("consumer", AppType::Standalone);
validate_manifest(&m, true).unwrap();
}
#[test]
fn has_ui_requires_ui_path_for_standalone_bun_fullstack() {
let mut m = min_v2("bun-fs", AppType::Standalone);
m.has_ui = true;
m.ui_path = None;
let err = validate_manifest(&m, true).unwrap_err();
assert!(err.to_string().contains("ui_path"));
m.ui_path = Some("ui-dist".into());
validate_manifest(&m, true).unwrap();
}
#[test]
fn parses_constraint_strings() {
parse_capability_requirement("core.storage.kv").unwrap();
parse_capability_requirement("core.lightning.payment.send:max=500sat/day").unwrap();
parse_capability_requirement("core.lightning.payment.send:max=1000msat/tx").unwrap();
parse_capability_requirement(
"core.lightning.payment.send:max=500sat/day:max=10000msat/tx",
)
.unwrap();
assert!(parse_capability_requirement("CORE.bad").is_err());
assert!(parse_capability_requirement("core.bad:max=foo/day").is_err());
assert!(parse_capability_requirement("core.bad:max=10sat/year").is_err());
assert!(parse_capability_requirement(
"core.bad:max=10sat/day:max=20sat/day"
)
.is_err());
}
}