Skip to main content

pacsea/logic/repos/
modal_data.rs

1//! Build data for the read-only Repositories modal.
2
3use std::collections::HashSet;
4use std::path::Path;
5use std::process::Command;
6
7use crate::state::types::{RepositoryKeyTrust, RepositoryModalRow, RepositoryPacmanStatus};
8use crate::theme::resolve_repos_config_path;
9
10use super::config::load_resolve_repos_from_str;
11use super::pacman_conf::{PacmanRepoPresence, scan_pacman_conf_path};
12
13/// What: Merge `repos.conf`, pacman scan, and optional keyring snapshot into modal rows.
14///
15/// Inputs:
16/// - `repos_path`: Resolved `repos.conf` path, if any.
17/// - `pacman_conf_path`: Usually `/etc/pacman.conf`.
18///
19/// Output:
20/// - `(rows, repos_conf_error, pacman_warnings)` for the Repositories modal UI.
21///
22/// Details:
23/// - When `repos_path` is `None`, rows stay empty (user has no config file yet).
24/// - Uses one batched `pacman-key --list-keys` when possible for fingerprint checks.
25pub fn build_repositories_modal_fields(
26    repos_path: Option<&Path>,
27    pacman_conf_path: &Path,
28) -> (Vec<RepositoryModalRow>, Option<String>, Vec<String>) {
29    let scan = scan_pacman_conf_path(pacman_conf_path);
30    let pacman_warnings = scan.warnings.clone();
31    let key_index = pacman_trusted_key_index();
32
33    let mut repos_conf_error: Option<String> = None;
34    let mut rows: Vec<RepositoryModalRow> = Vec::new();
35
36    let content_opt: Option<String> = repos_path.and_then(|p| match std::fs::read_to_string(p) {
37        Ok(c) => Some(c),
38        Err(e) => {
39            repos_conf_error = Some(format!("Could not read repos.conf ({}): {e}", p.display()));
40            None
41        }
42    });
43
44    if let Some(content) = content_opt {
45        match load_resolve_repos_from_str(&content) {
46            Ok((repo_rows, _map)) => {
47                for r in repo_rows {
48                    let name = r.name.as_deref().unwrap_or("").trim().to_string();
49                    let rf = r.results_filter.as_deref().unwrap_or("").trim().to_string();
50                    let presence = scan.presence_of(&name);
51                    let (pacman_status, source_hint) = map_presence(presence);
52                    let key_trust = r
53                        .key_id
54                        .as_deref()
55                        .map(str::trim)
56                        .filter(|s| !s.is_empty())
57                        .map_or(RepositoryKeyTrust::NotApplicable, |kid| {
58                            classify_key(kid, key_index.as_ref())
59                        });
60                    rows.push(RepositoryModalRow {
61                        pacman_section_name: name,
62                        results_filter_display: rf,
63                        pacman_status,
64                        source_hint,
65                        key_trust,
66                    });
67                }
68            }
69            Err(e) => {
70                repos_conf_error = Some(e);
71            }
72        }
73    }
74
75    (rows, repos_conf_error, pacman_warnings)
76}
77
78/// What: Convenience wrapper using resolved Pacsea paths and system `pacman.conf`.
79///
80/// Inputs:
81/// - None (reads [`resolve_repos_config_path`] and `/etc/pacman.conf`).
82///
83/// Output:
84/// - Same tuple as [`build_repositories_modal_fields`].
85///
86/// Details:
87/// - Call from UI handlers when opening the modal.
88#[must_use]
89pub fn build_repositories_modal_fields_default()
90-> (Vec<RepositoryModalRow>, Option<String>, Vec<String>) {
91    build_repositories_modal_fields(
92        resolve_repos_config_path().as_deref(),
93        Path::new("/etc/pacman.conf"),
94    )
95}
96
97/// What: Map scanner presence to UI enums and a short source hint.
98///
99/// Inputs:
100/// - `presence`: Merged [`PacmanRepoPresence`].
101///
102/// Output:
103/// - Status + optional file name hint.
104///
105/// Details:
106/// - Uses the file name from the scanner when available.
107fn map_presence(presence: PacmanRepoPresence) -> (RepositoryPacmanStatus, Option<String>) {
108    match presence {
109        PacmanRepoPresence::Absent => (RepositoryPacmanStatus::Absent, None),
110        PacmanRepoPresence::Active { source } => (
111            RepositoryPacmanStatus::Active,
112            source.as_deref().and_then(short_source_hint),
113        ),
114        PacmanRepoPresence::Commented { source } => (
115            RepositoryPacmanStatus::Commented,
116            source.as_deref().and_then(short_source_hint),
117        ),
118    }
119}
120
121/// What: Reduce a path to its file name for compact modal display.
122///
123/// Inputs:
124/// - `p`: Filesystem path from the scanner.
125///
126/// Output:
127/// - File name string, if any.
128fn short_source_hint(p: &std::path::Path) -> Option<String> {
129    p.file_name()
130        .map(|s| s.to_string_lossy().into_owned())
131        .filter(|s| !s.is_empty())
132}
133
134/// What: Index of `OpenPGP` v4-style fingerprints from `pacman-key --list-keys` output.
135///
136/// Inputs:
137/// - Built only via [`TrustedKeyIndex::from_pacman_list_keys_stdout`].
138///
139/// Output:
140/// - N/A (data container).
141///
142/// Details:
143/// - Stores full 40-hex fingerprints plus `gpg`-style long (16) and short (8) key ids as **suffixes**
144///   of those fingerprints so lookups are token-bounded instead of substring checks on merged hex.
145struct TrustedKeyIndex {
146    /// Uppercase 40-hex fingerprints parsed from listing lines.
147    full_fingerprints: HashSet<String>,
148    /// Last 16 hex digits of each fingerprint (`gpg` long key id).
149    long_key_ids: HashSet<String>,
150    /// Last 8 hex digits of each fingerprint (`gpg` short key id).
151    short_key_ids: HashSet<String>,
152}
153
154impl TrustedKeyIndex {
155    /// What: Parse human-readable `pacman-key --list-keys` stdout into fingerprint/id sets.
156    ///
157    /// Inputs:
158    /// - `stdout`: Raw listing text (`pacman-key` / gpg human format).
159    ///
160    /// Output:
161    /// - Populated index (possibly empty when no 40-hex runs are present).
162    ///
163    /// Details:
164    /// - Treats each **maximal** contiguous `ASCII` hex run on a line as a candidate; only runs of
165    ///   length **40** are fingerprints (matching `gpg` human listings of primary key material).
166    /// - Shorter/longer runs are ignored so arbitrary in-fingerprint substrings cannot register as
167    ///   trusted key ids.
168    fn from_pacman_list_keys_stdout(stdout: &str) -> Self {
169        let mut full_fingerprints = HashSet::new();
170        let mut long_key_ids = HashSet::new();
171        let mut short_key_ids = HashSet::new();
172        for line in stdout.lines() {
173            for run in hex_digit_runs(line) {
174                if run.len() == 40 {
175                    let fp = run.to_uppercase();
176                    long_key_ids.insert(fp[24..40].to_string());
177                    short_key_ids.insert(fp[32..40].to_string());
178                    full_fingerprints.insert(fp);
179                }
180            }
181        }
182        Self {
183            full_fingerprints,
184            long_key_ids,
185            short_key_ids,
186        }
187    }
188}
189
190/// What: Extract maximal contiguous ASCII hexadecimal runs from one text line.
191///
192/// Inputs:
193/// - `line`: Single line from gpg-style key listing output.
194///
195/// Output:
196/// - Borrowed substrings of `line` that are hex-only runs.
197///
198/// Details:
199/// - Keeps fingerprint tokens line-bounded; callers filter by run length.
200fn hex_digit_runs(line: &str) -> Vec<&str> {
201    let mut runs = Vec::new();
202    let mut start: Option<usize> = None;
203    for (i, c) in line.char_indices() {
204        if c.is_ascii_hexdigit() {
205            if start.is_none() {
206                start = Some(i);
207            }
208        } else if let Some(st) = start.take() {
209            runs.push(&line[st..i]);
210        }
211    }
212    if let Some(st) = start {
213        runs.push(&line[st..]);
214    }
215    runs
216}
217
218/// What: Run `pacman-key --list-keys` once and parse fingerprints for trust checks.
219///
220/// Inputs:
221/// - None (uses `PATH` to find `pacman-key`).
222///
223/// Output:
224/// - `None` when the tool is missing or the invocation fails.
225///
226/// Details:
227/// - Parses discrete 40-hex fingerprint tokens per line so matches cannot span key boundaries or
228///   arbitrary positions inside a fingerprint.
229fn pacman_trusted_key_index() -> Option<TrustedKeyIndex> {
230    if which::which("pacman-key").is_err() {
231        return None;
232    }
233    let out = Command::new("pacman-key")
234        .arg("--list-keys")
235        .output()
236        .ok()?;
237    if !out.status.success() {
238        return None;
239    }
240    Some(TrustedKeyIndex::from_pacman_list_keys_stdout(
241        &String::from_utf8_lossy(&out.stdout),
242    ))
243}
244
245/// What: Decide if a configured `key_id` appears in the pacman keyring listing.
246///
247/// Inputs:
248/// - `key_id`: Value from `repos.conf`.
249/// - `index`: Output of [`pacman_trusted_key_index`].
250///
251/// Output:
252/// - [`RepositoryKeyTrust`] variant.
253///
254/// Details:
255/// - Requires at least 8 hex digits after normalization (same floor as repo key refresh).
256/// - **40** hex chars: exact fingerprint match only.
257/// - **16** hex chars: `gpg` long key id (last 16 of a listed fingerprint) only.
258/// - **8** hex chars: `gpg` short key id (last 8 of a listed fingerprint) only.
259/// - Other lengths are **Unknown** so partial in-fingerprint substrings cannot mark a key trusted.
260fn classify_key(key_id: &str, index: Option<&TrustedKeyIndex>) -> RepositoryKeyTrust {
261    let needle: String = key_id
262        .chars()
263        .filter(char::is_ascii_hexdigit)
264        .collect::<String>()
265        .to_uppercase();
266    if needle.len() < 8 {
267        return RepositoryKeyTrust::Unknown;
268    }
269    let Some(idx) = index else {
270        return RepositoryKeyTrust::Unknown;
271    };
272    let trusted = match needle.len() {
273        40 => idx.full_fingerprints.contains(&needle),
274        16 => idx.long_key_ids.contains(&needle),
275        8 => idx.short_key_ids.contains(&needle),
276        _ => return RepositoryKeyTrust::Unknown,
277    };
278    if trusted {
279        RepositoryKeyTrust::Trusted
280    } else {
281        RepositoryKeyTrust::NotTrusted
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use tempfile::tempdir;
289
290    #[test]
291    fn merge_row_with_active_pacman_section() {
292        let tmp = tempdir().expect("td");
293        let repo_file = tmp.path().join("repos.conf");
294        std::fs::write(
295            &repo_file,
296            r#"
297[[repo]]
298name = "myrepo"
299results_filter = "mine"
300key_id = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
301"#,
302        )
303        .expect("write repos");
304        let pac = tmp.path().join("pacman.conf");
305        std::fs::write(&pac, "[myrepo]\nServer = https://x.test\n").expect("write pacman");
306        let (rows, err, _) =
307            build_repositories_modal_fields(Some(repo_file.as_path()), pac.as_path());
308        assert!(err.is_none(), "{err:?}");
309        assert_eq!(rows.len(), 1);
310        assert_eq!(rows[0].pacman_section_name, "myrepo");
311        assert_eq!(rows[0].results_filter_display, "mine");
312        assert_eq!(rows[0].pacman_status, RepositoryPacmanStatus::Active);
313    }
314
315    #[test]
316    fn classify_key_rejects_short_id_not_matching_any_listed_fingerprint_suffix() {
317        // Two valid 40-hex fingerprints whose short ids are all-1 / all-2; "5678ABCD" must not match
318        // via substring tricks across separate keys.
319        let stdout = concat!(
320            "1111111111111111111111111111111111111111\n",
321            "2222222222222222222222222222222222222222\n",
322        );
323        let index = TrustedKeyIndex::from_pacman_list_keys_stdout(stdout);
324        assert_eq!(
325            classify_key("5678ABCD", Some(&index)),
326            RepositoryKeyTrust::NotTrusted
327        );
328    }
329
330    #[test]
331    fn classify_key_rejects_short_id_that_exists_only_across_merged_hex_runs() {
332        // Regression: a single hex-only blob of the whole listing would contain "5678ABCD" across
333        // the join between fp1’s tail (...12345678) and fp2’s head (ABCDEF01...).
334        let stdout = concat!(
335            "pub\n      0000000000000000000000000000000012345678\n",
336            "uid           a <a@test>\n",
337            "\n",
338            "pub\n      ABCDEF0123456789ABCDEF0123456789012345678\n",
339        );
340        let index = TrustedKeyIndex::from_pacman_list_keys_stdout(stdout);
341        assert_eq!(
342            classify_key("5678ABCD", Some(&index)),
343            RepositoryKeyTrust::NotTrusted
344        );
345    }
346
347    #[test]
348    fn classify_key_trusted_when_short_id_matches_listed_fingerprint_suffix() {
349        let stdout = "0000000000000000000000000000000012345678\n";
350        let index = TrustedKeyIndex::from_pacman_list_keys_stdout(stdout);
351        assert_eq!(
352            classify_key("12345678", Some(&index)),
353            RepositoryKeyTrust::Trusted
354        );
355    }
356
357    #[test]
358    fn classify_key_unknown_when_normalized_id_shorter_than_eight_hex() {
359        let stdout = "0000000000000000000000000000000012345678\n";
360        let index = TrustedKeyIndex::from_pacman_list_keys_stdout(stdout);
361        assert_eq!(
362            classify_key("1234567", Some(&index)),
363            RepositoryKeyTrust::Unknown
364        );
365    }
366
367    #[test]
368    fn classify_key_unknown_when_keyring_index_unavailable() {
369        assert_eq!(classify_key("12345678", None), RepositoryKeyTrust::Unknown);
370    }
371
372    #[test]
373    fn classify_accepts_full_long_and_short_key_forms() {
374        let fp = "0123456789ABCDEF0123456789ABCDEF01234567";
375        let listing = format!("pub rsa4096 2020-01-01 [SC]\n      {fp}\n");
376        let idx = TrustedKeyIndex::from_pacman_list_keys_stdout(&listing);
377        assert_eq!(classify_key(fp, Some(&idx)), RepositoryKeyTrust::Trusted);
378        assert_eq!(
379            classify_key("89ABCDEF01234567", Some(&idx)),
380            RepositoryKeyTrust::Trusted
381        );
382        assert_eq!(
383            classify_key("01234567", Some(&idx)),
384            RepositoryKeyTrust::Trusted
385        );
386    }
387
388    #[test]
389    fn classify_rejects_short_id_that_is_only_inside_fingerprint() {
390        let fp = "0123456789ABCDEF0123456789ABCDEF01234567";
391        let listing = format!("pub\n      {fp}\n");
392        let idx = TrustedKeyIndex::from_pacman_list_keys_stdout(&listing);
393        assert_eq!(
394            classify_key("89ABCDEF", Some(&idx)),
395            RepositoryKeyTrust::NotTrusted
396        );
397    }
398
399    #[test]
400    fn classify_rejects_sixteen_hex_that_is_not_long_key_id_suffix() {
401        let fp = "0123456789ABCDEF0123456789ABCDEF01234567";
402        let listing = format!("pub\n      {fp}\n");
403        let idx = TrustedKeyIndex::from_pacman_list_keys_stdout(&listing);
404        assert_eq!(
405            classify_key("456789ABCDEF0123", Some(&idx)),
406            RepositoryKeyTrust::NotTrusted
407        );
408    }
409
410    #[test]
411    fn classify_unknown_for_nonstandard_hex_lengths() {
412        let fp = "0123456789ABCDEF0123456789ABCDEF01234567";
413        let listing = format!("pub\n      {fp}\n");
414        let idx = TrustedKeyIndex::from_pacman_list_keys_stdout(&listing);
415        assert_eq!(
416            classify_key("0123456789ABC", Some(&idx)),
417            RepositoryKeyTrust::Unknown
418        );
419    }
420
421    #[test]
422    fn hex_digit_runs_splits_non_hex_separators() {
423        assert_eq!(
424            hex_digit_runs("      ABCD0123EFABCD0123EFABCD0123EFABCD0123"),
425            vec!["ABCD0123EFABCD0123EFABCD0123EFABCD0123"]
426        );
427    }
428
429    #[test]
430    fn repos_parse_error_surfaces() {
431        let tmp = tempdir().expect("td");
432        let repo_file = tmp.path().join("repos.conf");
433        std::fs::write(
434            &repo_file,
435            r#"
436[[repo]]
437preset = "unsupported"
438"#,
439        )
440        .expect("bad");
441        let pac = tmp.path().join("pacman.conf");
442        std::fs::write(&pac, "\n").expect("write");
443        let (rows, err, _) =
444            build_repositories_modal_fields(Some(repo_file.as_path()), pac.as_path());
445        assert!(err.is_some());
446        assert!(rows.is_empty());
447    }
448}