Skip to main content

upstream_rs/services/packaging/
bundle_handler.rs

1use 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    /// Find candidate `.app` bundles and pick the best match for `package_name`.
155    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    /// Walk extracted files and return top-level `.app` directories.
164    ///
165    /// Nested bundles inside another `.app` are filtered out.
166    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    /// Rank bundle candidates by name match first, then by bundle size.
199    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    /// Compute total on-disk size for tie-breaking bundle selection.
236    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    /// Resolve the executable file inside `Contents/MacOS` for the selected app bundle.
249    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    /// Complete metadata/symlink updates after placing a macOS app bundle.
374    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}