apt: round-trip sources in place instead of consolidating them into sources.list

Saving the modified source entries with save_legacy rewrote every entry
into /etc/apt/sources.list in legacy format, destroying deb-src entries
and signed-by/trusted options, duplicating every distro entry that came
from a deb822 file (which stayed in place), and hardcoding the Ubuntu
keyring on cross builds. Entries now remember the file and format they
were loaded from and are written back there; new entries (PPAs, ports)
go to a pkh-owned /etc/apt/sources.list.d/pkh-added.list, and a one-time
<path>.pkh-backup copy is made before overwriting an existing file.

Also fixes: 'Types: deb deb-src' stanzas are split instead of being
treated as binary-only, commented-out legacy entries are kept disabled
instead of deleted, debian.sources is actually read on Debian (the old
else-if never fired), and the double blank lines save_legacy emitted.
This commit is contained in:
2026-09-16 00:10:45 +02:00
parent ea70ddc10d
commit 685538e637
3 changed files with 564 additions and 151 deletions
+544 -115
View File
@@ -1,97 +1,188 @@
//! APT sources.list management //! APT sources.list management
//! Provides a simple structure for managing APT repository sources //! 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::error::Error;
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::Arc; 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<Self> {
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 /// Represents a single source entry in sources.list
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceEntry { pub struct SourceEntry {
/// Is the source enabled? /// Is the source enabled?
pub enabled: bool, pub enabled: bool,
/// Kind of packages provided by the source (binary or source)
pub kind: SourceKind,
/// Source components (universe, main, contrib) /// Source components (universe, main, contrib)
pub components: Vec<String>, pub components: Vec<String>,
/// Source architectures (amd64, riscv64, arm64) /// Source architectures (amd64, riscv64, arm64)
pub architectures: Vec<String>, pub architectures: Vec<String>,
/// Keyring the repository is signed with ('signed-by' option)
pub signed_by: Option<String>,
/// Explicit trust flag ('trusted' option), when set
pub trusted: Option<bool>,
/// Source URI /// Source URI
pub uri: String, pub uri: String,
/// Source suites (series-pocket) /// Source suites (series-pocket)
pub suite: Vec<String>, pub suite: Vec<String>,
/// 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<SourceOrigin>,
} }
impl SourceEntry { impl SourceEntry {
/// Parse a string describing a source entry in deb822 format /// Build entries from a single deb822 stanza
pub fn from_deb822(data: &str) -> Option<Self> { ///
let mut current_entry = SourceEntry { /// A stanza declaring several types ('Types: deb deb-src') yields one
enabled: true, /// entry per type.
components: Vec::new(), fn from_deb822_stanza(p: &Paragraph) -> Vec<Self> {
architectures: Vec::new(), // apt defaults 'Types' to 'deb' when the field is absent
uri: String::new(), let mut kinds: Vec<SourceKind> = p
suite: Vec::new(), .get("Types")
}; .unwrap_or("deb")
.split_whitespace()
for line in data.lines() { .filter_map(SourceKind::parse)
let line = line.trim(); .collect();
if line.starts_with('#') { if kinds.is_empty() {
continue; kinds.push(SourceKind::Deb);
} }
// Empty line: end of an entry, or beginning let enabled = p
if line.is_empty() { .get("Enabled")
if !current_entry.uri.is_empty() { .map(|v| {
return Some(current_entry); let v = v.trim();
} else { !v.eq_ignore_ascii_case("no") && !v.eq_ignore_ascii_case("false")
continue; })
} .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<String> = p
.get("Suites")
.unwrap_or("")
.split_whitespace()
.map(|s| s.to_string())
.collect();
let components: Vec<String> = p
.get("Components")
.unwrap_or("")
.split_whitespace()
.map(|s| s.to_string())
.collect();
let architectures: Vec<String> = p
.get("Architectures")
.unwrap_or("")
.split_whitespace()
.map(|s| s.to_string())
.collect();
if let Some((key, value)) = line.split_once(':') { kinds
let key = key.trim(); .into_iter()
let value = value.trim(); .map(|kind| SourceEntry {
enabled,
match key { kind,
"Types" => { components: components.clone(),
// We only care about deb types architectures: architectures.clone(),
} signed_by: signed_by.clone(),
"URIs" => current_entry.uri = value.to_string(), trusted,
"Suites" => { uri: uri.clone(),
current_entry.suite = suite: suite.clone(),
value.split_whitespace().map(|s| s.to_string()).collect(); origin: None,
} })
"Components" => { .collect()
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();
}
_ => {}
}
}
}
// End of entry, or empty file?
if !current_entry.uri.is_empty() {
Some(current_entry)
} else {
None
}
} }
/// Parse a line describing a legacy source entry /// Parse a line describing a legacy source entry
pub fn from_legacy(data: &str) -> Option<Self> { pub fn from_legacy(data: &str) -> Option<Self> {
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; 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 // Extract bracket parameters first
let mut architectures = Vec::new(); let mut architectures = Vec::new();
let mut signed_by = None;
let mut trusted = None;
let mut line_without_brackets = line.to_string(); let mut line_without_brackets = line.to_string();
// Find and process bracket parameters // Find and process bracket parameters
@@ -102,14 +193,13 @@ impl SourceEntry {
// Parse parameters inside brackets // Parse parameters inside brackets
for param in bracket_content.split_whitespace() { for param in bracket_content.split_whitespace() {
if param.starts_with("arch=") { if let Some(values) = param.strip_prefix("arch=") {
let arch_values = param.split('=').nth(1).unwrap_or(""); architectures = values.split(',').map(|s| s.trim().to_string()).collect();
architectures = arch_values } else if let Some(keyring) = param.strip_prefix("signed-by=") {
.split(',') signed_by = Some(keyring.trim_matches('"').to_string());
.map(|s| s.trim().to_string()) } else if let Some(flag) = param.strip_prefix("trusted=") {
.collect(); 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 // Remove the bracket section from the line
@@ -120,37 +210,61 @@ impl SourceEntry {
let line_without_brackets = line_without_brackets.trim(); let line_without_brackets = line_without_brackets.trim();
let parts: Vec<&str> = line_without_brackets.split_whitespace().collect(); let parts: Vec<&str> = line_without_brackets.split_whitespace().collect();
// We need at least: deb, uri, suite // We need at least: type, uri, suite
if parts.len() < 3 || parts[0] != "deb" { if parts.len() < 3 {
return None; return None;
} }
let kind = SourceKind::parse(parts[0])?;
let uri = parts[1].to_string(); let uri = parts[1].to_string();
let suite = vec![parts[2].to_string()]; let suite = vec![parts[2].to_string()];
let components: Vec<String> = parts[3..].iter().map(|&s| s.to_string()).collect(); let components: Vec<String> = parts[3..].iter().map(|&s| s.to_string()).collect();
Some(SourceEntry { Some(SourceEntry {
enabled: true, enabled,
kind,
components, components,
architectures, architectures,
signed_by,
trusted,
uri, uri,
suite, suite,
origin: None,
}) })
} }
/// Convert this source entry to legacy format /// 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 { pub fn to_legacy(&self) -> String {
let mut result = String::new(); let mut result = String::new();
// Legacy entries contain one suite per line // Legacy entries contain one suite per line
for suite in &self.suite { for suite in &self.suite {
// Start with "deb" type if !self.enabled {
result.push_str("deb"); 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() { if !self.architectures.is_empty() {
result.push_str(" [arch="); options.push(format!("arch={}", self.architectures.join(",")));
result.push_str(&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(']'); result.push(']');
} }
@@ -171,88 +285,199 @@ impl SourceEntry {
result 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 /// 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<SourceEntry> { pub fn parse_deb822(data: &str) -> Vec<SourceEntry> {
data.split("\n\n") parse_paragraphs(data)
.flat_map(SourceEntry::from_deb822) .iter()
.flat_map(SourceEntry::from_deb822_stanza)
.collect() .collect()
} }
/// Parse a 'source list' string in legacy format into a SourceEntry vector /// Parse a 'source list' string in legacy format into a SourceEntry vector
pub fn parse_legacy(data: &str) -> Vec<SourceEntry> { pub fn parse_legacy(data: &str) -> Vec<SourceEntry> {
data.split("\n") data.split('\n')
.flat_map(SourceEntry::from_legacy) .flat_map(SourceEntry::from_legacy)
.collect() .collect()
} }
/// Load sources from context (or current context by default) /// Load sources from context (or current context by default)
pub fn load(ctx: Option<Arc<crate::context::Context>>) -> Result<Vec<SourceEntry>, Box<dyn Error>> { ///
/// 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<Arc<Context>>) -> Result<Vec<SourceEntry>, Box<dyn Error>> {
let mut sources = Vec::new(); let mut sources = Vec::new();
let ctx = ctx.unwrap_or_else(context::current); let ctx = ctx.unwrap_or_else(context::current);
// Try DEB822 format first (Ubuntu 24.04+ and Debian Trixie+) // Try DEB822 format first (Ubuntu 24.04+ and Debian Trixie+)
if let Ok(entries) = load_deb822(&ctx, "/etc/apt/sources.list.d/ubuntu.sources") { load_file(
sources.extend(entries); &ctx,
} else if let Ok(entries) = load_deb822(&ctx, "/etc/apt/sources.list.d/debian.sources") { "/etc/apt/sources.list.d/ubuntu.sources",
sources.extend(entries); SourceFormat::Deb822,
} &mut sources,
)?;
load_file(
&ctx,
"/etc/apt/sources.list.d/debian.sources",
SourceFormat::Deb822,
&mut sources,
)?;
// Fall back to legacy format // Fall back to legacy format
if let Ok(entries) = load_legacy(&ctx, "/etc/apt/sources.list") { load_file(
sources.extend(entries); &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) Ok(sources)
} }
/// Save sources back to context /// Save sources back to the context
pub fn save_legacy( ///
ctx: Option<Arc<crate::context::Context>>, /// Each entry is written back to the file it was loaded from
sources: Vec<SourceEntry>, /// ([`SourceEntry::origin`]), in that file's format. Entries without an
path: &str, /// origin (e.g. repositories added by pkh) go to the pkh-owned
) -> Result<(), Box<dyn Error>> { /// added-sources file in legacy format, never to distro-managed files.
let ctx = if let Some(c) = ctx { ///
c /// Files whose rendered content is byte-identical to their current content
/// are left untouched; otherwise a '<path>.pkh-backup' copy is created once
/// before the first overwrite.
pub fn save(ctx: Option<Arc<Context>>, sources: Vec<SourceEntry>) -> Result<(), Box<dyn Error>> {
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 { } else {
context::current() 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::<Vec<_>>()
.join("\n");
ctx.write_file(Path::new(path), &content)?;
Ok(()) Ok(())
} }
/// Load sources from DEB822 format /// Load entries from one sources file, if it exists, tagging them with
fn load_deb822(ctx: &context::Context, path: &str) -> Result<Vec<SourceEntry>, Box<dyn Error>> { /// their origin
let path = Path::new(path); fn load_file(
if path.exists() { ctx: &Context,
let content = ctx.read_file(path)?; path: &str,
return Ok(parse_deb822(&content)); format: SourceFormat,
out: &mut Vec<SourceEntry>,
) -> Result<(), Box<dyn Error>> {
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 /// Compute the writes needed to persist entries: one
fn load_legacy(ctx: &context::Context, path: &str) -> Result<Vec<SourceEntry>, Box<dyn Error>> { /// (path, format, content) triple per destination file, entries kept in order
let path = Path::new(path); ///
if path.exists() { /// Entries without an origin are routed to the pkh-owned added-sources file.
let content = ctx.read_file(path)?; fn plan_writes(sources: &[SourceEntry]) -> Vec<(PathBuf, SourceFormat, String)> {
return Ok(content.lines().flat_map(SourceEntry::from_legacy).collect()); 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::<Vec<_>>()
.join("\n"),
};
(path, format, content)
})
.collect()
}
/// Backup path for a sources file ('<path>.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::context::ContextConfig;
#[tokio::test] #[tokio::test]
async fn test_parse_deb822() { async fn test_parse_deb822() {
@@ -333,4 +558,208 @@ mod tests {
assert_eq!(sources[2].suite, vec!["resolute-security"]); assert_eq!(sources[2].suite, vec!["resolute-security"]);
assert_eq!(sources[2].components, vec!["main"]); 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"
);
}
} }
+9 -32
View File
@@ -117,6 +117,7 @@ pub fn ensure_repositories(
} }
let ports_entry = crate::apt::sources::SourceEntry { let ports_entry = crate::apt::sources::SourceEntry {
enabled: true, enabled: true,
kind: crate::apt::sources::SourceKind::Deb,
components: vec![ components: vec![
"main".to_string(), "main".to_string(),
"restricted".to_string(), "restricted".to_string(),
@@ -125,43 +126,19 @@ pub fn ensure_repositories(
], ],
architectures: vec![arch.to_string()], architectures: vec![arch.to_string()],
uri: "http://ports.ubuntu.com/ubuntu-ports".to_string(), uri: "http://ports.ubuntu.com/ubuntu-ports".to_string(),
signed_by: None,
trusted: None,
suite: ports_suites, suite: ports_suites,
// No origin: saved to the pkh-owned added-sources file
origin: None,
}; };
sources.push(ports_entry); sources.push(ports_entry);
} }
// Save the updated sources // Save the updated sources: each entry is written back to its origin
// Try to save in DEB822 format first, fall back to legacy format // file in its own format (keeping its own Signed-By and Enabled state),
let deb822_path = "/etc/apt/sources.list.d/ubuntu.sources"; // and the new ports entry goes to the pkh-owned added-sources file
if ctx crate::apt::sources::save(Some(ctx.clone()), sources)?;
.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")?;
}
Ok(()) Ok(())
} }
+8 -1
View File
@@ -107,10 +107,15 @@ pub async fn build(
let new_source = crate::apt::sources::SourceEntry { let new_source = crate::apt::sources::SourceEntry {
enabled: true, enabled: true,
kind: crate::apt::sources::SourceKind::Deb,
components: vec!["main".to_string()], components: vec!["main".to_string()],
architectures: architectures.clone(), architectures: architectures.clone(),
signed_by: None,
trusted: None,
suite: suites, suite: suites,
uri: base_url, uri: base_url,
// No origin: saved to the pkh-owned added-sources file
origin: None,
}; };
sources.push(new_source); sources.push(new_source);
modified = true; modified = true;
@@ -159,7 +164,9 @@ pub async fn build(
} }
if modified { 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 // Download and import PPA keys for all added PPAs
for (user, ppa_name) in added_ppas { for (user, ppa_name) in added_ppas {