tugger_apple_bundle/
directory_bundle.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Bundles backed by a directory.
6
7use {
8    crate::BundlePackageType,
9    anyhow::{anyhow, Context, Result},
10    std::path::{Path, PathBuf},
11};
12
13/// An Apple bundle backed by a filesystem/directory.
14///
15/// Instances represent a type-agnostic bundle (macOS application bundle, iOS
16/// application bundle, framework bundles, etc).
17pub struct DirectoryBundle {
18    /// Root directory of this bundle.
19    root: PathBuf,
20
21    /// Name of the root directory.
22    root_name: String,
23
24    /// Whether the bundle is shallow.
25    ///
26    /// If false, content is in a `Contents/` sub-directory.
27    shallow: bool,
28
29    /// The type of this bundle.
30    package_type: BundlePackageType,
31
32    /// Parsed `Info.plist` file.
33    info_plist: plist::Dictionary,
34}
35
36impl DirectoryBundle {
37    /// Open an existing bundle from a filesystem path.
38    ///
39    /// The specified path should be the root directory of the bundle.
40    ///
41    /// This will validate that the directory is a bundle and error if not.
42    /// Validation is limited to locating an `Info.plist` file, which is
43    /// required for all bundle types.
44    pub fn new_from_path(directory: &Path) -> Result<Self> {
45        if !directory.is_dir() {
46            return Err(anyhow!("{} is not a directory", directory.display()));
47        }
48
49        let root_name = directory
50            .file_name()
51            .ok_or_else(|| anyhow!("unable to resolve root directory name"))?
52            .to_string_lossy()
53            .to_string();
54
55        let contents = directory.join("Contents");
56        let shallow = !contents.is_dir();
57
58        let app_plist = if shallow {
59            directory.join("Info.plist")
60        } else {
61            contents.join("Info.plist")
62        };
63
64        let framework_plist = directory.join("Resources").join("Info.plist");
65
66        let (package_type, info_plist_path) = if app_plist.is_file() {
67            if root_name.ends_with(".app") {
68                (BundlePackageType::App, app_plist)
69            } else {
70                (BundlePackageType::Bundle, app_plist)
71            }
72        } else if framework_plist.is_file() {
73            if root_name.ends_with(".framework") {
74                (BundlePackageType::Framework, framework_plist)
75            } else {
76                (BundlePackageType::Bundle, framework_plist)
77            }
78        } else {
79            return Err(anyhow!("Info.plist not found; not a valid bundle"));
80        };
81
82        let info_plist_data = std::fs::read(&info_plist_path)?;
83        let cursor = std::io::Cursor::new(info_plist_data);
84        let value = plist::Value::from_reader_xml(cursor).context("parsing Info.plist XML")?;
85        let info_plist = value
86            .into_dictionary()
87            .ok_or_else(|| anyhow!("{} is not a dictionary", info_plist_path.display()))?;
88
89        Ok(Self {
90            root: directory.to_path_buf(),
91            root_name,
92            shallow,
93            package_type,
94            info_plist,
95        })
96    }
97
98    /// Resolve the absolute path to a file in the bundle.
99    pub fn resolve_path(&self, path: impl AsRef<Path>) -> PathBuf {
100        if self.shallow {
101            self.root.join(path.as_ref())
102        } else {
103            self.root.join("Contents").join(path.as_ref())
104        }
105    }
106
107    /// The root directory of this bundle.
108    pub fn root_dir(&self) -> &Path {
109        &self.root
110    }
111
112    /// The on-disk name of this bundle.
113    ///
114    /// This is effectively the directory name of the bundle. Contains the `.app`,
115    /// `.framework`, etc suffix.
116    pub fn name(&self) -> &str {
117        &self.root_name
118    }
119
120    /// Whether this is a shallow bundle.
121    ///
122    /// If false, content is likely in a `Contents` directory.
123    pub fn shallow(&self) -> bool {
124        self.shallow
125    }
126
127    /// Obtain the path to the `Info.plist` file.
128    pub fn info_plist_path(&self) -> PathBuf {
129        match self.package_type {
130            BundlePackageType::App | BundlePackageType::Bundle => self.resolve_path("Info.plist"),
131            BundlePackageType::Framework => self.root.join("Resources").join("Info.plist"),
132        }
133    }
134
135    /// Obtain the parsed `Info.plist` file.
136    pub fn info_plist(&self) -> &plist::Dictionary {
137        &self.info_plist
138    }
139
140    /// Obtain an `Info.plist` key as a `String`.
141    ///
142    /// Will return `None` if the specified key doesn't exist. Errors if the key value
143    /// is not a string.
144    pub fn info_plist_key_string(&self, key: &str) -> Result<Option<String>> {
145        if let Some(value) = self.info_plist.get(key) {
146            Ok(Some(
147                value
148                    .as_string()
149                    .ok_or_else(|| anyhow!("key {} is not a string", key))?
150                    .to_string(),
151            ))
152        } else {
153            Ok(None)
154        }
155    }
156
157    /// Obtain the type of bundle.
158    pub fn package_type(&self) -> BundlePackageType {
159        self.package_type
160    }
161
162    /// Obtain the bundle display name.
163    ///
164    /// This retrieves the value of `CFBundleDisplayName` from the `Info.plist`.
165    pub fn display_name(&self) -> Result<Option<String>> {
166        self.info_plist_key_string("CFBundleDisplayName")
167    }
168
169    /// Obtain the bundle identifier.
170    ///
171    /// This retrieves `CFBundleIdentifier` from the `Info.plist`.
172    pub fn identifier(&self) -> Result<Option<String>> {
173        self.info_plist_key_string("CFBundleIdentifier")
174    }
175
176    /// Obtain the bundle version string.
177    ///
178    /// This retrieves `CFBundleVersion` from the `Info.plist`.
179    pub fn version(&self) -> Result<Option<String>> {
180        self.info_plist_key_string("CFBundleVersion")
181    }
182
183    /// Obtain the name of the bundle's main executable file.
184    ///
185    /// This retrieves `CFBundleExecutable` from the `Info.plist`.
186    pub fn main_executable(&self) -> Result<Option<String>> {
187        self.info_plist_key_string("CFBundleExecutable")
188    }
189
190    /// Obtain filenames of bundle icon files.
191    ///
192    /// This retrieves `CFBundleIconFiles` from the `Info.plist`.
193    pub fn icon_files(&self) -> Result<Option<Vec<String>>> {
194        if let Some(value) = self.info_plist.get("CFBundleIconFiles") {
195            let values = value
196                .as_array()
197                .ok_or_else(|| anyhow!("CFBundleIconFiles not an array"))?;
198
199            Ok(Some(
200                values
201                    .iter()
202                    .map(|x| {
203                        Ok(x.as_string()
204                            .ok_or_else(|| anyhow!("CFBundleIconFiles value not a string"))?
205                            .to_string())
206                    })
207                    .collect::<Result<Vec<_>>>()?,
208            ))
209        } else {
210            Ok(None)
211        }
212    }
213
214    /// Obtain all files within this bundle.
215    ///
216    /// The iteration order is deterministic.
217    ///
218    /// `traverse_nested` defines whether to traverse into nested bundles.
219    pub fn files(&self, traverse_nested: bool) -> Result<Vec<DirectoryBundleFile<'_>>> {
220        let nested_dirs = self
221            .nested_bundles()?
222            .into_iter()
223            .map(|(_, bundle)| bundle.root_dir().to_path_buf())
224            .collect::<Vec<_>>();
225
226        Ok(walkdir::WalkDir::new(&self.root)
227            .sort_by(|a, b| a.file_name().cmp(b.file_name()))
228            .into_iter()
229            .map(|entry| {
230                let entry = entry?;
231
232                Ok(entry.path().to_path_buf())
233            })
234            .collect::<Result<Vec<_>>>()?
235            .into_iter()
236            .filter_map(|path| {
237                if path.is_dir()
238                    || (!traverse_nested
239                        && nested_dirs
240                            .iter()
241                            .any(|prefix| path.strip_prefix(prefix).is_ok()))
242                {
243                    None
244                } else {
245                    Some(DirectoryBundleFile::new(self, path))
246                }
247            })
248            .collect::<Vec<_>>())
249    }
250
251    /// Obtain all nested bundles within this one.
252    ///
253    /// This walks the directory tree for directories that can be parsed
254    /// as bundles.
255    ///
256    /// This will descend infinitely into nested bundles. i.e. we don't stop
257    /// traversing directories when we encounter a bundle.
258    pub fn nested_bundles(&self) -> Result<Vec<(String, Self)>> {
259        Ok(walkdir::WalkDir::new(&self.root)
260            .sort_by(|a, b| a.file_name().cmp(b.file_name()))
261            .into_iter()
262            .map(|entry| {
263                let entry = entry?;
264
265                Ok(entry.path().to_path_buf())
266            })
267            .collect::<Result<Vec<_>>>()?
268            .into_iter()
269            .filter_map(|p| {
270                let file_name = p.file_name().map(|x| x.to_string_lossy());
271
272                if p.is_dir() && file_name != Some("Contents".into()) && p != self.root {
273                    if let Ok(bundle) = Self::new_from_path(&p) {
274                        let rel = bundle
275                            .root
276                            .strip_prefix(&self.root)
277                            .expect("nested bundle should be in sub-directory of main");
278
279                        Some((rel.to_string_lossy().to_string(), bundle))
280                    } else {
281                        None
282                    }
283                } else {
284                    None
285                }
286            })
287            .collect::<Vec<_>>())
288    }
289}
290
291/// Represents a file in a [DirectoryBundle].
292pub struct DirectoryBundleFile<'a> {
293    bundle: &'a DirectoryBundle,
294    absolute_path: PathBuf,
295    relative_path: PathBuf,
296}
297
298impl<'a> DirectoryBundleFile<'a> {
299    fn new(bundle: &'a DirectoryBundle, absolute_path: PathBuf) -> Self {
300        let relative_path = absolute_path
301            .strip_prefix(&bundle.root)
302            .expect("path prefix strip should have worked")
303            .to_path_buf();
304
305        Self {
306            bundle,
307            absolute_path,
308            relative_path,
309        }
310    }
311
312    /// Absolute path to this file.
313    pub fn absolute_path(&self) -> &Path {
314        &self.absolute_path
315    }
316
317    /// Relative path within the bundle to this file.
318    pub fn relative_path(&self) -> &Path {
319        &self.relative_path
320    }
321
322    /// Whether this is the `Info.plist` file.
323    pub fn is_info_plist(&self) -> bool {
324        self.absolute_path == self.bundle.info_plist_path()
325    }
326
327    /// Whether this is the main executable for the bundle.
328    pub fn is_main_executable(&self) -> Result<bool> {
329        if let Some(main) = self.bundle.main_executable()? {
330            if self.bundle.shallow() {
331                Ok(self.absolute_path == self.bundle.resolve_path(main))
332            } else {
333                Ok(self.absolute_path == self.bundle.resolve_path(format!("MacOS/{}", main)))
334            }
335        } else {
336            Ok(false)
337        }
338    }
339
340    /// Whether this file is in the code signature directory.
341    pub fn is_in_code_signature_directory(&self) -> bool {
342        let prefix = self.bundle.resolve_path("_CodeSignature");
343
344        self.absolute_path.starts_with(&prefix)
345    }
346
347    /// Obtain the symlink target for this file.
348    ///
349    /// If `None`, the file is not a symlink.
350    pub fn symlink_target(&self) -> Result<Option<PathBuf>> {
351        let metadata = self.absolute_path.metadata()?;
352
353        if metadata.file_type().is_symlink() {
354            Ok(Some(std::fs::read_link(&self.absolute_path)?))
355        } else {
356            Ok(None)
357        }
358    }
359}