Files
vhaudiquet 6a5c5a7106 context: fix deadlock and panics when resolving parented contexts
set_current held the config RwLock for writing across make_context,
which for a context with a parent re-entered the same lock through
Context::new's global-manager lookup, deadlocking 'pkh context use'.
Context building is now lock-free by construction: make_context
resolves parent chains against a snapshot map (also rejecting parent
cycles), and neither set_current nor remove_context holds a guard
while building a Context.

A corrupt contexts.json no longer aborts every command at manager
init: load falls back to the default local-only config, backs the
corrupt file up to contexts.json.bak so a later save cannot silently
destroy it, and a dangling current/parent context falls back to local
with an error log instead of panicking.
2026-09-16 02:27:11 +02:00

766 lines
27 KiB
Rust

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