1use std::ffi::OsString;
2use std::os::unix::fs::PermissionsExt;
3use std::path::PathBuf;
4
5use anyhow::Result;
6
7use crate::error::PodboxError;
8
9const 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
27pub 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 let (container_path, desktop_content) = find_desktop_file(container_name, app)?;
38
39 let rewritten = rewrite_desktop_file(&desktop_content, container_name, app);
41
42 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 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 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
80fn 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 for dir in DESKTOP_SEARCH_PATHS {
93 if *dir == "/opt" {
94 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 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 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
126fn 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
143fn 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
174pub 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
202pub 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 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 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
267pub 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 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}