Skip to main content

retch_sysinfo/
packages.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Installed package count detection.
5//!
6//! Supports Pacman (Arch), Dpkg (Debian), XBPS (Void), RPM (Fedora/RHEL) on Linux,
7//! Homebrew (Formulae and Casks) and MacPorts on macOS, and Scoop/Chocolatey on Windows.
8
9/// Builds the SQLite URI used to read the RPM database without write access.
10///
11/// `/var/lib/rpm/rpmdb.sqlite` is owned by root (mode 0644) inside a root-owned directory,
12/// so an unprivileged process cannot create the journal sidecar files SQLite wants — and
13/// SQLite reports that as `attempt to write a readonly database` on the **query**, not on
14/// `open()`. Plain `mode=ro` is not enough for the same reason: it still needs to touch the
15/// directory. `immutable=1` promises SQLite the file will not change while it is open, which
16/// lets it skip locking and sidecars entirely, so the count succeeds as a normal user.
17///
18/// This is why `Packages` previously appeared only under `sudo`.
19#[cfg(any(not(any(target_os = "macos", target_os = "windows")), test))]
20fn rpm_db_uri(path: &str) -> String {
21    format!("file:{path}?immutable=1")
22}
23
24pub(crate) fn detect_packages() -> Option<usize> {
25    #[cfg(target_os = "macos")]
26    {
27        let mut count = 0;
28
29        for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
30            if let Ok(entries) = std::fs::read_dir(cellar_path) {
31                count += entries.filter_map(|e| e.ok()).count();
32            }
33        }
34
35        for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
36            if let Ok(entries) = std::fs::read_dir(cask_path) {
37                count += entries.filter_map(|e| e.ok()).count();
38            }
39        }
40
41        if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
42            count += entries.filter_map(|e| e.ok()).count();
43        }
44
45        if count > 0 {
46            Some(count)
47        } else {
48            None
49        }
50    }
51
52    #[cfg(target_os = "windows")]
53    {
54        let mut count = 0;
55
56        if let Some(home) = dirs::home_dir() {
57            let scoop_dir = std::env::var("SCOOP")
58                .map(std::path::PathBuf::from)
59                .unwrap_or_else(|_| home.join("scoop"));
60            if let Ok(entries) = std::fs::read_dir(scoop_dir.join("apps")) {
61                count += entries.filter_map(|e| e.ok()).count();
62            }
63        }
64
65        let choco_install = std::env::var("ChocolateyInstall")
66            .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
67        if let Ok(entries) = std::fs::read_dir(std::path::Path::new(&choco_install).join("lib")) {
68            count += entries.filter_map(|e| e.ok()).count();
69        }
70
71        if count > 0 {
72            Some(count)
73        } else {
74            None
75        }
76    }
77
78    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
79    {
80        if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
81            let count = entries.filter_map(|e| e.ok()).count();
82            if count > 0 {
83                return Some(count);
84            }
85        }
86
87        if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
88            let count = entries
89                .filter_map(|e| e.ok())
90                .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
91                .count();
92            if count > 0 {
93                return Some(count);
94            }
95        }
96
97        if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
98            let count: usize = entries
99                .filter_map(|e| e.ok())
100                .map(|e| {
101                    std::fs::read_dir(e.path())
102                        .map(|d| d.filter(|_| true).count())
103                        .unwrap_or(0)
104                })
105                .sum();
106            if count > 0 {
107                return Some(count);
108            }
109        }
110
111        if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
112            let count = entries
113                .filter_map(|e| e.ok())
114                .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
115                .count();
116            if count > 0 {
117                return Some(count);
118            }
119        }
120
121        let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
122        if std::path::Path::new(rpm_db).exists() {
123            use rusqlite::OpenFlags;
124            let flags = OpenFlags::SQLITE_OPEN_READ_ONLY
125                | OpenFlags::SQLITE_OPEN_URI
126                | OpenFlags::SQLITE_OPEN_NO_MUTEX;
127            match rusqlite::Connection::open_with_flags(rpm_db_uri(rpm_db), flags) {
128                Ok(conn) => {
129                    match conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
130                        row.get::<_, i64>(0)
131                    }) {
132                        Ok(count) if count > 0 => return Some(count as usize),
133                        Ok(_) => {}
134                        // Surfaced rather than swallowed: the read-only-database failure this
135                        // URI exists to prevent used to land here and vanish silently, so the
136                        // field simply disappeared with no clue why.
137                        Err(e) => {
138                            eprintln!("warning: failed to query RPM database at {rpm_db}: {e}");
139                        }
140                    }
141                }
142                Err(e) => {
143                    eprintln!("warning: failed to open RPM database at {rpm_db}: {e}");
144                }
145            }
146        }
147
148        None
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_rpm_db_uri_requests_immutable() {
158        // `immutable=1` is the load-bearing part: without it an unprivileged read of the
159        // root-owned rpmdb fails with "attempt to write a readonly database".
160        assert_eq!(
161            rpm_db_uri("/var/lib/rpm/rpmdb.sqlite"),
162            "file:/var/lib/rpm/rpmdb.sqlite?immutable=1"
163        );
164    }
165
166    #[test]
167    fn test_rpm_db_uri_is_a_file_uri() {
168        // The `file:` scheme is what makes SQLITE_OPEN_URI parse the query string at all;
169        // a bare path would silently ignore `immutable=1`.
170        let uri = rpm_db_uri("/tmp/some.sqlite");
171        assert!(uri.starts_with("file:"));
172        assert!(uri.contains("?immutable=1"));
173    }
174}