1use std::fmt;
11use std::path::Path;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum PackageManager {
17 Homebrew,
19 MacAppStore,
21 Apt,
23 Rpm,
25 Pacman,
27 Nix,
29 Flatpak,
31 Snap,
33 Scoop,
35 Chocolatey,
37 Winget,
39 MicrosoftStore,
41}
42
43impl PackageManager {
44 #[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#[derive(Debug, Clone, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum Ownership {
74 SelfUpdate,
76 #[non_exhaustive]
78 Managed {
79 manager: PackageManager,
81 package: Option<String>,
83 },
84 Packager(String),
87}
88
89impl Ownership {
90 #[must_use]
92 pub fn managed(manager: PackageManager, package: Option<String>) -> Self {
93 Self::Managed { manager, package }
94 }
95
96 #[must_use]
98 pub fn is_self_update(&self) -> bool {
99 matches!(self, Self::SelfUpdate)
100 }
101
102 #[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#[derive(Debug, Clone, PartialEq, Eq)]
141#[non_exhaustive]
142pub struct OwnershipHints {
143 pub explanation: Option<String>,
149 pub query_package_database: bool,
152}
153
154impl Default for OwnershipHints {
155 fn default() -> Self {
156 Self::new()
157 }
158}
159
160impl OwnershipHints {
161 #[must_use]
163 pub fn new() -> Self {
164 Self {
165 explanation: None,
166 query_package_database: true,
167 }
168 }
169
170 #[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 #[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#[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
244fn 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
256struct 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 fn after(&self, marker: &str) -> Option<String> {
273 let start = self.lower.find(marker)? + marker.len();
274 self.component_at(start)
275 }
276
277 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
295fn 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 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 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#[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#[cfg(any(target_os = "linux", test))]
377mod linux {
378 #[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 #[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 #[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 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 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 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 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}