Skip to main content

frs/repo/fix/
quarantine.rs

1//! macOS quarantine extended-attribute cleanup.
2
3use std::path::Path;
4use std::path::PathBuf;
5use std::process::Command;
6use std::thread;
7
8use rootcause::report;
9
10/// The result of cleaning one repo's quarantine metadata.
11pub struct RepoCleanup {
12    /// Repo that was inspected.
13    pub repo: PathBuf,
14    /// Cleanup failures encountered while inspecting the repo.
15    pub failures: Vec<rootcause::Report>,
16}
17
18/// Removes quarantine metadata from every repo while skipping nested repos and symbolic links.
19pub fn clean(repos: &[PathBuf], jobs: usize) -> Vec<RepoCleanup> {
20    repos.iter().map(|repo| clean_repo(repo, repos, jobs)).collect()
21}
22
23fn clean_repo(repo: &Path, repos: &[PathBuf], jobs: usize) -> RepoCleanup {
24    let mut failures = Vec::new();
25    let mut workers = std::collections::VecDeque::new();
26    workers.push_back(thread::spawn({
27        let repo = repo.to_path_buf();
28        move || clean_path(&repo)
29    }));
30    let mut directories = match std::fs::read_dir(repo) {
31        Ok(entries) => vec![(repo.to_path_buf(), entries)],
32        Err(error) => {
33            failures.push(
34                report!("quarantine traversal failed").attach(format!("directory={} error={error}", repo.display())),
35            );
36            Vec::new()
37        }
38    };
39
40    while let Some((directory, entries)) = directories.last_mut() {
41        let directory = directory.clone();
42        let entry = entries.next();
43        let Some(entry) = entry else {
44            directories.pop();
45            continue;
46        };
47
48        let entry = match entry {
49            Ok(entry) => entry,
50            Err(error) => {
51                failures.push(
52                    report!("quarantine traversal failed")
53                        .attach(format!("directory={} error={error}", directory.display())),
54                );
55                continue;
56            }
57        };
58
59        let path = entry.path();
60        let file_type = match entry.file_type() {
61            Ok(file_type) => file_type,
62            Err(error) => {
63                failures
64                    .push(report!("could not inspect path").attach(format!("path={} error={error}", path.display())));
65                continue;
66            }
67        };
68        if file_type.is_symlink() || repos.iter().any(|nested| nested != repo && path.starts_with(nested)) {
69            continue;
70        }
71
72        let child_entries = if file_type.is_dir() {
73            match std::fs::read_dir(&path) {
74                Ok(entries) => Some(entries),
75                Err(error) => {
76                    failures.push(
77                        report!("quarantine traversal failed")
78                            .attach(format!("directory={} error={error}", path.display())),
79                    );
80                    None
81                }
82            }
83        } else {
84            None
85        };
86        if workers.len() >= jobs
87            && let Some(worker) = workers.pop_front()
88            && let Some(failure) = collect_cleanup(worker)
89        {
90            failures.push(failure);
91        }
92        if let Some(entries) = child_entries {
93            directories.push((path.clone(), entries));
94        }
95        workers.push_back(thread::spawn(move || clean_path(&path)));
96    }
97
98    for worker in workers {
99        if let Some(failure) = collect_cleanup(worker) {
100            failures.push(failure);
101        }
102    }
103
104    RepoCleanup {
105        repo: repo.to_path_buf(),
106        failures,
107    }
108}
109
110fn clean_path(path: &Path) -> rootcause::Result<()> {
111    let output = Command::new("xattr").arg(path).output().map_err(|error| {
112        report!("could not inspect quarantine metadata").attach(format!("path={} error={error}", path.display()))
113    })?;
114    if !output.status.success() {
115        return Err(report!("could not inspect quarantine metadata").attach(format!("path={}", path.display())));
116    }
117
118    let quarantine = String::from_utf8_lossy(&output.stdout)
119        .lines()
120        .any(|attribute| attribute == "com.apple.quarantine");
121    if !quarantine {
122        return Ok(());
123    }
124
125    let status = Command::new("xattr")
126        .args(["-d", "com.apple.quarantine"])
127        .arg(path)
128        .status()
129        .map_err(|error| {
130            report!("could not remove quarantine metadata").attach(format!("path={} error={error}", path.display()))
131        })?;
132
133    if status.success() {
134        return Ok(());
135    }
136    Err(report!("could not remove quarantine metadata").attach(format!("path={}", path.display())))
137}
138
139fn collect_cleanup(cleanup: thread::JoinHandle<rootcause::Result<()>>) -> Option<rootcause::Report> {
140    match cleanup.join() {
141        Ok(Ok(())) => None,
142        Ok(Err(failure)) => Some(failure),
143        Err(_) => Some(report!("quarantine cleanup worker panicked")),
144    }
145}