upstream_rs/services/packaging/
bundle_handler.rs1use crate::{
2 models::upstream::Package,
3 services::integration::{SymlinkManager, permission_handler},
4 utils::{fs_move, static_paths::UpstreamPaths},
5};
6
7use anyhow::{Context, Result, anyhow};
8use chrono::Utc;
9#[cfg(target_os = "macos")]
10use std::process::Command;
11#[cfg(target_os = "macos")]
12use std::time::{SystemTime, UNIX_EPOCH};
13use std::{
14 fs,
15 path::{Path, PathBuf},
16};
17use walkdir::WalkDir;
18
19macro_rules! message {
20 ($cb:expr, $($arg:tt)*) => {{
21 if let Some(cb) = $cb.as_mut() {
22 cb(&format!($($arg)*));
23 }
24 }};
25}
26
27pub struct BundleHandler<'a> {
28 paths: &'a UpstreamPaths,
29 #[cfg(target_os = "macos")]
30 extract_cache: &'a Path,
31}
32
33#[cfg(target_os = "macos")]
34struct MountedDmg {
35 mount_point: PathBuf,
36 detached: bool,
37}
38
39#[cfg(target_os = "macos")]
40impl MountedDmg {
41 fn attach(dmg_path: &Path, mount_point: PathBuf) -> Result<Self> {
42 fs::create_dir_all(&mount_point).context(format!(
43 "Failed to create temporary DMG mountpoint '{}'",
44 mount_point.display()
45 ))?;
46
47 let output = Command::new("hdiutil")
48 .arg("attach")
49 .arg(dmg_path)
50 .arg("-nobrowse")
51 .arg("-readonly")
52 .arg("-mountpoint")
53 .arg(&mount_point)
54 .output()
55 .context("Failed to execute 'hdiutil attach'")?;
56
57 if !output.status.success() {
58 let _ = fs::remove_dir_all(&mount_point);
59 return Err(anyhow!(
60 "Failed to mount DMG '{}': {}",
61 dmg_path.display(),
62 String::from_utf8_lossy(&output.stderr).trim()
63 ));
64 }
65
66 Ok(Self {
67 mount_point,
68 detached: false,
69 })
70 }
71
72 fn detach(&mut self) -> Result<()> {
73 if self.detached {
74 return Ok(());
75 }
76
77 let output = Command::new("hdiutil")
78 .arg("detach")
79 .arg(&self.mount_point)
80 .output()
81 .context("Failed to execute 'hdiutil detach'")?;
82
83 if !output.status.success() {
84 let force_output = Command::new("hdiutil")
85 .arg("detach")
86 .arg("-force")
87 .arg(&self.mount_point)
88 .output()
89 .context("Failed to execute 'hdiutil detach -force'")?;
90
91 if !force_output.status.success() {
92 return Err(anyhow!(
93 "Failed to detach DMG mountpoint '{}': {}; force detach failed: {}",
94 self.mount_point.display(),
95 String::from_utf8_lossy(&output.stderr).trim(),
96 String::from_utf8_lossy(&force_output.stderr).trim()
97 ));
98 }
99 }
100
101 self.detached = true;
102 let _ = fs::remove_dir_all(&self.mount_point);
103 Ok(())
104 }
105}
106
107#[cfg(target_os = "macos")]
108impl Drop for MountedDmg {
109 fn drop(&mut self) {
110 let _ = self.detach();
111 }
112}
113
114impl<'a> BundleHandler<'a> {
115 pub fn new(paths: &'a UpstreamPaths, extract_cache: &'a Path) -> Self {
116 #[cfg(not(target_os = "macos"))]
117 let _ = extract_cache;
118
119 Self {
120 paths,
121 #[cfg(target_os = "macos")]
122 extract_cache,
123 }
124 }
125
126 #[cfg(target_os = "macos")]
127 fn package_cache_key(package_name: &str) -> String {
128 let timestamp = SystemTime::now()
129 .duration_since(UNIX_EPOCH)
130 .map(|d| d.as_nanos())
131 .unwrap_or(0);
132
133 let sanitized = package_name
134 .chars()
135 .map(|c| {
136 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
137 c
138 } else {
139 '_'
140 }
141 })
142 .collect::<String>();
143
144 format!("{}-{}", sanitized, timestamp)
145 }
146
147 fn is_app_bundle(path: &Path) -> bool {
148 path.extension()
149 .and_then(|ext| ext.to_str())
150 .map(|ext| ext.eq_ignore_ascii_case("app"))
151 .unwrap_or(false)
152 }
153
154 pub fn find_macos_app_bundle(
156 extracted_path: &Path,
157 package_name: &str,
158 ) -> Result<Option<PathBuf>> {
159 let bundles = Self::find_macos_app_bundles(extracted_path)?;
160 Ok(Self::select_macos_app_bundle(&bundles, package_name))
161 }
162
163 fn find_macos_app_bundles(root: &Path) -> Result<Vec<PathBuf>> {
167 if root.is_dir() && Self::is_app_bundle(root) {
168 return Ok(vec![root.to_path_buf()]);
169 }
170
171 if !root.is_dir() {
172 return Ok(Vec::new());
173 }
174
175 let mut bundles = Vec::new();
176 for entry in WalkDir::new(root).follow_links(false) {
177 let entry =
178 entry.context(format!("Failed to traverse directory '{}'", root.display()))?;
179 let path = entry.path();
180 if entry.file_type().is_dir() && Self::is_app_bundle(path) {
181 bundles.push(path.to_path_buf());
182 }
183 }
184
185 let mut top_level_bundles = Vec::new();
186 for candidate in &bundles {
187 let is_nested = bundles
188 .iter()
189 .any(|other| other != candidate && candidate.starts_with(other));
190 if !is_nested {
191 top_level_bundles.push(candidate.clone());
192 }
193 }
194
195 Ok(top_level_bundles)
196 }
197
198 fn select_macos_app_bundle(candidates: &[PathBuf], package_name: &str) -> Option<PathBuf> {
200 if candidates.is_empty() {
201 return None;
202 }
203
204 let package_name_lower = package_name.to_lowercase();
205 let mut scored: Vec<(PathBuf, i32, u64)> = candidates
206 .iter()
207 .cloned()
208 .map(|path| {
209 let stem = path
210 .file_stem()
211 .and_then(|s| s.to_str())
212 .unwrap_or("")
213 .to_lowercase();
214 let name_score = if stem == package_name_lower {
215 2
216 } else if stem.contains(&package_name_lower) {
217 1
218 } else {
219 0
220 };
221 let size = Self::directory_size(&path);
222 (path, name_score, size)
223 })
224 .collect();
225
226 scored.sort_by(|a, b| {
227 b.1.cmp(&a.1)
228 .then_with(|| b.2.cmp(&a.2))
229 .then_with(|| a.0.cmp(&b.0))
230 });
231
232 scored.into_iter().next().map(|entry| entry.0)
233 }
234
235 fn directory_size(path: &Path) -> u64 {
237 let mut total_size = 0u64;
238 for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
239 if entry.file_type().is_file()
240 && let Ok(metadata) = entry.metadata()
241 {
242 total_size = total_size.saturating_add(metadata.len());
243 }
244 }
245 total_size
246 }
247
248 fn find_macos_app_executable(app_bundle_path: &Path, package_name: &str) -> Result<PathBuf> {
250 let macos_dir = app_bundle_path.join("Contents").join("MacOS");
251 if !macos_dir.is_dir() {
252 return Err(anyhow!(
253 "Invalid .app bundle '{}': missing Contents/MacOS",
254 app_bundle_path.display()
255 ));
256 }
257
258 let package_name_lower = package_name.to_lowercase();
259 let mut executables = Vec::new();
260 for entry in fs::read_dir(&macos_dir).context(format!(
261 "Failed to read app executable directory '{}'",
262 macos_dir.display()
263 ))? {
264 let entry = entry?;
265 let file_type = entry.file_type()?;
266 if file_type.is_file() || file_type.is_symlink() {
267 executables.push(entry.path());
268 }
269 }
270
271 if executables.is_empty() {
272 return Err(anyhow!("No executable found in '{}'", macos_dir.display()));
273 }
274
275 executables.sort_by_key(|path| {
276 let file_name = path
277 .file_name()
278 .and_then(|s| s.to_str())
279 .unwrap_or("")
280 .to_lowercase();
281 if file_name == package_name_lower {
282 0
283 } else if file_name.starts_with(&package_name_lower) {
284 1
285 } else {
286 2
287 }
288 });
289
290 Ok(executables.remove(0))
291 }
292
293 #[cfg(target_os = "macos")]
294 fn copy_path_recursive(src: &Path, dst: &Path) -> Result<()> {
295 let metadata = fs::symlink_metadata(src)
296 .context(format!("Failed to read metadata for '{}'", src.display()))?;
297 let file_type = metadata.file_type();
298
299 if file_type.is_symlink() {
300 let link_target = fs::read_link(src)
301 .context(format!("Failed to read symlink '{}'", src.display()))?;
302 Self::copy_symlink(src, dst, &link_target)?;
303 return Ok(());
304 }
305
306 if metadata.is_file() {
307 fs::copy(src, dst).context(format!(
308 "Failed to copy file from '{}' to '{}'",
309 src.display(),
310 dst.display()
311 ))?;
312 fs::set_permissions(dst, metadata.permissions()).context(format!(
313 "Failed to preserve file permissions on '{}'",
314 dst.display()
315 ))?;
316 return Ok(());
317 }
318
319 if !metadata.is_dir() {
320 return Err(anyhow!(
321 "Unsupported file type while copying '{}'",
322 src.display()
323 ));
324 }
325
326 if dst.exists() {
327 return Err(anyhow!(
328 "Destination already exists while copying '{}'",
329 dst.display()
330 ));
331 }
332
333 fs::create_dir_all(dst)
334 .context(format!("Failed to create directory '{}'", dst.display()))?;
335 fs::set_permissions(dst, metadata.permissions()).context(format!(
336 "Failed to preserve permissions on '{}'",
337 dst.display()
338 ))?;
339
340 for entry in fs::read_dir(src).context(format!(
341 "Failed to read source directory '{}'",
342 src.display()
343 ))? {
344 let entry = entry?;
345 let src_child = entry.path();
346 let dst_child = dst.join(entry.file_name());
347 Self::copy_path_recursive(&src_child, &dst_child)?;
348 }
349
350 Ok(())
351 }
352
353 #[cfg(target_os = "macos")]
354 fn remove_path_if_exists(path: &Path) -> Result<()> {
355 if !path.exists() {
356 return Ok(());
357 }
358
359 let metadata = fs::symlink_metadata(path)
360 .context(format!("Failed to read metadata for '{}'", path.display()))?;
361 let file_type = metadata.file_type();
362
363 if file_type.is_symlink() || metadata.is_file() {
364 fs::remove_file(path).context(format!("Failed to remove file '{}'", path.display()))?;
365 } else if metadata.is_dir() {
366 fs::remove_dir_all(path)
367 .context(format!("Failed to remove directory '{}'", path.display()))?;
368 }
369
370 Ok(())
371 }
372
373 fn finalize_macos_app_install<H>(
375 &self,
376 out_path: PathBuf,
377 mut package: Package,
378 message_callback: &mut Option<H>,
379 ) -> Result<Package>
380 where
381 H: FnMut(&str),
382 {
383 let exec_path = Self::find_macos_app_executable(&out_path, &package.name)?;
384 permission_handler::make_executable(&exec_path).context(format!(
385 "Failed to make app executable '{}' executable",
386 exec_path.display()
387 ))?;
388
389 message!(
390 message_callback,
391 "Using app executable '{}'",
392 exec_path.display()
393 );
394
395 SymlinkManager::new(&self.paths.integration.symlinks_dir)
396 .add_link(&exec_path, &package.name)
397 .context(format!("Failed to create symlink for '{}'", package.name))?;
398
399 message!(
400 message_callback,
401 "Created symlink: {} → {}",
402 package.name,
403 exec_path.display()
404 );
405
406 package.install_path = Some(out_path);
407 package.exec_path = Some(exec_path);
408 package.last_upgraded = Utc::now();
409 Ok(package)
410 }
411
412 pub fn install_dmg<H>(
413 &self,
414 dmg_path: &Path,
415 package: Package,
416 message_callback: &mut Option<H>,
417 ) -> Result<Package>
418 where
419 H: FnMut(&str),
420 {
421 #[cfg(not(target_os = "macos"))]
422 {
423 let _ = (dmg_path, package, message_callback);
424 Err(anyhow!("DMG installation is only supported on macOS hosts"))
425 }
426
427 #[cfg(target_os = "macos")]
428 {
429 if !dmg_path.exists() || !dmg_path.is_file() {
430 return Err(anyhow!(
431 "Invalid DMG path '{}': file not found",
432 dmg_path.display()
433 ));
434 }
435
436 let mount_point = self.extract_cache.join(format!(
437 "dmg-mount-{}",
438 Self::package_cache_key(&package.name)
439 ));
440
441 message!(
442 message_callback,
443 "Mounting DMG '{}' ...",
444 dmg_path.display()
445 );
446 let mut mounted = MountedDmg::attach(dmg_path, mount_point)?;
447
448 message!(message_callback, "Searching DMG for .app bundle ...");
449 let app_bundles = Self::find_macos_app_bundles(&mounted.mount_point)
450 .context("Failed to inspect mounted DMG contents")?;
451 let Some(app_bundle_path) = Self::select_macos_app_bundle(&app_bundles, &package.name)
452 else {
453 return Err(anyhow!(
454 "No .app bundle found in mounted DMG '{}'",
455 dmg_path.display()
456 ));
457 };
458
459 let bundle_name = app_bundle_path
460 .file_name()
461 .ok_or_else(|| anyhow!("Invalid .app path: no filename"))?;
462 let out_path = self.paths.install.archives_dir.join(bundle_name);
463
464 Self::remove_path_if_exists(&out_path)?;
465 message!(
466 message_callback,
467 "Copying app bundle to '{}' ...",
468 out_path.display()
469 );
470 Self::copy_path_recursive(&app_bundle_path, &out_path).context(format!(
471 "Failed to copy app bundle from mounted DMG to '{}'",
472 out_path.display()
473 ))?;
474
475 mounted.detach()?;
476
477 self.finalize_macos_app_install(out_path, package, message_callback)
478 }
479 }
480
481 pub fn install_app_bundle<H>(
482 &self,
483 app_bundle_path: &Path,
484 package: Package,
485 message_callback: &mut Option<H>,
486 ) -> Result<Package>
487 where
488 H: FnMut(&str),
489 {
490 if !Self::is_app_bundle(app_bundle_path) || !app_bundle_path.is_dir() {
491 return Err(anyhow!(
492 "Expected .app bundle directory, got '{}'",
493 app_bundle_path.display()
494 ));
495 }
496
497 let bundle_name = app_bundle_path
498 .file_name()
499 .ok_or_else(|| anyhow!("Invalid .app path: no filename"))?;
500 let out_path = self.paths.install.archives_dir.join(bundle_name);
501
502 message!(
503 message_callback,
504 "Moving app bundle to '{}' ...",
505 out_path.display()
506 );
507
508 fs_move::move_file_or_dir(app_bundle_path, &out_path).context(format!(
509 "Failed to move app bundle to '{}'",
510 out_path.display()
511 ))?;
512
513 self.finalize_macos_app_install(out_path, package, message_callback)
514 }
515
516 #[cfg(target_os = "macos")]
517 fn copy_symlink(src: &Path, dst: &Path, link_target: &Path) -> Result<()> {
518 let _ = src;
519 std::os::unix::fs::symlink(link_target, dst).context(format!(
520 "Failed to create symlink '{}' -> '{}'",
521 dst.display(),
522 link_target.display()
523 ))?;
524 Ok(())
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::BundleHandler;
531 use crate::utils::static_paths::UpstreamPaths;
532 use std::path::{Path, PathBuf};
533 use std::time::{SystemTime, UNIX_EPOCH};
534 use std::{fs, io};
535
536 fn temp_root(name: &str) -> PathBuf {
537 let nanos = SystemTime::now()
538 .duration_since(UNIX_EPOCH)
539 .map(|d| d.as_nanos())
540 .unwrap_or(0);
541 std::env::temp_dir().join(format!("upstream-macbundle-test-{name}-{nanos}"))
542 }
543
544 fn cleanup(path: &Path) -> io::Result<()> {
545 fs::remove_dir_all(path)
546 }
547
548 fn write_sized_file(path: &Path, size: usize) {
549 fs::write(path, vec![0u8; size]).expect("write sized file");
550 }
551
552 #[test]
553 fn find_macos_app_bundle_prefers_package_named_bundle() {
554 let root = temp_root("app-bundle");
555 fs::create_dir_all(root.join("Other.app")).expect("create other app");
556 fs::create_dir_all(root.join("Tool.app")).expect("create package app");
557
558 let bundle = BundleHandler::find_macos_app_bundle(&root, "tool")
559 .expect("find bundle")
560 .expect("bundle");
561 assert_eq!(
562 bundle.file_name().and_then(|s| s.to_str()),
563 Some("Tool.app")
564 );
565
566 cleanup(&root).expect("cleanup");
567 }
568
569 #[test]
570 fn find_macos_app_executable_reads_contents_macos() {
571 let root = temp_root("app-exec");
572 let macos_dir = root.join("Tool.app").join("Contents").join("MacOS");
573 fs::create_dir_all(&macos_dir).expect("create macos dir");
574 let exec = macos_dir.join("Tool");
575 fs::write(&exec, b"#!/bin/sh\necho hi\n").expect("write executable");
576
577 let found = BundleHandler::find_macos_app_executable(&root.join("Tool.app"), "tool")
578 .expect("find executable");
579 assert_eq!(found, exec);
580
581 cleanup(&root).expect("cleanup");
582 }
583
584 #[test]
585 fn select_macos_app_bundle_prefers_name_match_over_size() {
586 let root = temp_root("select-name");
587 let matched = root.join("Tool.app");
588 let larger = root.join("Other.app");
589 fs::create_dir_all(&matched).expect("create matched app");
590 fs::create_dir_all(&larger).expect("create larger app");
591 write_sized_file(&matched.join("small"), 16);
592 write_sized_file(&larger.join("large"), 4096);
593
594 let selected =
595 BundleHandler::select_macos_app_bundle(&[larger.clone(), matched.clone()], "tool")
596 .expect("select app bundle");
597 assert_eq!(selected, matched);
598
599 cleanup(&root).expect("cleanup");
600 }
601
602 #[test]
603 fn select_macos_app_bundle_falls_back_to_largest_when_no_name_match() {
604 let root = temp_root("select-largest");
605 let small = root.join("Alpha.app");
606 let large = root.join("Beta.app");
607 fs::create_dir_all(&small).expect("create small app");
608 fs::create_dir_all(&large).expect("create large app");
609 write_sized_file(&small.join("small"), 64);
610 write_sized_file(&large.join("large"), 4096);
611
612 let selected =
613 BundleHandler::select_macos_app_bundle(&[small.clone(), large.clone()], "tool")
614 .expect("select app bundle");
615 assert_eq!(selected, large);
616
617 cleanup(&root).expect("cleanup");
618 }
619
620 #[test]
621 fn find_macos_app_bundles_ignores_nested_bundle_entries() {
622 let root = temp_root("find-bundles");
623 let top = root.join("Tool.app");
624 let nested = top.join("Contents").join("Resources").join("Nested.app");
625 fs::create_dir_all(&nested).expect("create nested app bundle");
626
627 let bundles = BundleHandler::find_macos_app_bundles(&root).expect("find app bundles");
628 assert_eq!(bundles, vec![top]);
629
630 cleanup(&root).expect("cleanup");
631 }
632
633 #[cfg(not(target_os = "macos"))]
634 #[test]
635 fn install_dmg_errors_on_non_macos_hosts() {
636 let root = temp_root("dmg-non-macos");
637 fs::create_dir_all(&root).expect("create root");
638 let dmg_path = root.join("app.dmg");
639 fs::write(&dmg_path, b"not-a-real-dmg").expect("write dmg");
640
641 let paths = UpstreamPaths::new();
642 let handler = BundleHandler::new(&paths, &root);
643 let package = crate::models::upstream::Package::with_defaults(
644 "tool".to_string(),
645 "owner/tool".to_string(),
646 crate::models::common::enums::Filetype::MacDmg,
647 None,
648 None,
649 crate::models::common::enums::Channel::Stable,
650 crate::models::common::enums::Provider::Github,
651 None,
652 );
653 let mut message_callback: Option<fn(&str)> = None;
654
655 let err = handler
656 .install_dmg(&dmg_path, package, &mut message_callback)
657 .expect_err("non-macos should reject dmg install");
658 assert!(
659 err.to_string()
660 .contains("DMG installation is only supported on macOS hosts")
661 );
662
663 cleanup(&root).expect("cleanup");
664 }
665}