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:
+547
-118
@@ -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<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
|
||||
#[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<String>,
|
||||
/// Source architectures (amd64, riscv64, arm64)
|
||||
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
|
||||
pub uri: String,
|
||||
/// Source suites (series-pocket)
|
||||
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 {
|
||||
/// Parse a string describing a source entry in deb822 format
|
||||
pub fn from_deb822(data: &str) -> Option<Self> {
|
||||
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<Self> {
|
||||
// apt defaults 'Types' to 'deb' when the field is absent
|
||||
let mut kinds: Vec<SourceKind> = 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<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();
|
||||
|
||||
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<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;
|
||||
}
|
||||
|
||||
// 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<String> = 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<SourceEntry> {
|
||||
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<SourceEntry> {
|
||||
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<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 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<Arc<crate::context::Context>>,
|
||||
sources: Vec<SourceEntry>,
|
||||
path: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
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 '<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 {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Load sources from DEB822 format
|
||||
fn load_deb822(ctx: &context::Context, path: &str) -> Result<Vec<SourceEntry>, Box<dyn Error>> {
|
||||
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<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
|
||||
fn load_legacy(ctx: &context::Context, path: &str) -> Result<Vec<SourceEntry>, Box<dyn Error>> {
|
||||
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::<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)]
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user