podbox-cli 0.7.1

Declarative Podman-native container environment manager. Define an environment as a TOML file and let systemd own its lifecycle.
Documentation
//! Host-side export of container binaries and `.desktop` files: bubblewrap/
//! shim export, symlinking, and per-container uninstall. One cohesive export
//! flow (`.desktop` discovery/rewrite lives in the [`desktop`](crate::export::desktop)
//! submodule); stays above ~300 LOC as a single cohesive concern (documented
//! exemption, per MODULARIZATION_GUIDE).

use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;

use anyhow::Result;

use crate::error::PodboxError;

/// Standard XDG application directories searched inside the container,
/// in priority order.  Many apps install to `~/.local/share/applications/`
mod desktop;

pub(crate) use desktop::{
    copy_icon_from_container, extract_icon_name, find_desktop_file, is_valid_app_name,
    rewrite_desktop_file,
};

pub fn export_app(container_name: &str, app: &str) -> Result<()> {
    if !is_valid_app_name(app) {
        return Err(PodboxError::ExportFailed {
            details: format!("invalid app name: '{app}'"),
        }
        .into());
    }

    // 1. Locate .desktop file in container, searching XDG directories.
    let (container_path, desktop_content) = find_desktop_file(container_name, app)?;

    // 2. Rewrite Name= and Exec= lines
    let rewritten = rewrite_desktop_file(&desktop_content, container_name, app);

    // 3. Write host .desktop file
    let apps_dir = dirs::data_dir()
        .unwrap_or_else(|| {
            dirs::home_dir()
                .map(|h| h.join(".local/share"))
                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
        })
        .join("applications");
    std::fs::create_dir_all(&apps_dir)?;

    let host_path = apps_dir.join(format!("podbox-{container_name}-{app}.desktop"));
    std::fs::write(&host_path, rewritten)?;

    // 4. Try to extract icon
    if let Some(icon_name) = extract_icon_name(&desktop_content) {
        if let Err(e) = copy_icon_from_container(container_name, &icon_name, container_name) {
            eprintln!("Warning: failed to copy icon '{icon_name}': {e}");
        }
    }

    // 5. Update desktop database
    if let Err(e) = std::process::Command::new("update-desktop-database")
        .arg(&apps_dir)
        .output()
        .map(|_| ())
    {
        eprintln!("Warning: update-desktop-database failed: {e}");
    }

    println!(
        "Exported app '{}'.desktop (from {}) -> {}",
        app,
        container_path,
        host_path.display()
    );
    Ok(())
}

/// Find a `.desktop` file in the container by searching XDG dirs,
/// falling back to user-installed locations.
pub fn export_bin(container_name: &str, bin: &str) -> Result<()> {
    let bin_dir = dirs::home_dir()
        .map(|h| h.join(".local/bin"))
        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));
    std::fs::create_dir_all(&bin_dir)?;

    let exe = std::env::current_exe()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|_| "podbox".to_string());
    let shim = format!(
        "#!/bin/sh\nexec {} --container \"{}\" exec \"{}\" \"$@\"\n",
        exe,
        container_name.replace('"', "\\\""),
        bin.replace('"', "\\\"")
    );

    let shim_path = bin_dir.join(bin);
    std::fs::write(&shim_path, shim)?;
    #[allow(clippy::print_literal)]
    {
        let _ = std::fs::set_permissions(&shim_path, std::fs::Permissions::from_mode(0o755));
    }

    println!("Exported bin shim '{}' -> {}", bin, shim_path.display());
    Ok(())
}

