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-{}-{}.desktop", container_name, app));
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!("{}.desktop", app);
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: {}",
375            icon_name
376        ));
377    }
378
379    let icons_dir = dirs::data_dir()
380        .unwrap_or_else(|| {
381            dirs::home_dir()
382                .map(|h| h.join(".local/share"))
383                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
384        })
385        .join(format!("icons/podbox/{}", container_name));
386    std::fs::create_dir_all(&icons_dir)?;
387
388    let icon_paths: Vec<String> = vec![
389        format!("/usr/share/icons/hicolor/48x48/apps/{}.png", icon_name),
390        format!("/usr/share/icons/hicolor/scalable/apps/{}.svg", icon_name),
391        format!("/usr/share/icons/hicolor/64x64/apps/{}.png", icon_name),
392        format!("/usr/share/icons/hicolor/128x128/apps/{}.png", icon_name),
393        format!("/usr/share/icons/hicolor/256x256/apps/{}.png", icon_name),
394        format!("/usr/share/icons/hicolor/48x48/apps/{}.svg", icon_name),
395    ];
396
397    for path in &icon_paths {
398        let ext = std::path::Path::new(path)
399            .extension()
400            .map(|e| e.to_string_lossy())
401            .unwrap_or_default();
402        let args: Vec<OsString> = vec![
403            "exec".into(),
404            container_name.into(),
405            "cat".into(),
406            path.into(),
407        ];
408        let output = crate::process::run_piped("podman", &args)?;
409        if output.status.success() {
410            let dest = icons_dir.join(format!("{}.{}", icon_name, ext));
411            std::fs::write(dest, &output.stdout)?;
412            break;
413        }
414    }
415
416    Ok(())
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn valid_app_names() {
425        for name in &["firefox", "Firefox", "code-oss", "code_oss", "v1.2.3", "a"] {
426            assert!(is_valid_app_name(name), "expected '{name}' to be valid");
427        }
428    }
429
430    #[test]
431    fn reject_empty_name() {
432        assert!(!is_valid_app_name(""));
433    }
434
435    #[test]
436    fn reject_shell_metacharacters() {
437        for bad in &[
438            "foo;rm", "foo\"bar", "foo`bar", "foo$bar", "foo|bar", "foo>bar", "foo<bar", "foo&bar",
439            "foo\nbar", "../foo", "foo/bar", "foo bar", "foo\\bar", "foo'bar",
440        ] {
441            assert!(!is_valid_app_name(bad), "expected '{bad}' to be rejected");
442        }
443    }
444
445    #[test]
446    fn export_app_rejects_invalid_name() {
447        let result = export_app("test-container", "foo;rm");
448        assert!(result.is_err());
449        let err = format!("{}", result.unwrap_err());
450        assert!(
451            err.contains("foo;rm") || err.contains("invalid"),
452            "error should mention the name: {err}"
453        );
454    }
455
456    #[test]
457    fn find_desktop_file_rejects_invalid_name() {
458        let result = find_desktop_file("test-container", "foo`whoami`");
459        assert!(result.is_err());
460        let err = format!("{}", result.unwrap_err());
461        assert!(
462            err.contains("foo`whoami`") || err.contains("invalid"),
463            "error should mention the name: {err}"
464        );
465    }
466
467    #[test]
468    fn list_exports_lists_apps_and_bins() {
469        let apps_dir = dirs::data_dir().expect("data dir").join("applications");
470        std::fs::create_dir_all(&apps_dir).expect("create apps dir");
471        let app_path = apps_dir.join("podbox-box-firefox.desktop");
472        std::fs::write(&app_path, "[Desktop Entry]\nName=Firefox (box)\n").unwrap();
473
474        let bin_dir = dirs::home_dir().expect("home dir").join(".local/bin");
475        std::fs::create_dir_all(&bin_dir).expect("create bin dir");
476        let shim_path = bin_dir.join("firefox");
477        std::fs::write(
478            &shim_path,
479            "#!/bin/sh\nexec /usr/bin/podbox --container \"box\" exec \"firefox\" \"$@\"\n",
480        )
481        .unwrap();
482
483        let apps_dir = dirs::data_dir().expect("data dir").join("applications");
484        let prefix = format!("podbox-{}-", "box");
485        let suffix = ".desktop";
486        let mut apps: Vec<String> = std::fs::read_dir(&apps_dir)
487            .unwrap()
488            .flatten()
489            .map(|e| e.file_name().to_string_lossy().into_owned())
490            .filter(|n| n.starts_with(&prefix) && n.ends_with(suffix))
491            .map(|n| n[prefix.len()..n.len() - suffix.len()].to_string())
492            .collect();
493        apps.sort();
494
495        let marker = format!("--container \"{}\"", "box");
496        let mut bins: Vec<String> = std::fs::read_dir(&bin_dir)
497            .unwrap()
498            .flatten()
499            .filter(|e| {
500                std::fs::read_to_string(e.path())
501                    .map(|c| c.contains(&marker))
502                    .unwrap_or(false)
503            })
504            .map(|e| e.file_name().to_string_lossy().into_owned())
505            .collect();
506        bins.sort();
507
508        assert_eq!(apps, vec!["firefox".to_string()]);
509        assert_eq!(bins, vec!["firefox".to_string()]);
510
511        let _ = std::fs::remove_file(&app_path);
512        let _ = std::fs::remove_file(&shim_path);
513    }
514}