xbp 10.28.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SystemdUnitSpec {
    pub slug: String,
    pub description: String,
    pub working_dir: PathBuf,
    pub start_command: String,
    pub unit_after: Vec<String>,
    pub unit_wants: Vec<String>,
    pub environment: BTreeMap<String, String>,
    pub environment_files: Vec<String>,
    pub config_paths: Vec<String>,
    pub read_write_paths: Vec<String>,
    pub runtime_directories: Vec<String>,
    pub state_directories: Vec<String>,
    pub service_directives: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedUnit {
    pub filename: String,
    pub content: String,
    pub written_path: Option<PathBuf>,
    pub write_error: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnitWriteError {
    PermissionDenied(String),
    Other(String),
}

pub fn build_api_unit_spec(exe_dir: &Path, exe: &Path, port: u16) -> SystemdUnitSpec {
    let mut environment = BTreeMap::new();
    environment.insert("XBP_API_BIND".to_string(), "127.0.0.1".to_string());
    environment.insert("PORT_XBP_API".to_string(), port.to_string());

    SystemdUnitSpec {
        slug: "xbp-api".to_string(),
        description: "XBP API Server".to_string(),
        working_dir: exe_dir.to_path_buf(),
        start_command: exe.display().to_string(),
        unit_after: vec!["network.target".to_string()],
        unit_wants: vec!["network-online.target".to_string()],
        environment,
        environment_files: vec!["/etc/default/xbp".to_string()],
        config_paths: vec![],
        read_write_paths: vec![
            "/var/log".to_string(),
            "/etc/nginx".to_string(),
            "/etc/systemd/system".to_string(),
            "/run".to_string(),
        ],
        runtime_directories: vec!["xbp".to_string()],
        state_directories: vec!["xbp".to_string()],
        service_directives: vec![
            "NoNewPrivileges=yes".to_string(),
            "PrivateTmp=yes".to_string(),
            "ProtectSystem=full".to_string(),
            "ProtectHome=read-only".to_string(),
            "CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_DAC_OVERRIDE CAP_CHOWN CAP_SETUID CAP_SETGID"
                .to_string(),
        ],
    }
}

pub fn unit_file_name(slug: &str) -> String {
    format!("{}.service", slug)
}

pub fn render_unit(spec: &SystemdUnitSpec) -> String {
    let mut lines = vec![
        "[Unit]".to_string(),
        format!("Description={}", spec.description),
    ];

    for after in &spec.unit_after {
        lines.push(format!("After={}", after));
    }

    for wants in &spec.unit_wants {
        lines.push(format!("Wants={}", wants));
    }

    lines.extend([
        String::new(),
        "[Service]".to_string(),
        "Type=simple".to_string(),
        format!("WorkingDirectory={}", spec.working_dir.display()),
    ]);

    for file in &spec.environment_files {
        lines.push(format!("EnvironmentFile=-{}", file));
    }

    for path in &spec.config_paths {
        lines.push(format!("ReadOnlyPaths={}", path));
    }

    for path in &spec.read_write_paths {
        lines.push(format!("ReadWritePaths={}", path));
    }

    for directory in &spec.runtime_directories {
        lines.push(format!("RuntimeDirectory={}", directory));
    }

    for directory in &spec.state_directories {
        lines.push(format!("StateDirectory={}", directory));
    }

    for (key, value) in &spec.environment {
        lines.push(format!(
            "Environment=\"{}={}\"",
            key,
            escape_environment(value)
        ));
    }

    lines.extend([
        format!("ExecStart={}", spec.start_command),
        "Restart=always".to_string(),
        "RestartSec=10".to_string(),
    ]);

    for directive in &spec.service_directives {
        lines.push(directive.clone());
    }

    lines.extend([
        String::new(),
        "[Install]".to_string(),
        "WantedBy=multi-user.target".to_string(),
    ]);

    lines.join("\n")
}

pub fn escape_environment(value: &str) -> String {
    value.replace('\\', "\\\\").replace('"', "\\\"")
}

pub fn render_unit_and_store(
    unit: &SystemdUnitSpec,
    directory: &Path,
) -> Result<RenderedUnit, String> {
    let content = render_unit(unit);
    let filename = unit_file_name(&unit.slug);
    match write_unit_file(&filename, directory, &content) {
        Ok(path) => Ok(RenderedUnit {
            filename,
            content,
            written_path: Some(path),
            write_error: None,
        }),
        Err(UnitWriteError::PermissionDenied(err)) => Ok(RenderedUnit {
            filename,
            content,
            written_path: None,
            write_error: Some(err),
        }),
        Err(UnitWriteError::Other(err)) => Err(err),
    }
}

pub fn write_unit_file(
    filename: &str,
    directory: &Path,
    content: &str,
) -> Result<PathBuf, UnitWriteError> {
    let path = directory.join(filename);
    fs::write(&path, content).map_err(|e| {
        let message = format!("Failed to write {}: {}", path.display(), e);
        if e.kind() == ErrorKind::PermissionDenied {
            UnitWriteError::PermissionDenied(message)
        } else {
            UnitWriteError::Other(message)
        }
    })?;
    Ok(path)
}

pub fn create_install_script(
    project_root: &Path,
    output_dir: &Path,
    units: &[RenderedUnit],
) -> Result<PathBuf, String> {
    let script_dir = project_root.join(".xbp");
    fs::create_dir_all(&script_dir).map_err(|e| {
        format!(
            "Failed to prepare script directory {}: {}",
            script_dir.display(),
            e
        )
    })?;

    let script_path = script_dir.join("install-systemd-units.sh");
    let mut script_content = String::from("#!/bin/sh\nset -euo pipefail\n\n");
    for unit in units {
        let target = output_dir.join(&unit.filename);
        script_content.push_str(&format!(
            "sudo tee {} <<'EOF'\n{}\nEOF\n\n",
            target.display(),
            unit.content
        ));
    }
    script_content.push_str("sudo systemctl daemon-reload\n");

    fs::write(&script_path, script_content)
        .map_err(|e| format!("Failed to write {}: {}", script_path.display(), e))?;

    make_executable(&script_path)?;
    Ok(script_path)
}

pub fn make_executable(path: &Path) -> Result<(), String> {
    #[cfg(unix)]
    {
        let mut permissions = fs::metadata(path)
            .map_err(|e| format!("Failed to read metadata for {}: {}", path.display(), e))?
            .permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(path, permissions)
            .map_err(|e| format!("Failed to set permissions on {}: {}", path.display(), e))?;
    }

    #[cfg(not(unix))]
    {
        let _ = path;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;

    #[test]
    fn render_unit_includes_environment_and_paths() {
        let mut environment = BTreeMap::new();
        environment.insert("A".to_string(), "1".to_string());
        let spec = SystemdUnitSpec {
            slug: "demo".to_string(),
            description: "Demo".to_string(),
            working_dir: PathBuf::from("/srv/demo"),
            start_command: "/bin/demo".to_string(),
            unit_after: vec!["network.target".to_string()],
            unit_wants: vec![],
            environment,
            environment_files: vec!["/etc/default/demo".to_string()],
            config_paths: vec!["/etc/demo/config.yaml".to_string()],
            read_write_paths: vec!["/var/log/demo".to_string()],
            runtime_directories: vec!["demo".to_string()],
            state_directories: vec!["demo".to_string()],
            service_directives: vec!["ProtectSystem=full".to_string()],
        };

        let rendered = render_unit(&spec);
        assert!(rendered.contains("EnvironmentFile=-/etc/default/demo"));
        assert!(rendered.contains("ReadOnlyPaths=/etc/demo/config.yaml"));
        assert!(rendered.contains("ReadWritePaths=/var/log/demo"));
        assert!(rendered.contains("RuntimeDirectory=demo"));
        assert!(rendered.contains("StateDirectory=demo"));
    }

    #[test]
    fn api_unit_spec_keeps_hardening_and_runtime_paths() {
        let spec = build_api_unit_spec(Path::new("/srv/bin"), Path::new("/srv/bin/xbp"), 8080);
        let rendered = render_unit(&spec);
        assert!(rendered.contains("EnvironmentFile=-/etc/default/xbp"));
        assert!(rendered.contains("NoNewPrivileges=yes"));
        assert!(rendered.contains("PrivateTmp=yes"));
        assert!(rendered.contains("ProtectSystem=full"));
        assert!(rendered.contains("ProtectHome=read-only"));
        assert!(rendered.contains("ReadWritePaths=/etc/systemd/system"));
        assert!(rendered.contains("StateDirectory=xbp"));
    }
}