Skip to main content

gpui_updater_core/install/
ownership.rs

1//! Who keeps an installed app up to date: the app itself, a package manager,
2//! or whoever packaged the build.
3//!
4//! An app that a package manager installed should not replace itself: the
5//! manager then reports a version it did not install, and its next upgrade
6//! can undo the update or fail on the changed files. Check [`ownership`] at
7//! startup. When it is not [`Ownership::SelfUpdate`], tell the user how to
8//! update instead of offering to.
9
10use std::fmt;
11use std::path::Path;
12
13/// The package managers [`ownership`] recognizes.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum PackageManager {
17    /// Homebrew: a formula in `Cellar/` or a cask in `Caskroom/`.
18    Homebrew,
19    /// The Mac App Store: the bundle carries an App Store receipt.
20    MacAppStore,
21    /// dpkg and apt (Debian, Ubuntu).
22    Apt,
23    /// rpm (Fedora, RHEL, SUSE).
24    Rpm,
25    /// pacman (Arch Linux).
26    Pacman,
27    /// Nix: the app is in `/nix/store`.
28    Nix,
29    /// Flatpak.
30    Flatpak,
31    /// Snap.
32    Snap,
33    /// Scoop (Windows).
34    Scoop,
35    /// Chocolatey (Windows).
36    Chocolatey,
37    /// A winget portable package (Windows).
38    Winget,
39    /// The Microsoft Store: an MSIX package in `WindowsApps`.
40    MicrosoftStore,
41}
42
43impl PackageManager {
44    /// A display name, such as `Homebrew`.
45    #[must_use]
46    pub fn name(self) -> &'static str {
47        match self {
48            Self::Homebrew => "Homebrew",
49            Self::MacAppStore => "the Mac App Store",
50            Self::Apt => "apt",
51            Self::Rpm => "rpm",
52            Self::Pacman => "pacman",
53            Self::Nix => "Nix",
54            Self::Flatpak => "Flatpak",
55            Self::Snap => "Snap",
56            Self::Scoop => "Scoop",
57            Self::Chocolatey => "Chocolatey",
58            Self::Winget => "winget",
59            Self::MicrosoftStore => "the Microsoft Store",
60        }
61    }
62}
63
64impl fmt::Display for PackageManager {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(self.name())
67    }
68}
69
70/// Who keeps the installed app up to date.
71#[derive(Debug, Clone, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum Ownership {
74    /// Nothing else claims the install: the app updates itself.
75    SelfUpdate,
76    /// A package manager installed the app and should update it.
77    #[non_exhaustive]
78    Managed {
79        /// The package manager.
80        manager: PackageManager,
81        /// The package, cask, or app ID, when known.
82        package: Option<String>,
83    },
84    /// The packager turned self-update off and says how this build is
85    /// updated, through [`OwnershipHints::explanation`].
86    Packager(String),
87}
88
89impl Ownership {
90    /// An install that `manager` owns, as `package` when known.
91    #[must_use]
92    pub fn managed(manager: PackageManager, package: Option<String>) -> Self {
93        Self::Managed { manager, package }
94    }
95
96    /// True when the app should update itself.
97    #[must_use]
98    pub fn is_self_update(&self) -> bool {
99        matches!(self, Self::SelfUpdate)
100    }
101
102    /// A command that updates the app, for a message such as "Run
103    /// `brew upgrade zed` to update". `None` when no single command fits.
104    #[must_use]
105    pub fn update_command(&self) -> Option<String> {
106        let Self::Managed { manager, package } = self else {
107            return None;
108        };
109        let package = package.as_deref();
110        Some(match (manager, package) {
111            (PackageManager::Pacman, _) => "sudo pacman -Syu".to_string(),
112            (PackageManager::Homebrew, Some(p)) => format!("brew upgrade {p}"),
113            (PackageManager::Apt, Some(p)) => format!("sudo apt install --only-upgrade {p}"),
114            (PackageManager::Rpm, Some(p)) => format!("sudo dnf upgrade {p}"),
115            (PackageManager::Flatpak, Some(p)) => format!("flatpak update {p}"),
116            (PackageManager::Snap, Some(p)) => format!("sudo snap refresh {p}"),
117            (PackageManager::Scoop, Some(p)) => format!("scoop update {p}"),
118            (PackageManager::Chocolatey, Some(p)) => format!("choco upgrade {p}"),
119            (PackageManager::Winget, Some(p)) => format!("winget upgrade --id {p}"),
120            _ => return None,
121        })
122    }
123}
124
125impl fmt::Display for Ownership {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            Self::SelfUpdate => f.write_str("updated by the app itself"),
129            Self::Managed {
130                manager,
131                package: Some(package),
132            } => write!(f, "installed by {manager} ({package})"),
133            Self::Managed { manager, .. } => write!(f, "installed by {manager}"),
134            Self::Packager(text) => f.write_str(text),
135        }
136    }
137}
138
139/// What [`ownership`] may use beyond the install location.
140#[derive(Debug, Clone, PartialEq, Eq)]
141#[non_exhaustive]
142pub struct OwnershipHints {
143    /// How the packager says this build is updated. A distribution that
144    /// builds the app sets it, and the app passes it on, for example from
145    /// `option_env!("MYAPP_UPDATE_EXPLANATION")`, as Zed does with
146    /// `ZED_UPDATE_EXPLANATION`. When set, [`ownership`] returns
147    /// [`Ownership::Packager`] without looking further.
148    pub explanation: Option<String>,
149    /// Ask the system package database (dpkg, rpm, pacman) who owns the app
150    /// on Linux. Each query is limited to two seconds. Defaults to `true`.
151    pub query_package_database: bool,
152}
153
154impl Default for OwnershipHints {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl OwnershipHints {
161    /// No packager explanation, and package database queries on.
162    #[must_use]
163    pub fn new() -> Self {
164        Self {
165            explanation: None,
166            query_package_database: true,
167        }
168    }
169
170    /// Set the packager's explanation. `None` or a blank text sets none, so
171    /// `option_env!(…)` can be passed as it is.
172    #[must_use]
173    pub fn explanation<S: Into<String>>(mut self, explanation: Option<S>) -> Self {
174        self.explanation = explanation
175            .map(Into::into)
176            .filter(|text| !text.trim().is_empty());
177        self
178    }
179
180    /// Turn the Linux package database queries on or off.
181    #[must_use]
182    pub fn query_package_database(mut self, query: bool) -> Self {
183        self.query_package_database = query;
184        self
185    }
186}
187
188/// Find who keeps the app at `install_root` up to date (see
189/// [`current_install_root`](super::current_install_root)).
190///
191/// In order: the packager's explanation in `hints`; the location (Nix,
192/// Snap, Flatpak, Homebrew's `Cellar`, Scoop, Chocolatey, winget, the
193/// Microsoft Store); a Mac App Store receipt; a Homebrew cask that links
194/// back to the app; and on Linux the dpkg, rpm, and pacman databases.
195/// Anything else is [`Ownership::SelfUpdate`]. Errors are not reported:
196/// what cannot be checked does not claim the install.
197///
198/// ```
199/// use gpui_updater_core::{Ownership, OwnershipHints, current_install_root, install};
200///
201/// let hints = OwnershipHints::new().explanation(option_env!("MYAPP_UPDATE_EXPLANATION"));
202/// let owner = current_install_root()
203///     .map_or(Ownership::SelfUpdate, |root| install::ownership(&root, &hints));
204/// if !owner.is_self_update() {
205///     println!("{owner}: {}", owner.update_command().unwrap_or_default());
206/// }
207/// ```
208#[must_use]
209pub fn ownership(install_root: &Path, hints: &OwnershipHints) -> Ownership {
210    if let Some(text) = hints
211        .explanation
212        .as_deref()
213        .filter(|text| !text.trim().is_empty())
214    {
215        return Ownership::Packager(text.to_string());
216    }
217    let canonical = std::fs::canonicalize(install_root).ok();
218    let paths: Vec<&Path> = std::iter::once(install_root)
219        .chain(canonical.as_deref().filter(|c| *c != install_root))
220        .collect();
221    let env = |name: &str| std::env::var(name).ok().filter(|v| !v.is_empty());
222    for path in &paths {
223        if let Some(found) = by_path(&path.to_string_lossy(), &env) {
224            return found;
225        }
226    }
227    let root = canonical.as_deref().unwrap_or(install_root);
228    if root.join("Contents/_MASReceipt/receipt").is_file() {
229        return Ownership::managed(PackageManager::MacAppStore, None);
230    }
231    #[cfg(unix)]
232    if let Some(found) = homebrew_cask(root, &homebrew_prefixes(&env)) {
233        return found;
234    }
235    #[cfg(target_os = "linux")]
236    if hints.query_package_database {
237        if let Some(found) = linux::package_database(&paths) {
238            return found;
239        }
240    }
241    Ownership::SelfUpdate
242}
243
244/// The places Homebrew is installed: `HOMEBREW_PREFIX`, then the defaults.
245fn homebrew_prefixes(env: &dyn Fn(&str) -> Option<String>) -> Vec<String> {
246    env("HOMEBREW_PREFIX")
247        .into_iter()
248        .chain(
249            ["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"]
250                .into_iter()
251                .map(str::to_string),
252        )
253        .collect()
254}
255
256/// A path as `/`-separated text, with a lowercase copy for matching. ASCII
257/// lowercasing keeps byte offsets, so a match in `lower` slices `original`.
258struct PathText {
259    original: String,
260    lower: String,
261}
262
263impl PathText {
264    fn new(path: &str) -> Self {
265        let original = path.replace('\\', "/");
266        let lower = original.to_ascii_lowercase();
267        Self { original, lower }
268    }
269
270    /// The path component right after the first `marker` (lowercase, with
271    /// `/` on both ends).
272    fn after(&self, marker: &str) -> Option<String> {
273        let start = self.lower.find(marker)? + marker.len();
274        self.component_at(start)
275    }
276
277    /// The path component right after `dir` when the path starts in `dir`.
278    fn after_prefix(&self, dir: &str) -> Option<String> {
279        let mut dir = dir.replace('\\', "/").to_ascii_lowercase();
280        if !dir.ends_with('/') {
281            dir.push('/');
282        }
283        self.lower
284            .starts_with(&dir)
285            .then(|| self.component_at(dir.len()))
286            .flatten()
287    }
288
289    fn component_at(&self, start: usize) -> Option<String> {
290        let name = self.original.get(start..)?.split('/').next()?;
291        (!name.is_empty()).then(|| name.to_string())
292    }
293}
294
295/// Ownership that the location alone shows.
296fn by_path(path: &str, env: &dyn Fn(&str) -> Option<String>) -> Option<Ownership> {
297    use PackageManager as M;
298    let text = PathText::new(path);
299    let managed = |manager, package| Some(Ownership::managed(manager, package));
300    if text.lower.starts_with("/nix/store/") {
301        return managed(M::Nix, None);
302    }
303    if let Some(name) = text.after_prefix("/snap") {
304        return managed(M::Snap, Some(name));
305    }
306    if text.lower.starts_with("/app/") {
307        if let Some(id) = env("FLATPAK_ID") {
308            return managed(M::Flatpak, Some(id));
309        }
310    }
311    if let Some(id) = text.after("/flatpak/app/") {
312        return managed(M::Flatpak, Some(id));
313    }
314    for prefix in homebrew_prefixes(env) {
315        if let Some(name) = text.after_prefix(&format!("{prefix}/Cellar")) {
316            return managed(M::Homebrew, Some(name));
317        }
318    }
319    let scoop = [env("SCOOP"), env("SCOOP_GLOBAL")];
320    if let Some(name) = scoop
321        .iter()
322        .flatten()
323        .find_map(|dir| text.after_prefix(&format!("{dir}/apps")))
324        .or_else(|| text.after("/scoop/apps/"))
325    {
326        return managed(M::Scoop, Some(name));
327    }
328    let chocolatey = env("ChocolateyInstall").unwrap_or_else(|| "C:/ProgramData/chocolatey".into());
329    if let Some(name) = text.after_prefix(&format!("{chocolatey}/lib")) {
330        return managed(M::Chocolatey, Some(name));
331    }
332    // A portable package unpacks into `<PackageId>_<SourceId>`.
333    if let Some(folder) = text.after("/microsoft/winget/packages/") {
334        let id = folder.split_once('_').map_or(folder.as_str(), |(id, _)| id);
335        return managed(M::Winget, Some(id.to_string()));
336    }
337    // An MSIX package folder is `<Name>_<Version>_<Arch>_<Resource>_<Publisher>`.
338    if let Some(folder) = text.after("/program files/windowsapps/") {
339        let name = folder
340            .split_once('_')
341            .map_or(folder.as_str(), |(name, _)| name);
342        return managed(M::MicrosoftStore, Some(name.to_string()));
343    }
344    None
345}
346
347/// A Homebrew cask that installed `root`: Homebrew moves the app into place
348/// and leaves a symlink to it at `Caskroom/<token>/<version>/<App>`.
349#[cfg(unix)]
350fn homebrew_cask(root: &Path, prefixes: &[String]) -> Option<Ownership> {
351    let name = root.file_name()?;
352    for prefix in prefixes {
353        let Ok(tokens) = std::fs::read_dir(Path::new(prefix).join("Caskroom")) else {
354            continue;
355        };
356        for token in tokens.flatten() {
357            let Ok(versions) = std::fs::read_dir(token.path()) else {
358                continue;
359            };
360            for version in versions.flatten() {
361                let link = version.path().join(name);
362                let is_link = link
363                    .symlink_metadata()
364                    .is_ok_and(|m| m.file_type().is_symlink());
365                if is_link && std::fs::canonicalize(&link).is_ok_and(|target| target == root) {
366                    let token = token.file_name().to_string_lossy().into_owned();
367                    return Some(Ownership::managed(PackageManager::Homebrew, Some(token)));
368                }
369            }
370        }
371    }
372    None
373}
374
375/// Queries of the Linux package databases.
376#[cfg(any(target_os = "linux", test))]
377mod linux {
378    /// The package that owns one of `paths`, by dpkg, rpm, or pacman.
379    #[cfg(target_os = "linux")]
380    pub(super) fn package_database(paths: &[&std::path::Path]) -> Option<super::Ownership> {
381        use super::{Ownership, PackageManager};
382
383        for path in paths {
384            let path = path.as_os_str();
385            if let Some(out) = run("dpkg-query", &["--search".as_ref(), "--".as_ref(), path]) {
386                if let Some(package) = dpkg_owner(&out, &path.to_string_lossy()) {
387                    let format = "--showformat=${db:Status-Status}";
388                    let args = ["--show", format, "--", package.as_str()];
389                    let status = run("dpkg-query", &args.map(std::ffi::OsStr::new));
390                    if status.as_deref().map(str::trim) == Some("installed") {
391                        return Some(Ownership::managed(PackageManager::Apt, Some(package)));
392                    }
393                }
394            }
395            let rpm = ["--query", "--file", "--queryformat", "%{NAME}\\n", "--"];
396            let args: Vec<&std::ffi::OsStr> =
397                rpm.iter().map(std::ffi::OsStr::new).chain([path]).collect();
398            if let Some(name) = run("rpm", &args).as_deref().and_then(single_name) {
399                return Some(Ownership::managed(PackageManager::Rpm, Some(name)));
400            }
401            if let Some(name) = run("pacman", &["-Qqo".as_ref(), "--".as_ref(), path])
402                .as_deref()
403                .and_then(single_name)
404            {
405                return Some(Ownership::managed(PackageManager::Pacman, Some(name)));
406            }
407        }
408        None
409    }
410
411    /// How long one package database query may take: two seconds.
412    ///
413    /// Tests allow 30 seconds. `dpkg-query --search` reads every package's
414    /// file list, and on a shared CI runner that also runs the other tests
415    /// in parallel, a cold query can take longer than two seconds. The
416    /// tests check what the query finds, not the time limit.
417    #[cfg(target_os = "linux")]
418    const QUERY_TIMEOUT: std::time::Duration =
419        std::time::Duration::from_secs(if cfg!(test) { 30 } else { 2 });
420
421    /// Run `program` with `LC_ALL=C` for at most [`QUERY_TIMEOUT`], and
422    /// return its output when it succeeds. A missing program is `None`.
423    #[cfg(target_os = "linux")]
424    fn run(program: &str, args: &[&std::ffi::OsStr]) -> Option<String> {
425        use std::io::Read;
426        use std::process::{Command, Stdio};
427        use std::time::{Duration, Instant};
428
429        let mut child = Command::new(program)
430            .args(args)
431            .env("LC_ALL", "C")
432            .stdin(Stdio::null())
433            .stdout(Stdio::piped())
434            .stderr(Stdio::null())
435            .spawn()
436            .ok()?;
437        let deadline = Instant::now() + QUERY_TIMEOUT;
438        loop {
439            match child.try_wait() {
440                Ok(Some(status)) => {
441                    let mut out = String::new();
442                    child.stdout.take()?.read_to_string(&mut out).ok()?;
443                    return status.success().then_some(out);
444                }
445                Ok(None) if Instant::now() < deadline => {
446                    std::thread::sleep(Duration::from_millis(10));
447                }
448                _ => {
449                    let _ = child.kill();
450                    let _ = child.wait();
451                    tracing::debug!(program, "package database query timed out");
452                    return None;
453                }
454            }
455        }
456    }
457
458    /// The single package that `dpkg-query --search` names for exactly
459    /// `path`. Lines read `package: path`; a shared path lists several
460    /// packages, and diversions are not ownership.
461    pub(super) fn dpkg_owner(out: &str, path: &str) -> Option<String> {
462        out.lines()
463            .filter(|line| !line.starts_with("diversion "))
464            .filter_map(|line| line.split_once(": "))
465            .find(|(_, found)| found.trim_end() == path)
466            .map(|(owner, _)| owner.trim())
467            .filter(|owner| !owner.is_empty() && !owner.contains([',', ' ']))
468            .map(str::to_string)
469    }
470
471    /// The package name an rpm or pacman query prints: one word on the
472    /// first line.
473    pub(super) fn single_name(out: &str) -> Option<String> {
474        let name = out.lines().next()?.trim();
475        (!name.is_empty() && !name.contains(char::is_whitespace)).then(|| name.to_string())
476    }
477}
478
479#[cfg(test)]
480#[allow(clippy::unwrap_used, reason = "tests")]
481mod tests {
482    use super::*;
483
484    fn no_env(_: &str) -> Option<String> {
485        None
486    }
487
488    fn owner(path: &str) -> Option<(PackageManager, Option<String>)> {
489        owner_with(path, &no_env)
490    }
491
492    fn owner_with(
493        path: &str,
494        env: &dyn Fn(&str) -> Option<String>,
495    ) -> Option<(PackageManager, Option<String>)> {
496        match by_path(path, env)? {
497            Ownership::Managed { manager, package } => Some((manager, package)),
498            other => panic!("{other:?}"),
499        }
500    }
501
502    #[allow(
503        clippy::unnecessary_wraps,
504        reason = "compared with what owner() returns"
505    )]
506    fn some(manager: PackageManager, package: &str) -> Option<(PackageManager, Option<String>)> {
507        Some((manager, Some(package.to_string())))
508    }
509
510    #[test]
511    fn recognizes_unix_package_locations() {
512        use PackageManager as M;
513        assert_eq!(
514            owner("/nix/store/abc-zed-1.0/bin/zed"),
515            Some((M::Nix, None))
516        );
517        assert_eq!(owner("/snap/zed/42/usr/bin/zed"), some(M::Snap, "zed"));
518        assert_eq!(
519            owner("/var/lib/flatpak/app/dev.zed.Zed/x86_64/stable/active/files/bin/zed"),
520            some(M::Flatpak, "dev.zed.Zed")
521        );
522        assert_eq!(
523            owner("/opt/homebrew/Cellar/zed/1.0/bin/zed"),
524            some(M::Homebrew, "zed")
525        );
526        assert_eq!(
527            owner("/home/linuxbrew/.linuxbrew/Cellar/zed/1.0/bin/zed"),
528            some(M::Homebrew, "zed")
529        );
530        // Inside the Flatpak sandbox the app is in /app.
531        let flatpak = |name: &str| (name == "FLATPAK_ID").then(|| "dev.zed.Zed".to_string());
532        assert_eq!(
533            owner_with("/app/bin/zed", &flatpak),
534            some(M::Flatpak, "dev.zed.Zed")
535        );
536        assert_eq!(owner("/app/bin/zed"), None);
537        let brew = |name: &str| (name == "HOMEBREW_PREFIX").then(|| "/custom/brew".to_string());
538        assert_eq!(
539            owner_with("/custom/brew/Cellar/zed/1.0/bin/zed", &brew),
540            some(M::Homebrew, "zed")
541        );
542        for unowned in [
543            "/usr/bin/zed",
544            "/opt/zed/zed",
545            "/home/me/.local/zed.app/bin/zed",
546            "/Applications/Zed.app",
547            "/snap",
548        ] {
549            assert_eq!(owner(unowned), None, "{unowned}");
550        }
551    }
552
553    #[test]
554    fn recognizes_windows_package_locations() {
555        use PackageManager as M;
556        assert_eq!(
557            owner(r"C:\Users\me\scoop\apps\zed\current\zed.exe"),
558            some(M::Scoop, "zed")
559        );
560        assert_eq!(
561            owner(r"C:\ProgramData\scoop\apps\zed\1.0\zed.exe"),
562            some(M::Scoop, "zed")
563        );
564        let scoop = |name: &str| (name == "SCOOP").then(|| r"D:\Tools".to_string());
565        assert_eq!(
566            owner_with(r"D:\Tools\apps\zed\current\zed.exe", &scoop),
567            some(M::Scoop, "zed")
568        );
569        assert_eq!(
570            owner(r"C:\ProgramData\chocolatey\lib\zed\tools\zed.exe"),
571            some(M::Chocolatey, "zed")
572        );
573        assert_eq!(
574            owner(
575                r"C:\Users\me\AppData\Local\Microsoft\WinGet\Packages\ZedIndustries.Zed_Microsoft.Winget.Source_8wekyb3d8bbwe\zed.exe"
576            ),
577            some(M::Winget, "ZedIndustries.Zed")
578        );
579        assert_eq!(
580            owner(
581                r"C:\Program Files\WindowsApps\ZedIndustries.Zed_1.0.0.0_x64__abcdefghjkmnp\zed.exe"
582            ),
583            some(M::MicrosoftStore, "ZedIndustries.Zed")
584        );
585        for unowned in [
586            r"C:\Users\me\AppData\Local\Programs\Zed\zed.exe",
587            r"C:\Program Files\Zed\zed.exe",
588        ] {
589            assert_eq!(owner(unowned), None, "{unowned}");
590        }
591    }
592
593    #[test]
594    fn a_packager_explanation_wins() {
595        let dir = tempfile::tempdir().unwrap();
596        let hints = OwnershipHints::new().explanation(Some("Update with your distribution."));
597        assert_eq!(
598            ownership(dir.path(), &hints),
599            Ownership::Packager("Update with your distribution.".into())
600        );
601        assert_eq!(
602            OwnershipHints::new().explanation(Some("  ")).explanation,
603            None
604        );
605        assert_eq!(
606            OwnershipHints::new().explanation(None::<&str>).explanation,
607            None
608        );
609    }
610
611    #[test]
612    fn an_app_nothing_claims_updates_itself() {
613        let dir = tempfile::tempdir().unwrap();
614        let app = dir.path().join("app");
615        std::fs::write(&app, b"app").unwrap();
616        assert_eq!(
617            ownership(&app, &OwnershipHints::new()),
618            Ownership::SelfUpdate
619        );
620    }
621
622    #[test]
623    fn a_mac_app_store_receipt_claims_the_bundle() {
624        let dir = tempfile::tempdir().unwrap();
625        let app = dir.path().join("App.app");
626        std::fs::create_dir_all(app.join("Contents/_MASReceipt")).unwrap();
627        std::fs::write(app.join("Contents/_MASReceipt/receipt"), b"receipt").unwrap();
628        let found = ownership(&app, &OwnershipHints::new().query_package_database(false));
629        assert_eq!(found, Ownership::managed(PackageManager::MacAppStore, None));
630        assert_eq!(found.update_command(), None);
631    }
632
633    #[cfg(unix)]
634    #[test]
635    fn a_homebrew_cask_links_back_to_the_app() {
636        let dir = tempfile::tempdir().unwrap();
637        let apps = dir.path().join("Applications");
638        let app = apps.join("Zed.app");
639        std::fs::create_dir_all(app.join("Contents")).unwrap();
640        let prefix = dir.path().join("brew");
641        let version = prefix.join("Caskroom/zed/1.0");
642        std::fs::create_dir_all(&version).unwrap();
643        std::fs::create_dir_all(prefix.join("Caskroom/zed/.metadata")).unwrap();
644        let app = std::fs::canonicalize(&app).unwrap();
645        let prefixes = [prefix.to_string_lossy().into_owned()];
646        assert_eq!(homebrew_cask(&app, &prefixes), None);
647        std::os::unix::fs::symlink(&app, version.join("Zed.app")).unwrap();
648        assert_eq!(
649            homebrew_cask(&app, &prefixes),
650            Some(Ownership::managed(
651                PackageManager::Homebrew,
652                Some("zed".into())
653            ))
654        );
655        // Another app of the same name elsewhere is not the cask's.
656        let other = dir.path().join("Other/Zed.app");
657        std::fs::create_dir_all(&other).unwrap();
658        assert_eq!(
659            homebrew_cask(&std::fs::canonicalize(&other).unwrap(), &prefixes),
660            None
661        );
662    }
663
664    #[test]
665    fn parses_package_database_answers() {
666        let out =
667            "diversion by foo from: /usr/bin/zed\nzed: /usr/bin/zed\nzed-data: /usr/share/zed\n";
668        assert_eq!(
669            linux::dpkg_owner(out, "/usr/bin/zed").as_deref(),
670            Some("zed")
671        );
672        assert_eq!(
673            linux::dpkg_owner("libc6:amd64: /lib/x.so\n", "/lib/x.so").as_deref(),
674            Some("libc6:amd64")
675        );
676        assert_eq!(
677            linux::dpkg_owner("a, b: /usr/share/doc\n", "/usr/share/doc"),
678            None
679        );
680        assert_eq!(
681            linux::dpkg_owner("zed: /usr/bin/zed-cli\n", "/usr/bin/zed"),
682            None
683        );
684        assert_eq!(linux::single_name("zed\n").as_deref(), Some("zed"));
685        assert_eq!(
686            linux::single_name("file /x is not owned by any package\n"),
687            None
688        );
689        assert_eq!(linux::single_name(""), None);
690    }
691
692    #[cfg(target_os = "linux")]
693    #[test]
694    fn dpkg_claims_a_system_program() {
695        if !Path::new("/usr/bin/dpkg-query").exists() {
696            eprintln!("skipped: no dpkg-query");
697            return;
698        }
699        let found = ownership(Path::new("/usr/bin/ls"), &OwnershipHints::new());
700        assert_eq!(
701            found,
702            Ownership::managed(PackageManager::Apt, Some("coreutils".into()))
703        );
704        assert_eq!(
705            found.update_command().as_deref(),
706            Some("sudo apt install --only-upgrade coreutils")
707        );
708        let off = OwnershipHints::new().query_package_database(false);
709        assert_eq!(
710            ownership(Path::new("/usr/bin/ls"), &off),
711            Ownership::SelfUpdate
712        );
713    }
714
715    #[test]
716    fn describes_ownership() {
717        let brew = Ownership::managed(PackageManager::Homebrew, Some("zed".into()));
718        assert_eq!(brew.to_string(), "installed by Homebrew (zed)");
719        assert_eq!(brew.update_command().as_deref(), Some("brew upgrade zed"));
720        assert!(!brew.is_self_update());
721        assert_eq!(
722            Ownership::managed(PackageManager::Nix, None).to_string(),
723            "installed by Nix"
724        );
725        assert_eq!(
726            Ownership::managed(PackageManager::Nix, None).update_command(),
727            None
728        );
729        assert_eq!(
730            Ownership::managed(PackageManager::Pacman, None)
731                .update_command()
732                .as_deref(),
733            Some("sudo pacman -Syu")
734        );
735        assert!(Ownership::SelfUpdate.is_self_update());
736        assert_eq!(
737            Ownership::Packager("Use apt.".into()).to_string(),
738            "Use apt."
739        );
740    }
741}