diff --git a/src/apt/sources.rs b/src/apt/sources.rs index a022b5d..cea888d 100644 --- a/src/apt/sources.rs +++ b/src/apt/sources.rs @@ -1,97 +1,188 @@ //! APT sources.list management //! Provides a simple structure for managing APT repository sources -use crate::context; +//! +//! 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; +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)] +#[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 { - /// Parse a string describing a source entry in deb822 format - pub fn from_deb822(data: &str) -> Option { - let mut current_entry = SourceEntry { - enabled: true, - components: Vec::new(), - architectures: Vec::new(), - uri: String::new(), - suite: Vec::new(), - }; - - for line in data.lines() { - let line = line.trim(); - if line.starts_with('#') { - continue; - } - - // Empty line: end of an entry, or beginning - if line.is_empty() { - if !current_entry.uri.is_empty() { - return Some(current_entry); - } else { - continue; - } - } - - if let Some((key, value)) = line.split_once(':') { - let key = key.trim(); - let value = value.trim(); - - match key { - "Types" => { - // We only care about deb types - } - "URIs" => current_entry.uri = value.to_string(), - "Suites" => { - current_entry.suite = - value.split_whitespace().map(|s| s.to_string()).collect(); - } - "Components" => { - current_entry.components = - value.split_whitespace().map(|s| s.to_string()).collect(); - } - "Architectures" => { - current_entry.architectures = - value.split_whitespace().map(|s| s.to_string()).collect(); - } - _ => {} - } - } + /// 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); } - // End of entry, or empty file? - if !current_entry.uri.is_empty() { - Some(current_entry) - } else { - None + 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 line = data.lines().next()?.trim(); + let raw = data.lines().next()?.trim(); - if line.is_empty() || line.starts_with("#") { + if raw.is_empty() { return None; } - // Parse legacy deb line format: deb [arch=... / signed_by=] uri suite [components...] + // 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 @@ -102,14 +193,13 @@ impl SourceEntry { // Parse parameters inside brackets for param in bracket_content.split_whitespace() { - if param.starts_with("arch=") { - let arch_values = param.split('=').nth(1).unwrap_or(""); - architectures = arch_values - .split(',') - .map(|s| s.trim().to_string()) - .collect(); + 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"); } - // signed-by parameter is parsed but not stored } // Remove the bracket section from the line @@ -120,37 +210,61 @@ impl SourceEntry { let line_without_brackets = line_without_brackets.trim(); let parts: Vec<&str> = line_without_brackets.split_whitespace().collect(); - // We need at least: deb, uri, suite - if parts.len() < 3 || parts[0] != "deb" { + // 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: true, + 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 { - // Start with "deb" type - result.push_str("deb"); + if !self.enabled { + result.push_str("# "); + } + result.push_str(self.kind.as_str()); - // Add architectures if present + // Bracket options: architectures, signing keyring and trust + let mut options = Vec::new(); if !self.architectures.is_empty() { - result.push_str(" [arch="); - result.push_str(&self.architectures.join(",")); + 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(']'); } @@ -171,88 +285,199 @@ impl SourceEntry { 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 { - data.split("\n\n") - .flat_map(SourceEntry::from_deb822) + 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") + data.split('\n') .flat_map(SourceEntry::from_legacy) .collect() } /// Load sources from context (or current context by default) -pub fn load(ctx: Option>) -> Result, Box> { +/// +/// 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+) - if let Ok(entries) = load_deb822(&ctx, "/etc/apt/sources.list.d/ubuntu.sources") { - sources.extend(entries); - } else if let Ok(entries) = load_deb822(&ctx, "/etc/apt/sources.list.d/debian.sources") { - sources.extend(entries); - } + 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 - if let Ok(entries) = load_legacy(&ctx, "/etc/apt/sources.list") { - sources.extend(entries); - } + 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 context -pub fn save_legacy( - ctx: Option>, - sources: Vec, - path: &str, -) -> Result<(), Box> { - let ctx = if let Some(c) = ctx { - c - } else { - context::current() - }; +/// 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)?; + } - let content = sources - .into_iter() - .map(|s| s.to_legacy()) - .collect::>() - .join("\n"); - ctx.write_file(Path::new(path), &content)?; Ok(()) } -/// Load sources from DEB822 format -fn load_deb822(ctx: &context::Context, path: &str) -> Result, Box> { - let path = Path::new(path); - if path.exists() { - let content = ctx.read_file(path)?; - return Ok(parse_deb822(&content)); +/// 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(()); } - Ok(Vec::new()) + 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(()) } -/// Load sources from legacy format -fn load_legacy(ctx: &context::Context, path: &str) -> Result, Box> { - let path = Path::new(path); - if path.exists() { - let content = ctx.read_file(path)?; - return Ok(content.lines().flat_map(SourceEntry::from_legacy).collect()); +/// 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])); + } } - Ok(Vec::new()) + 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() { @@ -333,4 +558,208 @@ mod tests { 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)); + + 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" + ); + } } diff --git a/src/deb/cross.rs b/src/deb/cross.rs index fa15029..b1848af 100644 --- a/src/deb/cross.rs +++ b/src/deb/cross.rs @@ -117,6 +117,7 @@ pub fn ensure_repositories( } let ports_entry = crate::apt::sources::SourceEntry { enabled: true, + kind: crate::apt::sources::SourceKind::Deb, components: vec![ "main".to_string(), "restricted".to_string(), @@ -125,43 +126,19 @@ pub fn ensure_repositories( ], architectures: vec![arch.to_string()], uri: "http://ports.ubuntu.com/ubuntu-ports".to_string(), + signed_by: None, + trusted: None, suite: ports_suites, + // No origin: saved to the pkh-owned added-sources file + origin: None, }; sources.push(ports_entry); } - // Save the updated sources - // Try to save in DEB822 format first, fall back to legacy format - let deb822_path = "/etc/apt/sources.list.d/ubuntu.sources"; - if ctx - .command("test") - .arg("-f") - .arg(deb822_path) - .status()? - .success() - { - // For DEB822 format, we need to reconstruct the file content - let mut content = String::new(); - for source in &sources { - if !source.enabled { - continue; - } - content.push_str("Types: deb\n"); - content.push_str(&format!("URIs: {}\n", source.uri)); - content.push_str(&format!("Suites: {}\n", source.suite.join(" "))); - content.push_str(&format!("Components: {}\n", source.components.join(" "))); - content.push_str("Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n"); - content.push_str(&format!( - "Architectures: {}\n", - source.architectures.join(" ") - )); - content.push('\n'); - } - ctx.write_file(std::path::Path::new(deb822_path), &content)?; - } else { - // Fall back to legacy format - crate::apt::sources::save_legacy(Some(ctx.clone()), sources, "/etc/apt/sources.list")?; - } + // Save the updated sources: each entry is written back to its origin + // file in its own format (keeping its own Signed-By and Enabled state), + // and the new ports entry goes to the pkh-owned added-sources file + crate::apt::sources::save(Some(ctx.clone()), sources)?; Ok(()) } diff --git a/src/deb/local.rs b/src/deb/local.rs index 0869a11..462783d 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -107,10 +107,15 @@ pub async fn build( let new_source = crate::apt::sources::SourceEntry { enabled: true, + kind: crate::apt::sources::SourceKind::Deb, components: vec!["main".to_string()], architectures: architectures.clone(), + signed_by: None, + trusted: None, suite: suites, uri: base_url, + // No origin: saved to the pkh-owned added-sources file + origin: None, }; sources.push(new_source); modified = true; @@ -159,7 +164,9 @@ pub async fn build( } if modified { - apt::sources::save_legacy(Some(ctx.clone()), sources, "/etc/apt/sources.list")?; + // Each entry is written back to its origin file in its own format; + // new PPA entries go to the pkh-owned added-sources file + apt::sources::save(Some(ctx.clone()), sources)?; // Download and import PPA keys for all added PPAs for (user, ppa_name) in added_ppas {