Skip to main content

pacsea/logic/repos/
apply_plan.rs

1//! Privileged apply plan: managed `pacsea-repos.conf` drop-in, optional `pacman.conf` markers, `pacman-key`.
2//!
3//! Planning runs without mutating the system. Final command strings use [`crate::logic::privilege::build_privilege_command`].
4
5use std::path::Path;
6
7use crate::install::shell_single_quote;
8use crate::logic::privilege::{PrivilegeTool, active_tool, build_privilege_command};
9use crate::util::curl_args;
10
11use super::config::{
12    RepoRow, ReposConfFile, repo_row_declares_apply_sources, row_is_enabled_for_repos_conf,
13};
14
15/// What: Start-of-block marker Pacsea appends to `/etc/pacman.conf`.
16pub const PACMAN_MANAGED_BEGIN: &str = "# === pacsea managed begin";
17/// What: End-of-block marker Pacsea appends to `/etc/pacman.conf`.
18pub const PACMAN_MANAGED_END: &str = "# === pacsea managed end";
19/// What: File name for the managed drop-in under `/etc/pacman.d/`.
20pub const MANAGED_DROPIN_FILE: &str = "pacsea-repos.conf";
21/// What: Default absolute path for the managed drop-in.
22pub const DEFAULT_DROPIN_PATH: &str = "/etc/pacman.d/pacsea-repos.conf";
23/// What: Default path to the main pacman configuration.
24pub const DEFAULT_MAIN_PACMAN_PATH: &str = "/etc/pacman.conf";
25/// What: Default `SigLevel` written to the managed drop-in when `sig_level` is omitted in `repos.conf`.
26pub const DEFAULT_DROPIN_SIG_LEVEL: &str = "Required DatabaseOptional";
27
28/// What: Human-readable summary lines plus shell commands for [`ExecutorRequest::Update`].
29///
30/// Inputs:
31/// - Created by [`build_repo_apply_bundle`].
32///
33/// Output:
34/// - Shown in logs / optional UI; `commands` are chained with `&&` by the executor.
35///
36/// Details:
37/// - Commands are already prefixed with `sudo` / `doas` via [`build_privilege_command`].
38#[derive(Debug)]
39pub struct RepoApplyBundle {
40    /// What: Short descriptions for user review (e.g. preflight log seeding).
41    pub summary_lines: Vec<String>,
42    /// What: Privilege-wrapped shell commands executed in order.
43    pub commands: Vec<String>,
44}
45
46/// What: Build privileged commands to apply enabled `[[repo]]` rows from `repos.conf`.
47///
48/// Inputs:
49/// - `repos`: Parsed document.
50/// - `main_pacman_text`: Current contents of the main `pacman.conf` (read from disk by the caller).
51/// - `selected_section`: `[repo]` name for the focused modal row (trimmed; case-insensitive). The matching `[[repo]]` must declare apply sources (`server`, `mirrorlist`, or `http`/`https` `mirrorlist_url`), even when `enabled = false`.
52///
53/// Output:
54/// - [`RepoApplyBundle`] or an actionable error string.
55///
56/// Details:
57/// - Regenerates the **entire** managed drop-in from all **enabled** rows that define `server`, local `mirrorlist`, or `mirrorlist_url` (`http`/`https`). When none are enabled, writes a short comment-only stub file.
58/// - Optionally downloads `mirrorlist_url` targets with curl (privileged) before writing the drop-in.
59/// - Runs `pacman-key --recv-keys` (with optional `--keyserver` from `key_server`) / `--lsign-key` for each distinct fingerprint when `key_id` parses to at least 8 hex digits.
60/// - Appends the managed `Include` block only if an **active** (uncommented) [`PACMAN_MANAGED_BEGIN`] line is absent.
61/// - Appends `pacman -Sy --noconfirm` after repo files are updated.
62/// - Uses [`active_tool`] for privilege wrapping; fails if no tool is configured.
63///
64/// # Errors
65///
66/// - When [`active_tool`] returns an error (no privilege tool configured).
67/// - When [`build_repo_apply_bundle_with_tool`] rejects the plan (bad selection, unsafe paths, invalid stanzas).
68pub fn build_repo_apply_bundle(
69    repos: &ReposConfFile,
70    main_pacman_text: &str,
71    selected_section: &str,
72) -> Result<RepoApplyBundle, String> {
73    let tool = active_tool()?;
74    build_repo_apply_bundle_with_tool(repos, main_pacman_text, selected_section, tool)
75}
76
77/// What: Same as [`build_repo_apply_bundle`] but accepts a fixed [`PrivilegeTool`] (for tests).
78///
79/// Inputs:
80/// - `repos`, `main_pacman_text`, `selected_section` as in [`build_repo_apply_bundle`].
81/// - `tool`: Privilege tool to wrap shell commands.
82///
83/// Output:
84/// - [`RepoApplyBundle`] or error.
85///
86/// Details:
87/// - Test-only consumers should call this to avoid depending on host `sudo`/`doas`.
88///
89/// # Errors
90///
91/// - When `selected_section` is empty, names no `[[repo]]`, or names a row without apply sources.
92/// - When drop-in or `pacman.conf` paths fail safety checks, or stanza rendering fails internally.
93pub fn build_repo_apply_bundle_with_tool(
94    repos: &ReposConfFile,
95    main_pacman_text: &str,
96    selected_section: &str,
97    tool: PrivilegeTool,
98) -> Result<RepoApplyBundle, String> {
99    let eligible: Vec<&RepoRow> = apply_eligible_rows(repos);
100    let want = selected_section.trim().to_lowercase();
101    if want.is_empty() {
102        return Err("No repository selected.".to_string());
103    }
104    let Some(selected_row) = find_repo_row_by_lower_name(repos, &want) else {
105        return Err(format!(
106            "Selected repository \"{selected_section}\" has no matching [[repo]] name in repos.conf."
107        ));
108    };
109    if !row_has_apply_source(selected_row) {
110        return Err(format!(
111            "Selected repository \"{selected_section}\" needs `server`, `mirrorlist`, or `http`/`https` mirrorlist_url in repos.conf \
112             before it can be applied."
113        ));
114    }
115
116    let mut summary_lines: Vec<String> = Vec::new();
117    let mut commands: Vec<String> = Vec::new();
118
119    if eligible.is_empty() {
120        summary_lines.push(
121            "No enabled [[repo]] rows: writing an empty managed drop-in (all custom repos disabled)."
122                .to_string(),
123        );
124    }
125
126    for r in &eligible {
127        let name = r.name.as_deref().map_or("", str::trim);
128        let has_ml = non_empty_trim(r.mirrorlist.as_deref());
129        let has_srv = non_empty_trim(r.server.as_deref());
130        let has_url = non_empty_trim(r.mirrorlist_url.as_deref());
131        if has_ml && has_url {
132            summary_lines.push(format!(
133                "Note: skipping mirrorlist_url for [{name}] (mirrorlist path is set)"
134            ));
135        }
136        if has_srv && has_url {
137            summary_lines.push(format!(
138                "Note: skipping mirrorlist_url for [{name}] (server is set)"
139            ));
140        }
141    }
142
143    let mirror_fetches = collect_mirror_fetch_steps(&eligible)?;
144    if !mirror_fetches.is_empty() {
145        ensure_curl_runnable()?;
146    }
147    for MirrorFetch { url, dest, name } in &mirror_fetches {
148        summary_lines.push(format!(
149            "Download mirrorlist for [{name}] via curl to {dest}"
150        ));
151        commands.push(privileged_curl_fetch_command(tool, url, dest)?);
152    }
153
154    let body = render_dropin_body(&eligible)?;
155
156    let key_specs = distinct_key_recv_specs(&eligible);
157    for (fpr, ks) in &key_specs {
158        let recv_inner = pacman_key_recv_inner(fpr, ks.as_deref());
159        summary_lines.push(format!("Receive signing key {fpr} (pacman-key)"));
160        commands.push(build_priv_command(tool, &recv_inner));
161        summary_lines.push(format!("Locally sign key {fpr} (pacman-key)"));
162        commands.push(build_priv_command(
163            tool,
164            &format!("pacman-key --lsign-key {}", shell_single_quote(fpr)),
165        ));
166    }
167
168    summary_lines.push(format!("Write managed drop-in {DEFAULT_DROPIN_PATH}"));
169    commands.push(write_dropin_command(tool, DEFAULT_DROPIN_PATH, &body)?);
170
171    if main_pacman_has_active_managed_marker(main_pacman_text) {
172        summary_lines.push(format!(
173            "Skip appending Include ({PACMAN_MANAGED_BEGIN} already active in {DEFAULT_MAIN_PACMAN_PATH})"
174        ));
175    } else {
176        summary_lines.push(format!(
177            "Append Pacsea Include block to {DEFAULT_MAIN_PACMAN_PATH}"
178        ));
179        commands.push(append_managed_include_command(
180            tool,
181            DEFAULT_MAIN_PACMAN_PATH,
182            DEFAULT_DROPIN_PATH,
183        )?);
184    }
185
186    summary_lines.push("Sync package databases (pacman -Sy --noconfirm)".to_string());
187    commands.push(build_priv_command(tool, "pacman -Sy --noconfirm"));
188
189    Ok(RepoApplyBundle {
190        summary_lines,
191        commands,
192    })
193}
194
195/// What: Plan privileged `pacman-key` receive + local sign for the selected repo row only.
196///
197/// Inputs:
198/// - `repos`: Parsed `repos.conf` document.
199/// - `selected_section`: Pacman `[repo]` name from the modal (case-insensitive trim).
200///
201/// Output:
202/// - [`RepoApplyBundle`] with two commands (recv, lsign) or an error string.
203///
204/// Details:
205/// - Requires `key_id` with at least 8 hex digits after normalization; optional `key_server` is passed
206///   to `pacman-key --recv-keys` the same way as full apply.
207/// - Does not write drop-ins or run `pacman -Sy`.
208///
209/// # Errors
210///
211/// - When [`active_tool`] returns an error (no privilege tool configured).
212/// - When no matching `[[repo]]` row exists, `key_id` is missing/invalid, or fingerprint normalization fails.
213pub fn build_repo_key_refresh_bundle(
214    repos: &ReposConfFile,
215    selected_section: &str,
216) -> Result<RepoApplyBundle, String> {
217    let tool = active_tool()?;
218    build_repo_key_refresh_bundle_with_tool(repos, selected_section, tool)
219}
220
221/// What: Same as [`build_repo_key_refresh_bundle`] but accepts a fixed [`PrivilegeTool`] (for tests).
222///
223/// Inputs:
224/// - `repos`, `selected_section` as in [`build_repo_key_refresh_bundle`].
225/// - `tool`: Privilege tool to wrap shell commands.
226///
227/// Output:
228/// - [`RepoApplyBundle`] or error.
229///
230/// Details:
231/// - Test callers avoid depending on host `sudo`/`doas` configuration.
232///
233/// # Errors
234///
235/// - When no matching `[[repo]]` row exists, `key_id` is missing/invalid, or fingerprint normalization fails.
236pub fn build_repo_key_refresh_bundle_with_tool(
237    repos: &ReposConfFile,
238    selected_section: &str,
239    tool: PrivilegeTool,
240) -> Result<RepoApplyBundle, String> {
241    let want = selected_section.trim().to_lowercase();
242    let row = repos
243        .repo
244        .iter()
245        .find(|r| {
246            r.name
247                .as_deref()
248                .map(str::trim)
249                .is_some_and(|n| n.to_lowercase() == want)
250        })
251        .ok_or_else(|| {
252            format!(
253                "No [[repo]] row named \"{}\" in repos.conf.",
254                selected_section.trim()
255            )
256        })?;
257    let kid = row
258        .key_id
259        .as_deref()
260        .map(str::trim)
261        .filter(|s| !s.is_empty())
262        .ok_or_else(|| {
263            "This repository has no key_id in repos.conf; nothing to refresh.".to_string()
264        })?;
265    let fpr = normalized_fingerprint(kid)
266        .ok_or_else(|| "key_id must contain at least 8 hexadecimal digits.".to_string())?;
267    let ks = row
268        .key_server
269        .as_deref()
270        .map(str::trim)
271        .filter(|s| !s.is_empty());
272    let mut summary_lines: Vec<String> = Vec::new();
273    let mut commands: Vec<String> = Vec::new();
274    let recv_inner = pacman_key_recv_inner(&fpr, ks);
275    summary_lines.push(format!("Receive signing key {fpr} (pacman-key)"));
276    commands.push(build_privilege_command(tool, &recv_inner));
277    summary_lines.push(format!("Locally sign key {fpr} (pacman-key)"));
278    commands.push(build_privilege_command(
279        tool,
280        &format!("pacman-key --lsign-key {}", shell_single_quote(&fpr)),
281    ));
282    Ok(RepoApplyBundle {
283        summary_lines,
284        commands,
285    })
286}
287
288/// What: Wrap a shell command with the active privilege tool (`sudo` / `doas`).
289///
290/// Inputs:
291/// - `tool`: Privilege backend.
292/// - `inner`: Inner shell snippet (no new wrapping).
293///
294/// Output:
295/// - Full command string for the executor.
296///
297/// Details:
298/// - Thin wrapper over [`build_privilege_command`].
299fn build_priv_command(tool: PrivilegeTool, inner: &str) -> String {
300    build_privilege_command(tool, inner)
301}
302
303/// What: One privileged `curl` fetch step generated from `mirrorlist_url`.
304///
305/// Inputs:
306/// - Fields filled by [`collect_mirror_fetch_steps`].
307///
308/// Output:
309/// - Used to build summary lines and shell commands.
310///
311/// Details:
312/// - `dest` is under `/etc/pacman.d/` with a Pacsea-specific filename.
313struct MirrorFetch {
314    /// Remote mirrorlist URL (`http`/`https` only).
315    url: String,
316    /// Absolute path written as root (matches `Include =` in the drop-in).
317    dest: String,
318    /// Pacman section name (for summaries).
319    name: String,
320}
321
322/// What: Detect an uncommented Pacsea marker line in main `pacman.conf` text.
323///
324/// Inputs:
325/// - `text`: Full file contents.
326///
327/// Output:
328/// - `true` when some line trims to exactly [`PACMAN_MANAGED_BEGIN`].
329///
330/// Details:
331/// - Lines such as `# === pacsea managed begin` do **not** match, so Apply may append the block again.
332fn main_pacman_has_active_managed_marker(text: &str) -> bool {
333    text.lines().any(|line| line.trim() == PACMAN_MANAGED_BEGIN)
334}
335
336/// What: Non-empty string after trim.
337///
338/// Inputs:
339/// - `s`: Optional string slice.
340///
341/// Output:
342/// - `true` when `s` is `Some` and not empty or whitespace-only.
343fn non_empty_trim(s: Option<&str>) -> bool {
344    s.map(str::trim).is_some_and(|t| !t.is_empty())
345}
346
347/// What: Return rows that participate in the managed drop-in.
348///
349/// Inputs:
350/// - `repos`: Parsed config.
351///
352/// Output:
353/// - Slice references in file order.
354///
355/// Details:
356/// - Skips `enabled = false`. Requires non-empty `name` and [`row_has_apply_source`].
357fn apply_eligible_rows(repos: &ReposConfFile) -> Vec<&RepoRow> {
358    repos
359        .repo
360        .iter()
361        .filter(|r| {
362            row_enabled(r)
363                && r.name
364                    .as_deref()
365                    .map(str::trim)
366                    .is_some_and(|s| !s.is_empty())
367                && row_has_apply_source(r)
368        })
369        .collect()
370}
371
372/// What: Whether a row defines any apply source (server, local mirrorlist, or HTTP(S) mirrorlist URL).
373///
374/// Inputs:
375/// - `r`: Parsed `[[repo]]` row.
376///
377/// Output:
378/// - `true` when at least one source is present and any URL uses `http://` or `https://`.
379///
380/// Details:
381/// - Non-HTTP `mirrorlist_url` values do not qualify alone (returns `false` when that is all there is).
382/// - A non-empty `server` is accepted here; [`render_dropin_body`] rejects values that are not HTTP(S) URLs.
383fn row_has_apply_source(r: &RepoRow) -> bool {
384    repo_row_declares_apply_sources(r)
385}
386
387/// What: Accept only obvious HTTP(S) mirrorlist URLs for privileged fetch.
388///
389/// Inputs:
390/// - `u`: Trimmed URL string.
391///
392/// Output:
393/// - `true` for `http://` or `https://` prefixes (ASCII case-insensitive).
394fn looks_like_http_url(u: &str) -> bool {
395    let lower = u.to_ascii_lowercase();
396    lower.starts_with("https://") || lower.starts_with("http://")
397}
398
399/// What: Determine whether a row is considered enabled for apply.
400///
401/// Inputs:
402/// - `r`: Parsed `[[repo]]` row.
403///
404/// Output:
405/// - `false` only when `enabled = false`; otherwise `true`.
406///
407/// Details:
408/// - `None` treats the row as enabled.
409fn row_enabled(r: &RepoRow) -> bool {
410    row_is_enabled_for_repos_conf(r)
411}
412
413/// What: Find a `[[repo]]` row by case-insensitive `name`.
414///
415/// Inputs:
416/// - `repos`: Parsed document.
417/// - `want_lower`: Lowercased section name (trimmed by caller).
418///
419/// Output:
420/// - Matching row reference, if any.
421///
422/// Details:
423/// - Ignores rows with empty `name` after trim.
424fn find_repo_row_by_lower_name<'a>(
425    repos: &'a ReposConfFile,
426    want_lower: &str,
427) -> Option<&'a RepoRow> {
428    repos.repo.iter().find(|r| {
429        r.name
430            .as_deref()
431            .map(str::trim)
432            .filter(|s| !s.is_empty())
433            .is_some_and(|n| n.to_lowercase() == want_lower)
434    })
435}
436
437/// What: Extract hex fingerprint material from a `key_id` string.
438///
439/// Inputs:
440/// - `key_id`: User-supplied key id (may contain spaces or `0x` prefixes).
441///
442/// Output:
443/// - Uppercase hex of length ≥ 8 when enough digits exist; otherwise `None`.
444///
445/// Details:
446/// - Non-hex characters are stripped; short ids are ignored.
447fn normalized_fingerprint(key_id: &str) -> Option<String> {
448    let hex: String = key_id.chars().filter(char::is_ascii_hexdigit).collect();
449    if hex.len() < 8 {
450        return None;
451    }
452    Some(hex.to_uppercase())
453}
454
455/// What: Distinct signing key fingerprints plus optional `key_server` for recv.
456///
457/// Inputs:
458/// - `rows`: Apply-eligible rows.
459///
460/// Output:
461/// - Pairs `(fingerprint, key_server)` in first-seen fingerprint order.
462///
463/// Details:
464/// - When the same fingerprint appears in multiple rows, `key_server` is the **first non-empty** trimmed value among those rows in file order.
465/// - Skips rows without `key_id` or with ids that normalize to fewer than 8 hex digits.
466fn distinct_key_recv_specs(rows: &[&RepoRow]) -> Vec<(String, Option<String>)> {
467    let mut specs: Vec<(String, Option<String>)> = Vec::new();
468    for r in rows {
469        let Some(k) = r.key_id.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
470            continue;
471        };
472        let Some(fpr) = normalized_fingerprint(k) else {
473            continue;
474        };
475        let ks = r
476            .key_server
477            .as_deref()
478            .map(str::trim)
479            .filter(|s| !s.is_empty())
480            .map(std::string::ToString::to_string);
481        if let Some(i) = specs.iter().position(|(f, _)| f == &fpr) {
482            if specs[i].1.is_none() {
483                specs[i].1 = ks;
484            }
485        } else {
486            specs.push((fpr, ks));
487        }
488    }
489    specs
490}
491
492/// What: Build inner shell for `pacman-key --recv-keys` with optional `--keyserver`.
493///
494/// Inputs:
495/// - `fpr`: Uppercase hex fingerprint.
496/// - `key_server`: Optional keyserver hostname or URL.
497///
498/// Output:
499/// - Unwrapped command string passed to [`build_privilege_command`].
500///
501/// Details:
502/// - Uses [`shell_single_quote`] for all variable fragments.
503fn pacman_key_recv_inner(fpr: &str, key_server: Option<&str>) -> String {
504    let qf = shell_single_quote(fpr);
505    let Some(ks) = key_server.map(str::trim).filter(|s| !s.is_empty()) else {
506        return format!("pacman-key --recv-keys {qf}");
507    };
508    format!(
509        "pacman-key --keyserver {} --recv-keys {qf}",
510        shell_single_quote(ks)
511    )
512}
513
514/// What: Plan privileged curl downloads for rows that rely on `mirrorlist_url`.
515///
516/// Inputs:
517/// - `rows`: Apply-eligible rows (file order preserved).
518///
519/// Output:
520/// - Fetch descriptors, or an error when a URL is not HTTP(S) or paths are invalid.
521///
522/// Details:
523/// - Skips rows that have `server` or `mirrorlist` set (those take precedence; see summary notes in the bundle builder).
524fn collect_mirror_fetch_steps(rows: &[&RepoRow]) -> Result<Vec<MirrorFetch>, String> {
525    let mut out = Vec::new();
526    for r in rows {
527        if non_empty_trim(r.server.as_deref()) || non_empty_trim(r.mirrorlist.as_deref()) {
528            continue;
529        }
530        let Some(url_raw) = r
531            .mirrorlist_url
532            .as_deref()
533            .map(str::trim)
534            .filter(|s| !s.is_empty())
535        else {
536            continue;
537        };
538        if !looks_like_http_url(url_raw) {
539            let name = r.name.as_deref().map_or("", str::trim);
540            return Err(format!(
541                "repos.conf: mirrorlist_url for [{name}] must start with http:// or https://"
542            ));
543        }
544        let name = r
545            .name
546            .as_deref()
547            .map(str::trim)
548            .filter(|s| !s.is_empty())
549            .ok_or_else(|| "repos.conf: mirrorlist_url row missing name (internal)".to_string())?;
550        let dest = mirror_url_dest_path(name)?;
551        out.push(MirrorFetch {
552            url: url_raw.to_string(),
553            dest,
554            name: name.to_string(),
555        });
556    }
557    Ok(out)
558}
559
560/// What: Verify `curl` runs so `mirrorlist_url` steps can succeed.
561///
562/// Inputs:
563/// - None.
564///
565/// Output:
566/// - `Ok(())` when `curl --version` succeeds.
567///
568/// Details:
569/// - Uses [`crate::util::curl::curl_binary_path`] for the executable.
570fn ensure_curl_runnable() -> Result<(), String> {
571    let bin = crate::util::curl::curl_binary_path();
572    match std::process::Command::new(bin)
573        .arg("--version")
574        .stdin(std::process::Stdio::null())
575        .stdout(std::process::Stdio::null())
576        .stderr(std::process::Stdio::null())
577        .status()
578    {
579        Ok(s) if s.success() => Ok(()),
580        Ok(_) => Err(format!(
581            "curl ('{bin}') is required for mirrorlist_url but did not run successfully. Install curl or use mirrorlist = \"/path\" instead."
582        )),
583        Err(e) => Err(format!(
584            "Could not run curl ('{bin}'): {e}. Install curl or use mirrorlist = \"/path\" instead."
585        )),
586    }
587}
588
589/// What: One privileged curl invocation: fetch URL to a path under `/etc/pacman.d/`.
590///
591/// Inputs:
592/// - `tool`: Privilege wrapper.
593/// - `url`: HTTP(S) URL (passed through [`curl_args`]).
594/// - `dest`: Absolute destination path.
595///
596/// Output:
597/// - Full privileged command string.
598///
599/// Details:
600/// - Each argv token is [`shell_single_quote`]d; adds `-o dest` via `curl_args` extras.
601fn privileged_curl_fetch_command(
602    tool: PrivilegeTool,
603    url: &str,
604    dest: &str,
605) -> Result<String, String> {
606    if !is_safe_abs_path(dest) {
607        return Err("Refusing unsafe mirrorlist destination path.".to_string());
608    }
609    let bin = crate::util::curl::curl_binary_path();
610    let argv = curl_args(url, &["-o", dest]);
611    let mut parts = vec![shell_single_quote(bin)];
612    for a in argv {
613        parts.push(shell_single_quote(&a));
614    }
615    let inner = parts.join(" ");
616    Ok(build_privilege_command(tool, &inner))
617}
618
619/// What: Stable on-disk path for a fetched mirrorlist (`Include =` target).
620///
621/// Inputs:
622/// - `section`: Repo section `name`.
623///
624/// Output:
625/// - Absolute path under `/etc/pacman.d/`.
626///
627/// Details:
628/// - Slug is sanitized; a short hash suffix avoids collisions when names normalize alike.
629/// - Hash uses FNV-1a (32-bit) so filenames stay stable across Rust compiler versions (unlike `DefaultHasher`).
630fn mirror_url_dest_path(section: &str) -> Result<String, String> {
631    let slug = sanitize_repo_slug(section)?;
632    let short = stable_fnv1a_u32(section.trim().as_bytes());
633    let p = format!("/etc/pacman.d/pacsea-mirror-{slug}-{short:x}.list");
634    if !is_safe_abs_path(&p) {
635        return Err("Refusing unsafe mirrorlist destination path.".to_string());
636    }
637    Ok(p)
638}
639
640/// What: Sanitize repo `name` into a path slug (lowercase, safe chars).
641///
642/// Inputs:
643/// - `section`: Raw section name.
644///
645/// Output:
646/// - Non-empty slug or error.
647fn sanitize_repo_slug(section: &str) -> Result<String, String> {
648    let mut out = String::new();
649    for c in section.trim().to_lowercase().chars() {
650        if c.is_ascii_alphanumeric() {
651            out.push(c);
652        } else {
653            out.push('_');
654        }
655    }
656    while out.contains("__") {
657        out = out.replace("__", "_");
658    }
659    let out = out.trim_matches('_').to_string();
660    if out.is_empty() {
661        return Err(
662            "repos.conf: repo `name` sanitizes to an empty path slug for mirrorlist_url."
663                .to_string(),
664        );
665    }
666    let out = if out.len() > 48 {
667        out[..48].trim_end_matches('_').to_string()
668    } else {
669        out
670    };
671    if out.is_empty() {
672        return Err(
673            "repos.conf: repo `name` too short after sanitizing for mirrorlist_url.".to_string(),
674        );
675    }
676    Ok(out)
677}
678
679/// What: Render `[repo]` stanzas for the managed drop-in file.
680///
681/// Inputs:
682/// - `rows`: Eligible rows.
683///
684/// Output:
685/// - Full file text ending with a newline.
686///
687/// Details:
688/// - Default `SigLevel` is [`DEFAULT_DROPIN_SIG_LEVEL`] when omitted.
689/// - `mirrorlist_url`-only rows emit `Include =` to the same path as [`mirror_url_dest_path`].
690/// - Non-empty `server` values must start with `http://` or `https://` (same policy as `mirrorlist_url` fetches).
691fn render_dropin_body(rows: &[&RepoRow]) -> Result<String, String> {
692    if rows.is_empty() {
693        return Ok(
694            "# Pacsea managed repositories\n# No enabled [[repo]] rows in repos.conf.\n"
695                .to_string(),
696        );
697    }
698    let mut out = String::new();
699    for r in rows {
700        let name = r
701            .name
702            .as_deref()
703            .map(str::trim)
704            .filter(|s| !s.is_empty())
705            .ok_or_else(|| "repos.conf: missing name on eligible row (internal)".to_string())?;
706        out.push('[');
707        out.push_str(name);
708        out.push_str("]\n");
709        let sl_line = r
710            .sig_level
711            .as_deref()
712            .map(str::trim)
713            .filter(|s| !s.is_empty())
714            .unwrap_or(DEFAULT_DROPIN_SIG_LEVEL);
715        out.push_str("SigLevel = ");
716        out.push_str(sl_line);
717        out.push('\n');
718        if let Some(srv) = r.server.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
719            if !looks_like_http_url(srv) {
720                return Err(format!(
721                    "repos.conf: server for [{name}] must start with http:// or https://"
722                ));
723            }
724            out.push_str("Server = ");
725            out.push_str(srv);
726            out.push('\n');
727        } else if let Some(inc) = r
728            .mirrorlist
729            .as_deref()
730            .map(str::trim)
731            .filter(|s| !s.is_empty())
732        {
733            out.push_str("Include = ");
734            out.push_str(inc);
735            out.push('\n');
736        } else if non_empty_trim(r.mirrorlist_url.as_deref())
737            && !non_empty_trim(r.server.as_deref())
738            && !non_empty_trim(r.mirrorlist.as_deref())
739        {
740            let dest = mirror_url_dest_path(name)?;
741            out.push_str("Include = ");
742            out.push_str(&dest);
743            out.push('\n');
744        } else {
745            return Err(
746                "Internal: row missing server, mirrorlist, and resolvable mirrorlist_url"
747                    .to_string(),
748            );
749        }
750        out.push('\n');
751    }
752    Ok(out)
753}
754
755/// What: Build a privileged command that writes the drop-in file atomically via `printf` and `tee`.
756///
757/// Inputs:
758/// - `tool`: Privilege backend.
759/// - `dropin_path`: Absolute path under `/etc/pacman.d/` (validated).
760/// - `body`: Full drop-in file contents.
761///
762/// Output:
763/// - Wrapped `sh -c` command string or a refusal error.
764///
765/// Details:
766/// - Each line is passed through [`shell_single_quote`] before `printf`.
767fn write_dropin_command(
768    tool: PrivilegeTool,
769    dropin_path: &str,
770    body: &str,
771) -> Result<String, String> {
772    if !dropin_path
773        .chars()
774        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '_'))
775    {
776        return Err("Refusing unsafe drop-in path.".to_string());
777    }
778    let pieces: Vec<String> = body.lines().map(shell_single_quote).collect();
779    if pieces.is_empty() {
780        return Err("Generated drop-in is empty.".to_string());
781    }
782    let printf_args = pieces.join(" ");
783    let inner = format!("printf '%s\\n' {printf_args} | tee {dropin_path} > /dev/null");
784    Ok(build_privilege_command(
785        tool,
786        &format!("sh -c {}", shell_single_quote(&inner)),
787    ))
788}
789
790/// What: Build a privileged command appending the managed `Include` block to main `pacman.conf`.
791///
792/// Inputs:
793/// - `tool`: Privilege backend.
794/// - `main_path`: Absolute path to `pacman.conf` (validated).
795/// - `dropin_path`: Absolute path passed to `Include =` (validated).
796///
797/// Output:
798/// - Wrapped `sh -c` command or path safety error.
799///
800/// Details:
801/// - Appends marker lines plus `Include = …` plus blank line via `printf` and `>>`.
802fn append_managed_include_command(
803    tool: PrivilegeTool,
804    main_path: &str,
805    dropin_path: &str,
806) -> Result<String, String> {
807    if !is_safe_abs_path(main_path) || !is_safe_abs_path(dropin_path) {
808        return Err("Refusing unsafe pacman path.".to_string());
809    }
810    let include_line = format!("Include = {dropin_path}");
811    let b = shell_single_quote(PACMAN_MANAGED_BEGIN);
812    let inc = shell_single_quote(&include_line);
813    let e = shell_single_quote(PACMAN_MANAGED_END);
814    let q_main = shell_single_quote(main_path);
815    let inner = format!("printf '%s\\n' {b} {inc} {e} '' >> {q_main}");
816    Ok(build_privilege_command(
817        tool,
818        &format!("sh -c {}", shell_single_quote(&inner)),
819    ))
820}
821
822/// What: Reject path strings that could break out of expected `/etc/...` layouts.
823///
824/// Inputs:
825/// - `p`: Path to validate.
826///
827/// Output:
828/// - `true` when the path is absolute and uses only safe characters.
829///
830/// Details:
831/// - Allows alphanumeric, `/`, `-`, `.`, `_`.
832/// - Rejects `..` path segments so traversal like `/etc/pacman.d/../../tmp/x` cannot pass.
833fn is_safe_abs_path(p: &str) -> bool {
834    p.starts_with('/')
835        && !p.contains("..")
836        && p.chars()
837            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '_'))
838}
839
840/// What: 32-bit FNV-1a hash over bytes (stable across toolchains).
841///
842/// Inputs:
843/// - `data`: Bytes to hash (e.g. UTF-8 of a trimmed repo name).
844///
845/// Output:
846/// - Unsigned 32-bit digest.
847///
848/// Details:
849/// - Used for mirrorlist filenames so upgrades to a new `rustc` do not orphan on-disk paths.
850fn stable_fnv1a_u32(data: &[u8]) -> u32 {
851    const OFFSET_BASIS: u32 = 0x811c_9dc5;
852    const PRIME: u32 = 0x0100_0193;
853    let mut h = OFFSET_BASIS;
854    for &b in data {
855        h ^= u32::from(b);
856        h = h.wrapping_mul(PRIME);
857    }
858    h
859}
860
861/// What: Read main `pacman.conf` for planning (production convenience).
862///
863/// Inputs:
864/// - `path`: Usually [`DEFAULT_MAIN_PACMAN_PATH`].
865///
866/// Output:
867/// - File text or an IO error message.
868///
869/// Details:
870/// - Callers may inject fixture text in tests instead.
871///
872/// # Errors
873///
874/// - When `path` cannot be read (permission, missing file, I/O error); message includes the path.
875pub fn read_main_pacman_conf_text(path: &Path) -> Result<String, String> {
876    std::fs::read_to_string(path).map_err(|e| {
877        format!(
878            "Could not read {}: {e}. Apply needs the live pacman configuration.",
879            path.display()
880        )
881    })
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use crate::logic::repos::{ReposConfFile, load_resolve_repos_from_str};
888
889    #[test]
890    fn bundle_contains_recv_lsign_write() {
891        let toml = r#"
892[[repo]]
893name = "myrepo"
894results_filter = "mine"
895server = "https://example.com/$repo/os/$arch"
896key_id = "AABBCCDDEEFF0011"
897"#;
898        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
899        let file = ReposConfFile { repo };
900        let main = "\n";
901        let b = build_repo_apply_bundle_with_tool(&file, main, "myrepo", PrivilegeTool::Sudo)
902            .expect("bundle");
903        assert!(
904            b.commands
905                .iter()
906                .any(|c| c.contains("pacman-key --recv-keys"))
907        );
908        assert!(
909            b.commands
910                .iter()
911                .any(|c| c.contains("pacman-key --lsign-key"))
912        );
913        assert!(b.commands.iter().any(|c| c.contains("pacsea-repos.conf")));
914        assert!(
915            b.summary_lines
916                .iter()
917                .any(|s| s.contains("Append Pacsea Include"))
918        );
919        assert!(
920            b.commands
921                .iter()
922                .any(|c| c.contains("pacman -Sy --noconfirm"))
923        );
924    }
925
926    #[test]
927    fn recv_uses_keyserver_when_configured() {
928        let toml = r#"
929[[repo]]
930name = "myrepo"
931results_filter = "mine"
932server = "https://example.com/$repo/os/$arch"
933key_id = "AABBCCDDEEFF0011"
934key_server = "keyserver.ubuntu.com"
935"#;
936        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
937        let file = ReposConfFile { repo };
938        let b = build_repo_apply_bundle_with_tool(&file, "\n", "myrepo", PrivilegeTool::Sudo)
939            .expect("b");
940        assert!(b.commands.iter().any(|c| {
941            c.contains("pacman-key")
942                && c.contains("--keyserver")
943                && c.contains("keyserver.ubuntu.com")
944        }));
945    }
946
947    #[test]
948    fn skips_include_when_marker_present() {
949        let toml = r#"
950[[repo]]
951name = "myrepo"
952results_filter = "mine"
953server = "https://x.test"
954"#;
955        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
956        let file = ReposConfFile { repo };
957        let main = format!(
958            "\n{PACMAN_MANAGED_BEGIN}\nInclude = /etc/pacman.d/pacsea-repos.conf\n{PACMAN_MANAGED_END}\n"
959        );
960        let b = build_repo_apply_bundle_with_tool(&file, &main, "myrepo", PrivilegeTool::Sudo)
961            .expect("b");
962        assert!(
963            b.summary_lines
964                .iter()
965                .any(|s| s.contains("Skip appending") && s.contains("already active"))
966        );
967        assert!(
968            !b.commands
969                .iter()
970                .any(|c| { c.contains(">>") && c.contains("/etc/pacman.conf") })
971        );
972    }
973
974    #[test]
975    fn commented_marker_line_still_appends_include() {
976        let toml = r#"
977[[repo]]
978name = "myrepo"
979results_filter = "mine"
980server = "https://x.test"
981"#;
982        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
983        let file = ReposConfFile { repo };
984        let main = format!("# {PACMAN_MANAGED_BEGIN}\n");
985        let b = build_repo_apply_bundle_with_tool(&file, &main, "myrepo", PrivilegeTool::Sudo)
986            .expect("b");
987        assert!(
988            b.summary_lines
989                .iter()
990                .any(|s| s.contains("Append Pacsea Include"))
991        );
992        assert!(
993            b.commands
994                .iter()
995                .any(|c| { c.contains(">>") && c.contains("/etc/pacman.conf") })
996        );
997    }
998
999    #[test]
1000    fn mirrorlist_url_fetch_and_include() {
1001        let toml = r#"
1002[[repo]]
1003name = "myrepo"
1004results_filter = "mine"
1005mirrorlist_url = "https://archlinux.org/mirrorlist/all/http/"
1006"#;
1007        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
1008        let file = ReposConfFile { repo };
1009        let b = build_repo_apply_bundle_with_tool(&file, "\n", "myrepo", PrivilegeTool::Sudo)
1010            .expect("b");
1011        assert!(
1012            b.commands
1013                .iter()
1014                .any(|c| c.contains("curl") && c.contains("archlinux.org"))
1015        );
1016        assert!(
1017            b.commands
1018                .iter()
1019                .any(|c| c.contains("pacsea-mirror-myrepo-"))
1020        );
1021    }
1022
1023    #[test]
1024    fn selected_row_without_server_errors() {
1025        let toml = r#"
1026[[repo]]
1027name = "myrepo"
1028results_filter = "mine"
1029server = "https://x.test"
1030"#;
1031        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
1032        let file = ReposConfFile { repo };
1033        let err = build_repo_apply_bundle_with_tool(&file, "", "other", PrivilegeTool::Sudo)
1034            .expect_err("err");
1035        assert!(
1036            err.contains("no matching [[repo]]") || err.contains("needs"),
1037            "{err}"
1038        );
1039    }
1040
1041    #[test]
1042    fn all_disabled_rows_still_writes_empty_dropin() {
1043        let toml = r#"
1044[[repo]]
1045name = "myrepo"
1046results_filter = "mine"
1047server = "https://x.test"
1048enabled = false
1049"#;
1050        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
1051        let file = ReposConfFile { repo };
1052        let b = build_repo_apply_bundle_with_tool(&file, "\n", "myrepo", PrivilegeTool::Sudo)
1053            .expect("bundle");
1054        assert!(
1055            b.summary_lines
1056                .iter()
1057                .any(|s| s.contains("empty managed drop-in")),
1058            "{:?}",
1059            b.summary_lines
1060        );
1061        assert!(b.commands.iter().any(|c| c.contains("pacsea-repos.conf")));
1062        assert!(b.commands.iter().any(|c| c.contains("pacman -Sy")));
1063    }
1064
1065    #[test]
1066    fn key_refresh_bundle_only_recv_and_lsign() {
1067        let toml = r#"
1068[[repo]]
1069name = "myrepo"
1070results_filter = "mine"
1071server = "https://example.com/$repo/os/$arch"
1072key_id = "AABBCCDDEEFF0011"
1073"#;
1074        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
1075        let file = ReposConfFile { repo };
1076        let b = build_repo_key_refresh_bundle_with_tool(&file, "myrepo", PrivilegeTool::Sudo)
1077            .expect("bundle");
1078        assert_eq!(b.commands.len(), 2);
1079        assert!(
1080            b.commands
1081                .iter()
1082                .any(|c| c.contains("pacman-key") && c.contains("--recv-keys"))
1083        );
1084        assert!(
1085            b.commands
1086                .iter()
1087                .any(|c| c.contains("pacman-key --lsign-key"))
1088        );
1089        assert!(!b.commands.iter().any(|c| c.contains("pacsea-repos.conf")));
1090        assert!(!b.commands.iter().any(|c| c.contains("pacman -Sy")));
1091    }
1092
1093    #[test]
1094    fn key_refresh_uses_keyserver_when_configured() {
1095        let toml = r#"
1096[[repo]]
1097name = "kr"
1098results_filter = "k"
1099server = "https://x.test"
1100key_id = "AABBCCDDEEFF0011"
1101key_server = "keyserver.ubuntu.com"
1102"#;
1103        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
1104        let file = ReposConfFile { repo };
1105        let b =
1106            build_repo_key_refresh_bundle_with_tool(&file, "kr", PrivilegeTool::Doas).expect("b");
1107        assert!(b.commands.iter().any(|c| {
1108            c.contains("pacman-key")
1109                && c.contains("--keyserver")
1110                && c.contains("keyserver.ubuntu.com")
1111        }));
1112    }
1113
1114    #[test]
1115    fn is_safe_abs_path_rejects_dotdot() {
1116        assert!(!is_safe_abs_path("/etc/pacman.d/../../tmp/evil"));
1117        assert!(is_safe_abs_path("/etc/pacman.d/pacsea-repos.conf"));
1118    }
1119
1120    #[test]
1121    fn server_must_use_http_or_https() {
1122        let toml = r#"
1123[[repo]]
1124name = "bad"
1125results_filter = "b"
1126server = "file:///etc/shadow"
1127"#;
1128        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
1129        let file = ReposConfFile { repo };
1130        let err = build_repo_apply_bundle_with_tool(&file, "\n", "bad", PrivilegeTool::Sudo)
1131            .expect_err("err");
1132        assert!(err.contains("http://") && err.contains("https://"), "{err}");
1133    }
1134
1135    #[test]
1136    fn dropin_command_contains_safe_default_sig_level() {
1137        let toml = r#"
1138[[repo]]
1139name = "myrepo"
1140results_filter = "mine"
1141server = "https://example.com/$repo/os/$arch"
1142"#;
1143        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
1144        let file = ReposConfFile { repo };
1145        let b = build_repo_apply_bundle_with_tool(&file, "\n", "myrepo", PrivilegeTool::Sudo)
1146            .expect("bundle");
1147        assert!(
1148            b.commands
1149                .iter()
1150                .any(|c| c.contains(DEFAULT_DROPIN_SIG_LEVEL)),
1151            "expected drop-in write to use default sig level"
1152        );
1153    }
1154
1155    #[test]
1156    fn key_refresh_errors_without_key_id() {
1157        let toml = r#"
1158[[repo]]
1159name = "nok"
1160results_filter = "n"
1161server = "https://x.test"
1162"#;
1163        let (repo, _) = load_resolve_repos_from_str(toml).expect("parse");
1164        let file = ReposConfFile { repo };
1165        let err = build_repo_key_refresh_bundle_with_tool(&file, "nok", PrivilegeTool::Sudo)
1166            .expect_err("err");
1167        assert!(err.contains("key_id"), "{err}");
1168    }
1169}