1use std::os::unix::fs::PermissionsExt;
8use std::path::PathBuf;
9
10use anyhow::Result;
11
12use crate::error::PodboxError;
13
14mod 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 let (container_path, desktop_content) = find_desktop_file(container_name, app)?;
33
34 let rewritten = rewrite_desktop_file(&desktop_content, container_name, app);
36
37 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 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 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
75pub 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
104pub 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 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 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
169pub 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}