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:
+544
-115
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user