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-{}-{}.desktop", container_name, app));
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!("{}.desktop", app);
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: {}",
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}