1use 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
15pub const PACMAN_MANAGED_BEGIN: &str = "# === pacsea managed begin";
17pub const PACMAN_MANAGED_END: &str = "# === pacsea managed end";
19pub const MANAGED_DROPIN_FILE: &str = "pacsea-repos.conf";
21pub const DEFAULT_DROPIN_PATH: &str = "/etc/pacman.d/pacsea-repos.conf";
23pub const DEFAULT_MAIN_PACMAN_PATH: &str = "/etc/pacman.conf";
25pub const DEFAULT_DROPIN_SIG_LEVEL: &str = "Required DatabaseOptional";
27
28#[derive(Debug)]
39pub struct RepoApplyBundle {
40 pub summary_lines: Vec<String>,
42 pub commands: Vec<String>,
44}
45
46pub 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
77pub 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
195pub 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
221pub 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
288fn build_priv_command(tool: PrivilegeTool, inner: &str) -> String {
300 build_privilege_command(tool, inner)
301}
302
303struct MirrorFetch {
314 url: String,
316 dest: String,
318 name: String,
320}
321
322fn main_pacman_has_active_managed_marker(text: &str) -> bool {
333 text.lines().any(|line| line.trim() == PACMAN_MANAGED_BEGIN)
334}
335
336fn non_empty_trim(s: Option<&str>) -> bool {
344 s.map(str::trim).is_some_and(|t| !t.is_empty())
345}
346
347fn 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
372fn row_has_apply_source(r: &RepoRow) -> bool {
384 repo_row_declares_apply_sources(r)
385}
386
387fn 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
399fn row_enabled(r: &RepoRow) -> bool {
410 row_is_enabled_for_repos_conf(r)
411}
412
413fn 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
437fn 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
455fn 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
492fn 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
514fn 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
560fn 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
589fn 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
619fn 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
640fn 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
679fn 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
755fn 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
790fn 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
822fn 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
840fn 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
861pub 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}