/// Remove all exports for a container.
pub fn unexport_all(container_name: &str) -> Result<()> {
    let apps_dir = dirs::data_dir()
        .unwrap_or_else(|| {
            dirs::home_dir()
                .map(|h| h.join(".local/share"))
                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
        })
        .join("applications");
    let prefix = format!("podbox-{container_name}");

    if let Ok(entries) = std::fs::read_dir(&apps_dir) {
        for entry in entries.flatten() {
            let name = entry.file_name();
            if name.to_string_lossy().starts_with(&prefix) {
                let _ = std::fs::remove_file(entry.path());
            }
        }
    }

    let icons_dir = dirs::data_dir()
        .unwrap_or_else(|| {
            dirs::home_dir()
                .map(|h| h.join(".local/share"))
                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
        })
        .join(format!("icons/podbox/{container_name}"));
    // Also remove legacy icons path
    let old_icons_dir = dirs::data_dir()
        .unwrap_or_else(|| {
            dirs::home_dir()
                .map(|h| h.join(".local/share"))
                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
        })
        .join(format!("icons/podmgr/{container_name}"));
    let _ = std::fs::remove_dir_all(&icons_dir);
    if old_icons_dir.exists() {
        let _ = std::fs::remove_dir_all(&old_icons_dir);
    }

    let bin_dir = dirs::home_dir()
        .map(|h| h.join(".local/bin"))
        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));

    // Remove shims that reference this container
    let marker = format!("--container \"{container_name}\"");
    if let Ok(entries) = std::fs::read_dir(&bin_dir) {
        for entry in entries.flatten() {
            if let Ok(mut file) = std::fs::File::open(entry.path()) {
                use std::io::Read;
                let mut chunk = vec![0u8; 4096];
                if let Ok(bytes_read) = file.read(&mut chunk) {
                    let content = String::from_utf8_lossy(&chunk[..bytes_read]);
                    if content.contains(&marker) {
                        let _ = std::fs::remove_file(entry.path());
                    }
                }
            }
        }
    }

    println!("Unexported all apps and bins for '{container_name}'.");
    Ok(())
}

