Skip to main content

podbox/
export.rs

1//! Host-side export of container binaries and `.desktop` files: bubblewrap/
2//! shim export, symlinking, and per-container uninstall. One cohesive export
3//! flow (`.desktop` discovery/rewrite lives in the [`desktop`](crate::export::desktop)
4//! submodule); stays above ~300 LOC as a single cohesive concern (documented
5//! exemption, per MODULARIZATION_GUIDE).
6
7use std::os::unix::fs::PermissionsExt;
8use std::path::PathBuf;
9
10use anyhow::Result;
11
12use crate::error::PodboxError;
13
14/// Standard XDG application directories searched inside the container,
15/// in priority order.  Many apps install to `~/.local/share/applications/`
16mod desktop;
17
18pub(crate) use desktop::{
19    copy_icon_from_container, extract_icon_name, find_desktop_file, is_valid_app_name,
20    rewrite_desktop_file,
21};
22
23pub fn export_app(container_name: &str, app: &str) -> Result<()> {
24    if !is_valid_app_name(app) {
25        return Err(PodboxError::ExportFailed {
26            details: format!("invalid app name: '{app}'"),
27        }
28        .into());
29    }
30
31    // 1. Locate .desktop file in container, searching XDG directories.
32    let (container_path, desktop_content) = find_desktop_file(container_name, app)?;
33
34    // 2. Rewrite Name= and Exec= lines
35    let rewritten = rewrite_desktop_file(&desktop_content, container_name, app);
36
37    // 3. Write host .desktop file
38    let apps_dir = dirs::data_dir()
39        .unwrap_or_else(|| {
40            dirs::home_dir()
41                .map(|h| h.join(".local/share"))
42                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
43        })
44        .join("applications");
45    std::fs::create_dir_all(&apps_dir)?;
46
47    let host_path = apps_dir.join(format!("podbox-{container_name}-{app}.desktop"));
48    std::fs::write(&host_path, rewritten)?;
49
50    // 4. Try to extract icon
51    if let Some(icon_name) = extract_icon_name(&desktop_content) {
52        if let Err(e) = copy_icon_from_container(container_name, &icon_name, container_name) {
53            eprintln!("Warning: failed to copy icon '{icon_name}': {e}");
54        }
55    }
56
57    // 5. Update desktop database
58    if let Err(e) = std::process::Command::new("update-desktop-database")
59        .arg(&apps_dir)
60        .output()
61        .map(|_| ())
62    {
63        eprintln!("Warning: update-desktop-database failed: {e}");
64    }
65
66    println!(
67        "Exported app '{}'.desktop (from {}) -> {}",
68        app,
69        container_path,
70        host_path.display()
71    );
72    Ok(())
73}
74
75/// Find a `.desktop` file in the container by searching XDG dirs,
76/// falling back to user-installed locations.
77pub fn export_bin(container_name: &str, bin: &str) -> Result<()> {
78    let bin_dir = dirs::home_dir()
79        .map(|h| h.join(".local/bin"))
80        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));
81    std::fs::create_dir_all(&bin_dir)?;
82
83    let exe = std::env::current_exe()
84        .map(|p| p.to_string_lossy().to_string())
85        .unwrap_or_else(|_| "podbox".to_string());
86    let shim = format!(
87        "#!/bin/sh\nexec {} --container \"{}\" exec \"{}\" \"$@\"\n",
88        exe,
89        container_name.replace('"', "\\\""),
90        bin.replace('"', "\\\"")
91    );
92
93    let shim_path = bin_dir.join(bin);
94    std::fs::write(&shim_path, shim)?;
95    #[allow(clippy::print_literal)]
96    {
97        let _ = std::fs::set_permissions(&shim_path, std::fs::Permissions::from_mode(0o755));
98    }
99
100    println!("Exported bin shim '{}' -> {}", bin, shim_path.display());
101    Ok(())
102}
103
104/// Remove all exports for a container.
105pub fn unexport_all(container_name: &str) -> Result<()> {
106    let apps_dir = dirs::data_dir()
107        .unwrap_or_else(|| {
108            dirs::home_dir()
109                .map(|h| h.join(".local/share"))
110                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
111        })
112        .join("applications");
113    let prefix = format!("podbox-{container_name}");
114
115    if let Ok(entries) = std::fs::read_dir(&apps_dir) {
116        for entry in entries.flatten() {
117            let name = entry.file_name();
118            if name.to_string_lossy().starts_with(&prefix) {
119                let _ = std::fs::remove_file(entry.path());
120            }
121        }
122    }
123
124    let icons_dir = dirs::data_dir()
125        .unwrap_or_else(|| {
126            dirs::home_dir()
127                .map(|h| h.join(".local/share"))
128                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
129        })
130        .join(format!("icons/podbox/{container_name}"));
131    // Also remove legacy icons path
132    let old_icons_dir = dirs::data_dir()
133        .unwrap_or_else(|| {
134            dirs::home_dir()
135                .map(|h| h.join(".local/share"))
136                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
137        })
138        .join(format!("icons/podmgr/{container_name}"));
139    let _ = std::fs::remove_dir_all(&icons_dir);
140    if old_icons_dir.exists() {
141        let _ = std::fs::remove_dir_all(&old_icons_dir);
142    }
143
144    let bin_dir = dirs::home_dir()
145        .map(|h| h.join(".local/bin"))
146        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));
147
148    // Remove shims that reference this container
149    let marker = format!("--container \"{container_name}\"");
150    if let Ok(entries) = std::fs::read_dir(&bin_dir) {
151        for entry in entries.flatten() {
152            if let Ok(mut file) = std::fs::File::open(entry.path()) {
153                use std::io::Read;
154                let mut chunk = vec![0u8; 4096];
155                if let Ok(bytes_read) = file.read(&mut chunk) {
156                    let content = String::from_utf8_lossy(&chunk[..bytes_read]);
157                    if content.contains(&marker) {
158                        let _ = std::fs::remove_file(entry.path());
159                    }
160                }
161            }
162        }
163    }
164
165    println!("Unexported all apps and bins for '{container_name}'.");
166    Ok(())
167}
168
169/// List the .desktop apps and bin shims exported to the host for a container.
170pub fn list_exports(container_name: &str) -> Result<()> {
171    let apps_dir = dirs::data_dir()
172        .unwrap_or_else(|| {
173            dirs::home_dir()
174                .map(|h| h.join(".local/share"))
175                .unwrap_or_else(|| PathBuf::from("/usr/local/share"))
176        })
177        .join("applications");
178    let prefix = format!("podbox-{container_name}-");
179    let suffix = ".desktop";
180
181    let mut apps: Vec<String> = Vec::new();
182    if let Ok(entries) = std::fs::read_dir(&apps_dir) {
183        for entry in entries.flatten() {
184            let name = entry.file_name().to_string_lossy().into_owned();
185            if name.starts_with(&prefix) && name.ends_with(suffix) {
186                apps.push(name[prefix.len()..name.len() - suffix.len()].to_string());
187            }
188        }
189    }
190    apps.sort();
191
192    let bin_dir = dirs::home_dir()
193        .map(|h| h.join(".local/bin"))
194        .unwrap_or_else(|| PathBuf::from("/usr/local/bin"));
195    let marker = format!("--container \"{container_name}\"");
196    let mut bins: Vec<String> = Vec::new();
197    if let Ok(entries) = std::fs::read_dir(&bin_dir) {
198        for entry in entries.flatten() {
199            let path = entry.path();
200            if let Ok(mut file) = std::fs::File::open(&path) {
201                use std::io::Read;
202                let mut chunk = vec![0u8; 4096];
203                if let Ok(bytes_read) = file.read(&mut chunk) {
204                    let content = String::from_utf8_lossy(&chunk[..bytes_read]);
205                    if content.contains(&marker) {
206                        bins.push(entry.file_name().to_string_lossy().into_owned());
207                    }
208                }
209            }
210        }
211    }
212    bins.sort();
213
214    if apps.is_empty() && bins.is_empty() {
215        println!("No exports for '{container_name}'.");
216        return Ok(());
217    }
218
219    if !apps.is_empty() {
220        println!("Apps:");
221        for app in &apps {
222            println!("  {app}");
223        }
224    }
225    if !bins.is_empty() {
226        if !apps.is_empty() {
227            println!();
228        }
229        println!("Bins:");
230        for bin in &bins {
231            println!("  {bin}");
232        }
233    }
234    Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn valid_app_names() {
243        for name in &["firefox", "Firefox", "code-oss", "code_oss", "v1.2.3", "a"] {
244            assert!(is_valid_app_name(name), "expected '{name}' to be valid");
245        }
246    }
247
248    #[test]
249    fn reject_empty_name() {
250        assert!(!is_valid_app_name(""));
251    }
252
253    #[test]
254    fn reject_shell_metacharacters() {
255        for bad in &[
256            "foo;rm", "foo\"bar", "foo`bar", "foo$bar", "foo|bar", "foo>bar", "foo<bar", "foo&bar",
257            "foo\nbar", "../foo", "foo/bar", "foo bar", "foo\\bar", "foo'bar",
258        ] {
259            assert!(!is_valid_app_name(bad), "expected '{bad}' to be rejected");
260        }
261    }
262
263    #[test]
264    fn export_app_rejects_invalid_name() {
265        let result = export_app("test-container", "foo;rm");
266        assert!(result.is_err());
267        let err = format!("{}", result.unwrap_err());
268        assert!(
269            err.contains("foo;rm") || err.contains("invalid"),
270            "error should mention the name: {err}"
271        );
272    }
273
274    #[test]
275    fn find_desktop_file_rejects_invalid_name() {
276        let result = find_desktop_file("test-container", "foo`whoami`");
277        assert!(result.is_err());
278        let err = format!("{}", result.unwrap_err());
279        assert!(
280            err.contains("foo`whoami`") || err.contains("invalid"),
281            "error should mention the name: {err}"
282        );
283    }
284
285    #[test]
286    fn rewrite_desktop_file_exec_has_no_leading_whitespace() {
287        let input = "[Desktop Entry]\nName=Firefox\nExec=/usr/bin/firefox %u\nIcon=firefox\n";
288        let out = rewrite_desktop_file(input, "box", "firefox");
289        let exec = out
290            .lines()
291            .find(|l| l.starts_with("Exec="))
292            .expect("rewritten Exec= line");
293        assert!(
294            exec.starts_with("Exec="),
295            "Exec key must start at column 0, got: {exec:?}"
296        );
297        assert!(exec.contains("--container \"box\" exec -- /usr/bin/firefox %u"));
298    }
299
300    #[test]
301    fn rewrite_desktop_file_appends_container_to_name() {
302        let input = "[Desktop Entry]\nName=Firefox\nExec=/usr/bin/firefox\n";
303        let out = rewrite_desktop_file(input, "box", "firefox");
304        assert!(out.contains("Name=Firefox (box)"));
305    }
306
307    #[test]
308    fn list_exports_lists_apps_and_bins() {
309        let apps_dir = dirs::data_dir().expect("data dir").join("applications");
310        std::fs::create_dir_all(&apps_dir).expect("create apps dir");
311        let app_path = apps_dir.join("podbox-box-firefox.desktop");
312        std::fs::write(&app_path, "[Desktop Entry]\nName=Firefox (box)\n").unwrap();
313
314        let bin_dir = dirs::home_dir().expect("home dir").join(".local/bin");
315        std::fs::create_dir_all(&bin_dir).expect("create bin dir");
316        let shim_path = bin_dir.join("firefox");
317        std::fs::write(
318            &shim_path,
319            "#!/bin/sh\nexec /usr/bin/podbox --container \"box\" exec \"firefox\" \"$@\"\n",
320        )
321        .unwrap();
322
323        let apps_dir = dirs::data_dir().expect("data dir").join("applications");
324        let prefix = format!("podbox-{}-", "box");
325        let suffix = ".desktop";
326        let mut apps: Vec<String> = std::fs::read_dir(&apps_dir)
327            .unwrap()
328            .flatten()
329            .map(|e| e.file_name().to_string_lossy().into_owned())
330            .filter(|n| n.starts_with(&prefix) && n.ends_with(suffix))
331            .map(|n| n[prefix.len()..n.len() - suffix.len()].to_string())
332            .collect();
333        apps.sort();
334
335        let marker = format!("--container \"{}\"", "box");
336        let mut bins: Vec<String> = std::fs::read_dir(&bin_dir)
337            .unwrap()
338            .flatten()
339            .filter(|e| {
340                std::fs::read_to_string(e.path())
341                    .map(|c| c.contains(&marker))
342                    .unwrap_or(false)
343            })
344            .map(|e| e.file_name().to_string_lossy().into_owned())
345            .collect();
346        bins.sort();
347
348        assert_eq!(apps, vec!["firefox".to_string()]);
349        assert_eq!(bins, vec!["firefox".to_string()]);
350
351        let _ = std::fs::remove_file(&app_path);
352        let _ = std::fs::remove_file(&shim_path);
353    }
354}