Skip to main content

podbox/
export.rs

1use std::ffi::OsString;
2use std::os::unix::fs::PermissionsExt;
3use std::path::PathBuf;
4
5use anyhow::Result;
6
7use crate::error::PodboxError;
8
9/// Standard XDG application directories searched inside the container,
10/// in priority order.  Many apps install to `~/.local/share/applications/`
11/// (per-user), `/usr/local/share/applications/`, or `/opt/<app>/share/applications/`.
12const DESKTOP_SEARCH_PATHS: &[&str] = &[
13    "/usr/share/applications",
14    "/usr/local/share/applications",
15    "/usr/share/applications/kde",
16    "/usr/share/applications/gnome",
17    "/opt",
18];
19
20fn is_valid_app_name(app: &str) -> bool {
21    !app.is_empty()
22        && app
23            .chars()
24            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
25}
26
27/// Export an application as a .desktop file on the host.
28pub fn export_app(container_name: &str, app: &str) -> Result<()> {
29    if !is_valid_app_name(app) {
30        return Err(PodboxError::ExportFailed {
31            details: format!("invalid app name: '{app}'"),
32        }
33        .into());
34    }
35
36    // 1. Locate .desktop file in container, searching XDG directories.
37    let (container_path, desktop_content) = find_desktop_file(container_name, app)?;
38
39    // 2. Rewrite Name= and Exec= lines
40    let rewritten = rewrite_desktop_file(&desktop_content, container_name, app);
41
42    // 3. Write host .desktop file
43    let apps_dir = dirs::data_dir()
44        .unwrap_or_else(|| {
45            dirs::home_dir()
46                .map(|h| h.join(".local/share"))
47                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
48        })
49        .join("applications");
50    std::fs::create_dir_all(&apps_dir)?;
51
52    let host_path = apps_dir.join(format!("podbox-{container_name}-{app}.desktop"));
53    std::fs::write(&host_path, rewritten)?;
54
55    // 4. Try to extract icon
56    if let Some(icon_name) = extract_icon_name(&desktop_content) {
57        if let Err(e) = copy_icon_from_container(container_name, &icon_name, container_name) {
58            eprintln!("Warning: failed to copy icon '{icon_name}': {e}");
59        }
60    }
61
62    // 5. Update desktop database
63    if let Err(e) = std::process::Command::new("update-desktop-database")
64        .arg(&apps_dir)
65        .output()
66        .map(|_| ())
67    {
68        eprintln!("Warning: update-desktop-database failed: {e}");
69    }
70
71    println!(
72        "Exported app '{}'.desktop (from {}) -> {}",
73        app,
74        container_path,
75        host_path.display()
76    );
77    Ok(())
78}
79
80/// Find a `.desktop` file in the container by searching XDG dirs,
81/// falling back to user-installed locations.
82fn find_desktop_file(container_name: &str, app: &str) -> Result<(String, String)> {
83    if !is_valid_app_name(app) {
84        return Err(PodboxError::ExportFailed {
85            details: format!("invalid app name: '{app}'"),
86        }
87        .into());
88    }
89    let filename = format!("{app}.desktop");
90
91    // First: search well-known system locations.
92    for dir in DESKTOP_SEARCH_PATHS {
93        if *dir == "/opt" {
94            // /opt is a prefix — search one level deep for share/applications.
95            continue;
96        }
97        let candidate = format!("{dir}/{filename}");
98        if let Some(content) = try_cat(container_name, &candidate)? {
99            return Ok((candidate, content));
100        }
101    }
102
103    // Second: per-user installs.
104    let user_dirs = ["/root/.local/share/applications", "/home"];
105    for dir in user_dirs {
106        if let Some(content) = try_cat(container_name, &format!("{dir}/{filename}"))? {
107            return Ok((format!("{dir}/{filename}"), content));
108        }
109    }
110
111    // Third: /opt — search for any /opt/*/share/applications/<app>.desktop.
112    if let Some((path, content)) = find_desktop_in_opt(container_name, app)? {
113        return Ok((path, content));
114    }
115
116    Err(PodboxError::ExportFailed {
117        details: format!(
118            "app {} not found in container (searched: {})",
119            app,
120            DESKTOP_SEARCH_PATHS.join(", ")
121        ),
122    }
123    .into())
124}
125
126/// `podman exec <container> cat <path>` — returns `Some(content)` if the
127/// file exists, `None` if `cat` reports missing, error on other failures.
128fn try_cat(container_name: &str, path: &str) -> Result<Option<String>> {
129    let args: Vec<OsString> = vec![
130        "exec".into(),
131        container_name.into(),
132        "cat".into(),
133        path.into(),
134    ];
135    let output = crate::process::run_piped("podman", &args)?;
136    if output.status.success() {
137        Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
138    } else {
139        Ok(None)
140    }
141}
142
143/// Search /opt/*/share/applications/ for a matching .desktop file.
144fn find_desktop_in_opt(container_name: &str, app: &str) -> Result<Option<(String, String)>> {
145    let args: Vec<OsString> = vec![
146        "exec".into(),
147        container_name.into(),
148        "sh".into(),
149        "-c".into(),
150        format!(
151            "for d in /opt/*/share/applications; do \
152               [ -f \"$d/{app}.desktop\" ] && echo \"$d/{app}.desktop\"; \
153             done"
154        )
155        .into(),
156    ];
157    let output = crate::process::run_piped("podman", &args)?;
158    if !output.status.success() {
159        return Ok(None);
160    }
161    let stdout = String::from_utf8_lossy(&output.stdout);
162    for line in stdout.lines().take(500) {
163        let line = line.trim();
164        if line.is_empty() {
165            continue;
166        }
167        if let Some(content) = try_cat(container_name, line)? {
168            return Ok(Some((line.to_string(), content)));
169        }
170    }
171    Ok(None)
172}
173
174/// Export a binary shim to ~/.local/bin.
175pub fn export_bin(container_name: &str, bin: &str) -> Result<()> {
176    let bin_dir = dirs::home_dir()
177        .map(|h| h.join(".local/bin"))
178        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));
179    std::fs::create_dir_all(&bin_dir)?;
180
181    let exe = std::env::current_exe()
182        .map(|p| p.to_string_lossy().to_string())
183        .unwrap_or_else(|_| "podbox".to_string());
184    let shim = format!(
185        "#!/bin/sh\nexec {} --container \"{}\" exec \"{}\" \"$@\"\n",
186        exe,
187        container_name.replace('"', "\\\""),
188        bin.replace('"', "\\\"")
189    );
190
191    let shim_path = bin_dir.join(bin);
192    std::fs::write(&shim_path, shim)?;
193    #[allow(clippy::print_literal)]
194    {
195        let _ = std::fs::set_permissions(&shim_path, std::fs::Permissions::from_mode(0o755));
196    }
197
198    println!("Exported bin shim '{}' -> {}", bin, shim_path.display());
199    Ok(())
200}
201
202/// Remove all exports for a container.
203pub fn unexport_all(container_name: &str) -> Result<()> {
204    let apps_dir = dirs::data_dir()
205        .unwrap_or_else(|| {
206            dirs::home_dir()
207                .map(|h| h.join(".local/share"))
208                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
209        })
210        .join("applications");
211    let prefix = format!("podbox-{container_name}");
212
213    if let Ok(entries) = std::fs::read_dir(&apps_dir) {
214        for entry in entries.flatten() {
215            let name = entry.file_name();
216            if name.to_string_lossy().starts_with(&prefix) {
217                let _ = std::fs::remove_file(entry.path());
218            }
219        }
220    }
221
222    let icons_dir = dirs::data_dir()
223        .unwrap_or_else(|| {
224            dirs::home_dir()
225                .map(|h| h.join(".local/share"))
226                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
227        })
228        .join(format!("icons/podbox/{container_name}"));
229    // Also remove legacy icons path
230    let old_icons_dir = dirs::data_dir()
231        .unwrap_or_else(|| {
232            dirs::home_dir()
233                .map(|h| h.join(".local/share"))
234                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
235        })
236        .join(format!("icons/podmgr/{container_name}"));
237    let _ = std::fs::remove_dir_all(&icons_dir);
238    if old_icons_dir.exists() {
239        let _ = std::fs::remove_dir_all(&old_icons_dir);
240    }
241
242    let bin_dir = dirs::home_dir()
243        .map(|h| h.join(".local/bin"))
244        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));
245
246    // Remove shims that reference this container
247    let marker = format!("--container \"{container_name}\"");
248    if let Ok(entries) = std::fs::read_dir(&bin_dir) {
249        for entry in entries.flatten() {
250            if let Ok(mut file) = std::fs::File::open(entry.path()) {
251                use std::io::Read;
252                let mut chunk = vec![0u8; 4096];
253                if let Ok(bytes_read) = file.read(&mut chunk) {
254                    let content = String::from_utf8_lossy(&chunk[..bytes_read]);
255                    if content.contains(&marker) {
256                        let _ = std::fs::remove_file(entry.path());
257                    }
258                }
259            }
260        }
261    }
262
263    println!("Unexported all apps and bins for '{container_name}'.");
264    Ok(())
265}
266
267/// List the .desktop apps and bin shims exported to the host for a container.
268pub fn list_exports(container_name: &str) -> Result<()> {
269    let apps_dir = dirs::data_dir()
270        .unwrap_or_else(|| {
271            dirs::home_dir()
272                .map(|h| h.join(".local/share"))
273                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
274        })
275        .join("applications");
276    let prefix = format!("podbox-{container_name}-");
277    let suffix = ".desktop";
278
279    let mut apps: Vec<String> = Vec::new();
280    if let Ok(entries) = std::fs::read_dir(&apps_dir) {
281        for entry in entries.flatten() {
282            let name = entry.file_name().to_string_lossy().into_owned();
283            if name.starts_with(&prefix) && name.ends_with(suffix) {
284                apps.push(name[prefix.len()..name.len() - suffix.len()].to_string());
285            }
286        }
287    }
288    apps.sort();
289
290    let bin_dir = dirs::home_dir()
291        .map(|h| h.join(".local/bin"))
292        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));
293    let marker = format!("--container \"{container_name}\"");
294    let mut bins: Vec<String> = Vec::new();
295    if let Ok(entries) = std::fs::read_dir(&bin_dir) {
296        for entry in entries.flatten() {
297            let path = entry.path();
298            if let Ok(mut file) = std::fs::File::open(&path) {
299                use std::io::Read;
300                let mut chunk = vec![0u8; 4096];
301                if let Ok(bytes_read) = file.read(&mut chunk) {
302                    let content = String::from_utf8_lossy(&chunk[..bytes_read]);
303                    if content.contains(&marker) {
304                        bins.push(entry.file_name().to_string_lossy().into_owned());
305                    }
306                }
307            }
308        }
309    }
310    bins.sort();
311
312    if apps.is_empty() && bins.is_empty() {
313        println!("No exports for '{container_name}'.");
314        return Ok(());
315    }
316
317    if !apps.is_empty() {
318        println!("Apps:");
319        for app in &apps {
320            println!("  {app}");
321        }
322    }
323    if !bins.is_empty() {
324        if !apps.is_empty() {
325            println!();
326        }
327        println!("Bins:");
328        for bin in &bins {
329            println!("  {bin}");
330        }
331    }
332    Ok(())
333}
334
335fn rewrite_desktop_file(content: &str, container_name: &str, _app: &str) -> String {
336    let exe = std::env::current_exe()
337        .map(|p| p.to_string_lossy().to_string())
338        .unwrap_or_else(|_| "podbox".to_string());
339    let suffix = format!("({container_name})");
340    content
341        .lines()
342        .map(|line| {
343            if let Some(original) = line.strip_prefix("Exec=") {
344                format!(
345                    "Exec={} --container \"{}\" exec -- {}",
346                    exe,
347                    container_name.replace('"', "\\\""),
348                    original
349                )
350            } else if let Some((key, val)) = line.split_once('=') {
351                if (key == "Name" || key.starts_with("Name[")) && !val.contains(&suffix) {
352                    format!("{key}={val} ({container_name})")
353                } else {
354                    line.to_string()
355                }
356            } else {
357                line.to_string()
358            }
359        })
360        .collect::<Vec<_>>()
361        .join("\n")
362}
363
364fn extract_icon_name(content: &str) -> Option<String> {
365    content
366        .lines()
367        .find_map(|line| line.strip_prefix("Icon=").map(|s| s.to_string()))
368}
369
370fn copy_icon_from_container(container_name: &str, icon_name: &str, _profile: &str) -> Result<()> {
371    // Sanitize icon name: refuse path separators to prevent traversal
372    if icon_name.contains('/') || icon_name.contains("..") {
373        return Err(anyhow::anyhow!(
374            "icon name contains path separators, refusing: {icon_name}"
375        ));
376    }
377
378    let icons_dir = dirs::data_dir()
379        .unwrap_or_else(|| {
380            dirs::home_dir()
381                .map(|h| h.join(".local/share"))
382                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
383        })
384        .join(format!("icons/podbox/{container_name}"));
385    std::fs::create_dir_all(&icons_dir)?;
386
387    let icon_paths: Vec<String> = vec![
388        format!("/usr/share/icons/hicolor/48x48/apps/{}.png", icon_name),
389        format!("/usr/share/icons/hicolor/scalable/apps/{}.svg", icon_name),
390        format!("/usr/share/icons/hicolor/64x64/apps/{}.png", icon_name),
391        format!("/usr/share/icons/hicolor/128x128/apps/{}.png", icon_name),
392        format!("/usr/share/icons/hicolor/256x256/apps/{}.png", icon_name),
393        format!("/usr/share/icons/hicolor/48x48/apps/{}.svg", icon_name),
394    ];
395
396    for path in &icon_paths {
397        let ext = std::path::Path::new(path)
398            .extension()
399            .map(|e| e.to_string_lossy())
400            .unwrap_or_default();
401        let args: Vec<OsString> = vec![
402            "exec".into(),
403            container_name.into(),
404            "cat".into(),
405            path.into(),
406        ];
407        let output = crate::process::run_piped("podman", &args)?;
408        if output.status.success() {
409            let dest = icons_dir.join(format!("{icon_name}.{ext}"));
410            std::fs::write(dest, &output.stdout)?;
411            break;
412        }
413    }
414
415    Ok(())
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn valid_app_names() {
424        for name in &["firefox", "Firefox", "code-oss", "code_oss", "v1.2.3", "a"] {
425            assert!(is_valid_app_name(name), "expected '{name}' to be valid");
426        }
427    }
428
429    #[test]
430    fn reject_empty_name() {
431        assert!(!is_valid_app_name(""));
432    }
433
434    #[test]
435    fn reject_shell_metacharacters() {
436        for bad in &[
437            "foo;rm", "foo\"bar", "foo`bar", "foo$bar", "foo|bar", "foo>bar", "foo<bar", "foo&bar",
438            "foo\nbar", "../foo", "foo/bar", "foo bar", "foo\\bar", "foo'bar",
439        ] {
440            assert!(!is_valid_app_name(bad), "expected '{bad}' to be rejected");
441        }
442    }
443
444    #[test]
445    fn export_app_rejects_invalid_name() {
446        let result = export_app("test-container", "foo;rm");
447        assert!(result.is_err());
448        let err = format!("{}", result.unwrap_err());
449        assert!(
450            err.contains("foo;rm") || err.contains("invalid"),
451            "error should mention the name: {err}"
452        );
453    }
454
455    #[test]
456    fn find_desktop_file_rejects_invalid_name() {
457        let result = find_desktop_file("test-container", "foo`whoami`");
458        assert!(result.is_err());
459        let err = format!("{}", result.unwrap_err());
460        assert!(
461            err.contains("foo`whoami`") || err.contains("invalid"),
462            "error should mention the name: {err}"
463        );
464    }
465
466    #[test]
467    fn rewrite_desktop_file_exec_has_no_leading_whitespace() {
468        let input = "[Desktop Entry]\nName=Firefox\nExec=/usr/bin/firefox %u\nIcon=firefox\n";
469        let out = rewrite_desktop_file(input, "box", "firefox");
470        let exec = out
471            .lines()
472            .find(|l| l.starts_with("Exec="))
473            .expect("rewritten Exec= line");
474        assert!(
475            exec.starts_with("Exec="),
476            "Exec key must start at column 0, got: {exec:?}"
477        );
478        assert!(exec.contains("--container \"box\" exec -- /usr/bin/firefox %u"));
479    }
480
481    #[test]
482    fn rewrite_desktop_file_appends_container_to_name() {
483        let input = "[Desktop Entry]\nName=Firefox\nExec=/usr/bin/firefox\n";
484        let out = rewrite_desktop_file(input, "box", "firefox");
485        assert!(out.contains("Name=Firefox (box)"));
486    }
487
488    #[test]
489    fn list_exports_lists_apps_and_bins() {
490        let apps_dir = dirs::data_dir().expect("data dir").join("applications");
491        std::fs::create_dir_all(&apps_dir).expect("create apps dir");
492        let app_path = apps_dir.join("podbox-box-firefox.desktop");
493        std::fs::write(&app_path, "[Desktop Entry]\nName=Firefox (box)\n").unwrap();
494
495        let bin_dir = dirs::home_dir().expect("home dir").join(".local/bin");
496        std::fs::create_dir_all(&bin_dir).expect("create bin dir");
497        let shim_path = bin_dir.join("firefox");
498        std::fs::write(
499            &shim_path,
500            "#!/bin/sh\nexec /usr/bin/podbox --container \"box\" exec \"firefox\" \"$@\"\n",
501        )
502        .unwrap();
503
504        let apps_dir = dirs::data_dir().expect("data dir").join("applications");
505        let prefix = format!("podbox-{}-", "box");
506        let suffix = ".desktop";
507        let mut apps: Vec<String> = std::fs::read_dir(&apps_dir)
508            .unwrap()
509            .flatten()
510            .map(|e| e.file_name().to_string_lossy().into_owned())
511            .filter(|n| n.starts_with(&prefix) && n.ends_with(suffix))
512            .map(|n| n[prefix.len()..n.len() - suffix.len()].to_string())
513            .collect();
514        apps.sort();
515
516        let marker = format!("--container \"{}\"", "box");
517        let mut bins: Vec<String> = std::fs::read_dir(&bin_dir)
518            .unwrap()
519            .flatten()
520            .filter(|e| {
521                std::fs::read_to_string(e.path())
522                    .map(|c| c.contains(&marker))
523                    .unwrap_or(false)
524            })
525            .map(|e| e.file_name().to_string_lossy().into_owned())
526            .collect();
527        bins.sort();
528
529        assert_eq!(apps, vec!["firefox".to_string()]);
530        assert_eq!(bins, vec!["firefox".to_string()]);
531
532        let _ = std::fs::remove_file(&app_path);
533        let _ = std::fs::remove_file(&shim_path);
534    }
535}