/// List the .desktop apps and bin shims exported to the host for a container.
pub fn list_exports(container_name: &str) -> Result<()> {
    let apps_dir = dirs::data_dir()
        .unwrap_or_else(|| {
            dirs::home_dir()
                .map(|h| h.join(".local/share"))
                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
        })
        .join("applications");
    let prefix = format!("podbox-{container_name}-");
    let suffix = ".desktop";

    let mut apps: Vec<String> = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&apps_dir) {
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().into_owned();
            if name.starts_with(&prefix) && name.ends_with(suffix) {
                apps.push(name[prefix.len()..name.len() - suffix.len()].to_string());
            }
        }
    }
    apps.sort();

    let bin_dir = dirs::home_dir()
        .map(|h| h.join(".local/bin"))
        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));
    let marker = format!("--container \"{container_name}\"");
    let mut bins: Vec<String> = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&bin_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if let Ok(mut file) = std::fs::File::open(&path) {
                use std::io::Read;
                let mut chunk = vec![0u8; 4096];
                if let Ok(bytes_read) = file.read(&mut chunk) {
                    let content = String::from_utf8_lossy(&chunk[..bytes_read]);
                    if content.contains(&marker) {
                        bins.push(entry.file_name().to_string_lossy().into_owned());
                    }
                }
            }
        }
    }
    bins.sort();

    if apps.is_empty() && bins.is_empty() {
        println!("No exports for '{container_name}'.");
        return Ok(());
    }

    if !apps.is_empty() {
        println!("Apps:");
        for app in &apps {
            println!("  {app}");
        }
    }
    if !bins.is_empty() {
        if !apps.is_empty() {
            println!();
        }
        println!("Bins:");
        for bin in &bins {
            println!("  {bin}");
        }
    }
    Ok(())
}

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

    #[test]
    fn valid_app_names() {
        for name in &["firefox", "Firefox", "code-oss", "code_oss", "v1.2.3", "a"] {
            assert!(is_valid_app_name(name), "expected '{name}' to be valid");
        }
    }

    #[test]
    fn reject_empty_name() {
        assert!(!is_valid_app_name(""));
    }

    #[test]
    fn reject_shell_metacharacters() {
        for bad in &[
            "foo;rm", "foo\"bar", "foo`bar", "foo$bar", "foo|bar", "foo>bar", "foo<bar", "foo&bar",
            "foo\nbar", "../foo", "foo/bar", "foo bar", "foo\\bar", "foo'bar",
        ] {
            assert!(!is_valid_app_name(bad), "expected '{bad}' to be rejected");
        }
    }

    #[test]
    fn export_app_rejects_invalid_name() {
        let result = export_app("test-container", "foo;rm");
        assert!(result.is_err());
        let err = format!("{}", result.unwrap_err());
        assert!(
            err.contains("foo;rm") || err.contains("invalid"),
            "error should mention the name: {err}"
        );
    }

    #[test]
    fn find_desktop_file_rejects_invalid_name() {
        let result = find_desktop_file("test-container", "foo`whoami`");
        assert!(result.is_err());
        let err = format!("{}", result.unwrap_err());
        assert!(
            err.contains("foo`whoami`") || err.contains("invalid"),
            "error should mention the name: {err}"
        );
    }

    #[test]
    fn rewrite_desktop_file_exec_has_no_leading_whitespace() {
        let input = "[Desktop Entry]\nName=Firefox\nExec=/usr/bin/firefox %u\nIcon=firefox\n";
        let out = rewrite_desktop_file(input, "box", "firefox");
        let exec = out
            .lines()
            .find(|l| l.starts_with("Exec="))
            .expect("rewritten Exec= line");
        assert!(
            exec.starts_with("Exec="),
            "Exec key must start at column 0, got: {exec:?}"
        );
        assert!(exec.contains("--container \"box\" exec -- /usr/bin/firefox %u"));
    }

    #[test]
    fn rewrite_desktop_file_appends_container_to_name() {
        let input = "[Desktop Entry]\nName=Firefox\nExec=/usr/bin/firefox\n";
        let out = rewrite_desktop_file(input, "box", "firefox");
        assert!(out.contains("Name=Firefox (box)"));
    }

    #[test]
    fn list_exports_lists_apps_and_bins() {
        let apps_dir = dirs::data_dir().expect("data dir").join("applications");
        std::fs::create_dir_all(&apps_dir).expect("create apps dir");
        let app_path = apps_dir.join("podbox-box-firefox.desktop");
        std::fs::write(&app_path, "[Desktop Entry]\nName=Firefox (box)\n").unwrap();

        let bin_dir = dirs::home_dir().expect("home dir").join(".local/bin");
        std::fs::create_dir_all(&bin_dir).expect("create bin dir");
        let shim_path = bin_dir.join("firefox");
        std::fs::write(
            &shim_path,
            "#!/bin/sh\nexec /usr/bin/podbox --container \"box\" exec \"firefox\" \"$@\"\n",
        )
        .unwrap();

        let apps_dir = dirs::data_dir().expect("data dir").join("applications");
        let prefix = format!("podbox-{}-", "box");
        let suffix = ".desktop";
        let mut apps: Vec<String> = std::fs::read_dir(&apps_dir)
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .filter(|n| n.starts_with(&prefix) && n.ends_with(suffix))
            .map(|n| n[prefix.len()..n.len() - suffix.len()].to_string())
            .collect();
        apps.sort();

        let marker = format!("--container \"{}\"", "box");
        let mut bins: Vec<String> = std::fs::read_dir(&bin_dir)
            .unwrap()
            .flatten()
            .filter(|e| {
                std::fs::read_to_string(e.path())
                    .map(|c| c.contains(&marker))
                    .unwrap_or(false)
            })
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .collect();
        bins.sort();

        assert_eq!(apps, vec!["firefox".to_string()]);
        assert_eq!(bins, vec!["firefox".to_string()]);

        let _ = std::fs::remove_file(&app_path);
        let _ = std::fs::remove_file(&shim_path);
    }
}