//! APT sources.list management //! Provides a simple structure for managing APT repository sources //! //! Entries carry enough information (kind, signed-by, trusted, enabled) to //! be written back without loss, and remember the file they were loaded //! from ([`SourceEntry::origin`]) so that saving writes each entry back to //! its own file, in that file's own format. use crate::context::{self, Context}; use crate::debian::control::{Paragraph, parse_paragraphs, write_paragraph}; use std::error::Error; use std::path::{Path, PathBuf}; use std::sync::Arc; /// Sources file owned by pkh, holding entries added by pkh (e.g. PPAs). /// /// New entries never end up in distro-managed files. const PKH_ADDED_PATH: &str = "/etc/apt/sources.list.d/pkh-added.list"; /// Suffix appended to an origin file path to build its backup path const BACKUP_SUFFIX: &str = ".pkh-backup"; /// Kind of packages provided by a source entry #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceKind { /// Binary packages ('deb') Deb, /// Source packages ('deb-src') DebSrc, } impl SourceKind { /// Token used in legacy lines and deb822 'Types' fields pub fn as_str(self) -> &'static str { match self { SourceKind::Deb => "deb", SourceKind::DebSrc => "deb-src", } } /// Parse a type token ('deb' or 'deb-src') fn parse(token: &str) -> Option { match token { "deb" => Some(SourceKind::Deb), "deb-src" => Some(SourceKind::DebSrc), _ => None, } } } /// On-disk format of a sources file #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceFormat { /// Legacy one-line-per-entry format (sources.list, *.list) Legacy, /// deb822 format (*.sources) Deb822, } /// File a source entry was loaded from #[derive(Debug, Clone, PartialEq, Eq)] pub struct SourceOrigin { /// Path of the origin file, inside the context pub path: PathBuf, /// Format of the origin file pub format: SourceFormat, } /// Represents a single source entry in sources.list #[derive(Debug, Clone, PartialEq, Eq)] pub struct SourceEntry { /// Is the source enabled? pub enabled: bool, /// Kind of packages provided by the source (binary or source) pub kind: SourceKind, /// Source components (universe, main, contrib) pub components: Vec, /// Source architectures (amd64, riscv64, arm64) pub architectures: Vec, /// Keyring the repository is signed with ('signed-by' option) pub signed_by: Option, /// Explicit trust flag ('trusted' option), when set pub trusted: Option, /// Source URI pub uri: String, /// Source suites (series-pocket) pub suite: Vec, /// File and format the entry was loaded from /// /// Entries without an origin are new (e.g. repositories added by pkh); /// they are saved to the pkh-owned added-sources file. pub origin: Option, } impl SourceEntry { /// Build entries from a single deb822 stanza /// /// A stanza declaring several types ('Types: deb deb-src') yields one /// entry per type. fn from_deb822_stanza(p: &Paragraph) -> Vec { // apt defaults 'Types' to 'deb' when the field is absent let mut kinds: Vec = p .get("Types") .unwrap_or("deb") .split_whitespace() .filter_map(SourceKind::parse) .collect(); if kinds.is_empty() { kinds.push(SourceKind::Deb); } let enabled = p .get("Enabled") .map(|v| { let v = v.trim(); !v.eq_ignore_ascii_case("no") && !v.eq_ignore_ascii_case("false") }) .unwrap_or(true); let signed_by = p .get("Signed-By") .map(str::trim) .filter(|v| !v.is_empty()) .map(str::to_string); let trusted = p .get("Trusted") .map(|v| v.trim().eq_ignore_ascii_case("yes")); let uri = p.get("URIs").unwrap_or("").trim().to_string(); if uri.is_empty() { return Vec::new(); } let suite: Vec = p .get("Suites") .unwrap_or("") .split_whitespace() .map(|s| s.to_string()) .collect(); let components: Vec = p .get("Components") .unwrap_or("") .split_whitespace() .map(|s| s.to_string()) .collect(); let architectures: Vec = p .get("Architectures") .unwrap_or("") .split_whitespace() .map(|s| s.to_string()) .collect(); kinds .into_iter() .map(|kind| SourceEntry { enabled, kind, components: components.clone(), architectures: architectures.clone(), signed_by: signed_by.clone(), trusted, uri: uri.clone(), suite: suite.clone(), origin: None, }) .collect() } /// Parse a line describing a legacy source entry pub fn from_legacy(data: &str) -> Option { let raw = data.lines().next()?.trim(); if raw.is_empty() { return None; } // Entries commented out with '#' are disabled, not deleted let (enabled, line) = match raw.strip_prefix('#') { Some(rest) => (false, rest.trim_start()), None => (true, raw), }; // Parse legacy deb line format: // deb [arch=... signed-by=... trusted=...] uri suite [components...] // Extract bracket parameters first let mut architectures = Vec::new(); let mut signed_by = None; let mut trusted = None; let mut line_without_brackets = line.to_string(); // Find and process bracket parameters if let Some(start_bracket) = line.find('[') && let Some(end_bracket) = line.find(']') { let bracket_content = &line[start_bracket + 1..end_bracket]; // Parse parameters inside brackets for param in bracket_content.split_whitespace() { if let Some(values) = param.strip_prefix("arch=") { architectures = values.split(',').map(|s| s.trim().to_string()).collect(); } else if let Some(keyring) = param.strip_prefix("signed-by=") { signed_by = Some(keyring.trim_matches('"').to_string()); } else if let Some(flag) = param.strip_prefix("trusted=") { trusted = Some(flag.eq_ignore_ascii_case("yes") || flag == "1"); } } // Remove the bracket section from the line line_without_brackets = line[..start_bracket].to_string() + &line[end_bracket + 1..]; } // Trim and split the remaining line let line_without_brackets = line_without_brackets.trim(); let parts: Vec<&str> = line_without_brackets.split_whitespace().collect(); // We need at least: type, uri, suite if parts.len() < 3 { return None; } let kind = SourceKind::parse(parts[0])?; let uri = parts[1].to_string(); let suite = vec![parts[2].to_string()]; let components: Vec = parts[3..].iter().map(|&s| s.to_string()).collect(); Some(SourceEntry { enabled, kind, components, architectures, signed_by, trusted, uri, suite, origin: None, }) } /// Convert this source entry to legacy format /// /// Entries holding several suites are rendered as one line per suite. /// Disabled entries are commented out. pub fn to_legacy(&self) -> String { let mut result = String::new(); // Legacy entries contain one suite per line for suite in &self.suite { if !self.enabled { result.push_str("# "); } result.push_str(self.kind.as_str()); // Bracket options: architectures, signing keyring and trust let mut options = Vec::new(); if !self.architectures.is_empty() { options.push(format!("arch={}", self.architectures.join(","))); } if let Some(keyring) = &self.signed_by { if keyring.contains(char::is_whitespace) { options.push(format!("signed-by=\"{keyring}\"")); } else { options.push(format!("signed-by={keyring}")); } } if let Some(trusted) = self.trusted { options.push(format!("trusted={}", if trusted { "yes" } else { "no" })); } if !options.is_empty() { result.push_str(" ["); result.push_str(&options.join(" ")); result.push(']'); } // Add URI and suite result.push(' '); result.push_str(&self.uri); result.push(' '); result.push_str(suite); // Add components if !self.components.is_empty() { result.push(' '); result.push_str(&self.components.join(" ")); } result.push('\n'); } result } /// Convert this source entry to a deb822 stanza (with a trailing newline) pub fn to_deb822(&self) -> String { let mut stanza = Paragraph::new(); stanza.set("Types", self.kind.as_str()); stanza.set("URIs", &self.uri); stanza.set("Suites", &self.suite.join(" ")); stanza.set("Components", &self.components.join(" ")); if let Some(keyring) = &self.signed_by { stanza.set("Signed-By", keyring); } if !self.architectures.is_empty() { stanza.set("Architectures", &self.architectures.join(" ")); } if let Some(trusted) = self.trusted { stanza.set("Trusted", if trusted { "yes" } else { "no" }); } if !self.enabled { stanza.set("Enabled", "no"); } write_paragraph(&stanza) } } /// Parse a 'source list' string in deb822 format into a SourceEntry vector /// /// A stanza declaring several types ('Types: deb deb-src') yields one entry /// per type. pub fn parse_deb822(data: &str) -> Vec { parse_paragraphs(data) .iter() .flat_map(SourceEntry::from_deb822_stanza) .collect() } /// Parse a 'source list' string in legacy format into a SourceEntry vector pub fn parse_legacy(data: &str) -> Vec { data.split('\n') .flat_map(SourceEntry::from_legacy) .collect() } /// Load sources from context (or current context by default) /// /// Reads the deb822 distro sources (ubuntu.sources or debian.sources), the /// legacy '/etc/apt/sources.list' and the pkh-owned added-sources file when /// they exist. Every entry remembers the file and format it came from. pub fn load(ctx: Option>) -> Result, Box> { let mut sources = Vec::new(); let ctx = ctx.unwrap_or_else(context::current); // Try DEB822 format first (Ubuntu 24.04+ and Debian Trixie+) load_file( &ctx, "/etc/apt/sources.list.d/ubuntu.sources", SourceFormat::Deb822, &mut sources, )?; load_file( &ctx, "/etc/apt/sources.list.d/debian.sources", SourceFormat::Deb822, &mut sources, )?; // Fall back to legacy format load_file( &ctx, "/etc/apt/sources.list", SourceFormat::Legacy, &mut sources, )?; // Entries added by a previous pkh run load_file(&ctx, PKH_ADDED_PATH, SourceFormat::Legacy, &mut sources)?; Ok(sources) } /// Save sources back to the context /// /// Each entry is written back to the file it was loaded from /// ([`SourceEntry::origin`]), in that file's format. Entries without an /// origin (e.g. repositories added by pkh) go to the pkh-owned /// added-sources file in legacy format, never to distro-managed files. /// /// Files whose rendered content is byte-identical to their current content /// are left untouched; otherwise a '.pkh-backup' copy is created once /// before the first overwrite. pub fn save(ctx: Option>, sources: Vec) -> Result<(), Box> { let ctx = ctx.unwrap_or_else(context::current); for (path, _format, content) in plan_writes(&sources) { let original = if ctx.exists(&path)? { Some(ctx.read_file(&path)?) } else { None }; if original.as_deref() == Some(content.as_str()) { // Nothing changed: leave the file untouched continue; } // One-time backup before overwriting an existing file if original.is_some() { let backup = backup_path(&path); if !ctx.exists(&backup)? { ctx.copy_path(&path, &backup)?; } } ctx.write_file(&path, &content)?; } Ok(()) } /// Load entries from one sources file, if it exists, tagging them with /// their origin fn load_file( ctx: &Context, path: &str, format: SourceFormat, out: &mut Vec, ) -> Result<(), Box> { let path = PathBuf::from(path); if !ctx.exists(&path)? { return Ok(()); } let content = ctx.read_file(&path)?; let mut entries = match format { SourceFormat::Deb822 => parse_deb822(&content), SourceFormat::Legacy => parse_legacy(&content), }; for entry in &mut entries { entry.origin = Some(SourceOrigin { path: path.clone(), format, }); } out.append(&mut entries); Ok(()) } /// Compute the writes needed to persist entries: one /// (path, format, content) triple per destination file, entries kept in order /// /// Entries without an origin are routed to the pkh-owned added-sources file. fn plan_writes(sources: &[SourceEntry]) -> Vec<(PathBuf, SourceFormat, String)> { let mut plan: Vec<(PathBuf, SourceFormat, Vec<&SourceEntry>)> = Vec::new(); for entry in sources { let (path, format) = match &entry.origin { Some(origin) => (origin.path.clone(), origin.format), None => (PathBuf::from(PKH_ADDED_PATH), SourceFormat::Legacy), }; if let Some((_, _, group)) = plan.iter_mut().find(|(p, _, _)| *p == path) { group.push(entry); } else { plan.push((path, format, vec![entry])); } } plan.into_iter() .map(|(path, format, entries)| { let content = match format { // Legacy entries end with '\n': plain concatenation, no // blank lines in between SourceFormat::Legacy => entries.iter().map(|e| e.to_legacy()).collect(), // deb822 stanzas end with '\n': a '\n' join gives one blank // line between stanzas SourceFormat::Deb822 => entries .iter() .map(|e| e.to_deb822()) .collect::>() .join("\n"), }; (path, format, content) }) .collect() } /// Backup path for a sources file ('.pkh-backup') fn backup_path(path: &Path) -> PathBuf { let mut with_suffix = path.as_os_str().to_os_string(); with_suffix.push(BACKUP_SUFFIX); PathBuf::from(with_suffix) } #[cfg(test)] mod tests { use super::*; use crate::context::ContextConfig; #[tokio::test] async fn test_parse_deb822() { let deb822 = "\ Types: deb\n\ URIs: http://fr.archive.ubuntu.com/ubuntu/\n\ Suites: questing questing-updates questing-backports\n\ Components: main restricted universe multiverse\n\ Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\ Architectures: amd64\n\ \n\ Types: deb\n\ URIs: http://security.ubuntu.com/ubuntu/\n\ Suites: questing-security\n\ Components: main restricted universe multiverse\n\ Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\ Architectures: amd64\n\ \n\ Types: deb\n\ URIs: http://ports.ubuntu.com/ubuntu-ports/\n\ Suites: questing questing-updates questing-backports\n\ Components: main restricted universe multiverse\n\ Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\ Architectures: riscv64\n\ "; let sources = parse_deb822(deb822); assert_eq!(sources.len(), 3); assert_eq!(sources[0].uri, "http://fr.archive.ubuntu.com/ubuntu/"); assert_eq!(sources[0].architectures, vec!["amd64"]); assert_eq!( sources[0].suite, vec!["questing", "questing-updates", "questing-backports"] ); assert_eq!( sources[0].components, vec!["main", "restricted", "universe", "multiverse"] ); assert_eq!(sources[1].uri, "http://security.ubuntu.com/ubuntu/"); assert_eq!(sources[1].architectures, vec!["amd64"]); assert_eq!(sources[1].suite, vec!["questing-security"]); assert_eq!( sources[1].components, vec!["main", "restricted", "universe", "multiverse"] ); assert_eq!(sources[2].uri, "http://ports.ubuntu.com/ubuntu-ports/"); assert_eq!(sources[2].architectures.len(), 1); assert_eq!(sources[2].architectures, vec!["riscv64"]); assert_eq!( sources[2].suite, vec!["questing", "questing-updates", "questing-backports"] ); assert_eq!( sources[2].components, vec!["main", "restricted", "universe", "multiverse"] ); } #[tokio::test] async fn test_parse_legacy() { let legacy = "\ deb [signed-by=\"/usr/share/keyrings/ubuntu-archive-keyring.gpg\" arch=amd64] http://archive.ubuntu.com/ubuntu resolute main universe\n\ deb [arch=amd64,i386 signed-by=\"/usr/share/keyrings/ubuntu-archive-keyring.gpg\"] http://archive.ubuntu.com/ubuntu resolute-updates main\n\ deb [signed-by=\"/usr/share/keyrings/ubuntu-archive-keyring.gpg\"] http://security.ubuntu.com/ubuntu resolute-security main\n\ "; let sources = parse_legacy(legacy); assert_eq!(sources.len(), 3); assert_eq!(sources[0].uri, "http://archive.ubuntu.com/ubuntu"); assert_eq!(sources[0].suite, vec!["resolute"]); assert_eq!(sources[0].components, vec!["main", "universe"]); assert_eq!(sources[0].architectures, vec!["amd64"]); assert_eq!(sources[1].uri, "http://archive.ubuntu.com/ubuntu"); assert_eq!(sources[1].suite, vec!["resolute-updates"]); assert_eq!(sources[1].components, vec!["main"]); assert_eq!(sources[1].architectures, vec!["amd64", "i386"]); assert_eq!(sources[2].uri, "http://security.ubuntu.com/ubuntu"); assert_eq!(sources[2].suite, vec!["resolute-security"]); assert_eq!(sources[2].components, vec!["main"]); } /// Legacy round-trip: kind, signed-by, trusted and arch are preserved, /// and rendering introduces no blank lines #[test] fn legacy_roundtrip_preserves_options() { let input = "\ deb [arch=amd64 signed-by=/k.gpg] http://x noble main\n\ deb-src http://x noble main\n\ deb [trusted=yes] http://x noble universe\n\ # deb [arch=i386] http://x noble main\n"; let sources = parse_legacy(input); assert_eq!(sources.len(), 4); assert_eq!(sources[0].kind, SourceKind::Deb); assert_eq!(sources[0].signed_by.as_deref(), Some("/k.gpg")); assert_eq!(sources[0].architectures, vec!["amd64"]); assert_eq!(sources[1].kind, SourceKind::DebSrc); assert_eq!(sources[2].trusted, Some(true)); assert!(!sources[3].enabled); // Render as a legacy file through the save planning path let origin = SourceOrigin { path: PathBuf::from("/etc/apt/sources.list"), format: SourceFormat::Legacy, }; let mut sources = sources; for entry in &mut sources { entry.origin = Some(origin.clone()); } let plan = plan_writes(&sources); assert_eq!(plan.len(), 1); let rendered = &plan[0].2; // Rendering is faithful: byte-identical and without blank lines assert_eq!(rendered, input); assert!(!rendered.contains("\n\n")); let reparsed = parse_legacy(rendered); assert_eq!(reparsed, parse_legacy(input)); } /// deb822 round-trip: multiple types are split into one entry per type, /// Signed-By and Enabled are preserved #[test] fn deb822_roundtrip_preserves_types_and_options() { let input = "\ Types: deb deb-src\n\ URIs: http://archive.ubuntu.com/ubuntu\n\ Suites: noble\n\ Components: main\n\ Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\ Enabled: false\n\ \n\ Types: deb\n\ URIs: http://archive.ubuntu.com/ubuntu\n\ Suites: noble-updates\n\ Components: main universe\n"; let sources = parse_deb822(input); // The first stanza declares two types: one entry per type assert_eq!(sources.len(), 3); assert_eq!(sources[0].kind, SourceKind::Deb); assert_eq!(sources[1].kind, SourceKind::DebSrc); assert_eq!(sources[2].kind, SourceKind::Deb); assert!(!sources[0].enabled); assert!(!sources[1].enabled); assert!(sources[2].enabled); assert_eq!( sources[0].signed_by.as_deref(), Some("/usr/share/keyrings/ubuntu-archive-keyring.gpg") ); assert_eq!(sources[1].signed_by, sources[0].signed_by); assert_eq!(sources[2].signed_by, None); // Render as a deb822 file through the save planning path let mut sources = sources; for entry in &mut sources { entry.origin = Some(SourceOrigin { path: PathBuf::from("/etc/apt/sources.list.d/ubuntu.sources"), format: SourceFormat::Deb822, }); } let plan = plan_writes(&sources); assert_eq!(plan.len(), 1); let rendered = &plan[0].2; let reparsed = parse_deb822(rendered); // Parse/render round-trip preserves the model (origin excepted) assert_eq!(reparsed, parse_deb822(input)); assert_eq!(reparsed[0].kind, SourceKind::Deb); assert_eq!(reparsed[1].kind, SourceKind::DebSrc); assert_eq!(reparsed[2].kind, SourceKind::Deb); assert!(!reparsed[0].enabled); assert!(!reparsed[1].enabled); assert!(reparsed[2].enabled); assert_eq!( reparsed[0].signed_by.as_deref(), Some("/usr/share/keyrings/ubuntu-archive-keyring.gpg") ); // 'Enabled' is only emitted for disabled entries assert_eq!(rendered.matches("Enabled: no").count(), 2); } /// Entries are routed to their origin file in its own format, and new /// entries (no origin) go to the pkh-owned added-sources file #[test] fn plan_writes_routes_by_origin() { let origin_a = SourceOrigin { path: PathBuf::from("/etc/apt/sources.list.d/ubuntu.sources"), format: SourceFormat::Deb822, }; let mut sources = parse_deb822( "Types: deb\nURIs: http://archive.ubuntu.com/ubuntu\nSuites: noble\nComponents: main\n", ); sources[0].origin = Some(origin_a.clone()); // Modify the origin-A entry and add a brand new (PPA) entry sources[0].components.push("universe".to_string()); sources.push(SourceEntry { enabled: true, kind: SourceKind::Deb, components: vec!["main".to_string()], architectures: vec![], signed_by: None, trusted: None, uri: "http://ppa.example.org/user/ppa/ubuntu".to_string(), suite: vec!["noble".to_string()], origin: None, }); let plan = plan_writes(&sources); assert_eq!(plan.len(), 2); assert_eq!(plan[0].0, origin_a.path); assert_eq!(plan[0].1, SourceFormat::Deb822); assert!(plan[0].2.starts_with("Types: deb\n")); assert!(plan[0].2.contains("main universe")); assert_eq!( plan[1].0, PathBuf::from("/etc/apt/sources.list.d/pkh-added.list") ); assert_eq!(plan[1].1, SourceFormat::Legacy); assert!(plan[1].2.starts_with("deb http://ppa.example.org/")); } /// save() leaves unchanged files untouched, and backs up existing files /// once before overwriting them; the backup also works for the /// pkh-owned added-sources file #[test] fn save_skips_unchanged_and_backs_up() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ubuntu.sources"); std::fs::write( &path, "Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n", ) .unwrap(); let ctx = Arc::new(Context::new(ContextConfig::Local).unwrap()); let origin = SourceOrigin { path: path.clone(), format: SourceFormat::Deb822, }; let mut entries = parse_deb822("Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n"); entries[0].origin = Some(origin.clone()); // Unchanged content: no write, no backup save(Some(ctx.clone()), entries.clone()).unwrap(); assert_eq!( std::fs::read_to_string(&path).unwrap(), "Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n" ); assert!(!backup_path(&path).exists()); // Modified content: backup created, file rewritten in its own format entries[0].components.push("universe".to_string()); save(Some(ctx.clone()), entries).unwrap(); assert_eq!( std::fs::read_to_string(backup_path(&path)).unwrap(), "Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n" ); assert_eq!( std::fs::read_to_string(&path).unwrap(), "Types: deb\nURIs: http://a\nSuites: noble\nComponents: main universe\n" ); // A second save does not overwrite the first backup let mut entries = parse_deb822("Types: deb\nURIs: http://a\nSuites: noble\nComponents: main universe\n"); entries[0].origin = Some(origin); entries[0].components.push("restricted".to_string()); save(Some(ctx.clone()), entries).unwrap(); assert_eq!( std::fs::read_to_string(backup_path(&path)).unwrap(), "Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n" ); assert_eq!( std::fs::read_to_string(&path).unwrap(), "Types: deb\nURIs: http://a\nSuites: noble\nComponents: main universe restricted\n" ); } }