use std::collections::BTreeSet;
use std::env;
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::config::schema::{AptMirrorDef, AptMirrorRuleDef};
use crate::error::ForgeError;
use crate::fsutil::{
atomic_write_file, copy_file, create_dir_all, lock_exclusive_cancellable, read_dir,
read_to_string, remove_dir_all, write_file,
};
use fs2::FileExt;
const DEFAULT_SOURCE_FILE: &str = "/etc/apt/sources.list.d/bot-forge.sources";
const APT_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AptMirrorPreview {
pub(crate) distribution: String,
pub(crate) codename: String,
pub(crate) architecture: String,
pub(crate) source_file: PathBuf,
pub(crate) source_contents: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AptSystem {
distribution: String,
codename: String,
architecture: String,
}
pub(crate) fn apt_mirror_preview(
mirror: Option<&AptMirrorDef>,
) -> Result<AptMirrorPreview, ForgeError> {
let mirror =
mirror.ok_or_else(|| ForgeError::Config("apt_mirror is not configured".to_string()))?;
let system = detect_apt_system()?;
build_preview(mirror, &system)
}
pub(crate) fn check_apt_mirror(
preview: &AptMirrorPreview,
mut notice: impl FnMut(&str),
) -> Result<(), ForgeError> {
let temp_dir = temp_dir("apt-mirror-check")?;
let source_file = temp_dir.join(check_source_file_name(preview));
let lists_dir = temp_dir.join("lists");
let result = (|| {
create_dir_all(&lists_dir.join("partial"))?;
write_file(&source_file, &preview.source_contents)?;
let deadline = Instant::now() + APT_CHECK_TIMEOUT;
let output = bounded_command("apt-get", deadline)?
.arg("-o")
.arg(format!("Dir::Etc::sourcelist={}", source_file.display()))
.arg("-o")
.arg("Dir::Etc::sourceparts=-")
.arg("-o")
.arg(format!("Dir::State::lists={}", lists_dir.display()))
.arg("-o")
.arg("APT::Get::List-Cleanup=0")
.arg("update")
.output()
.map_err(|source| ForgeError::Io {
path: PathBuf::from("timeout"),
source,
})?;
reject_timeout(&output, "APT mirror validation")?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
Err(ForgeError::Command(format!(
"temporary APT source validation failed: {}",
if stderr.is_empty() { stdout } else { stderr }
)))
})();
let cleanup = remove_dir_all(&temp_dir);
if let Err(error) = cleanup {
notice(&format!(
"Could not remove APT validation directory {}: {error}",
temp_dir.display()
));
}
result
}
pub(crate) fn check_current_apt_sources(
preview: &AptMirrorPreview,
required_packages: &BTreeSet<String>,
mut notice: impl FnMut(&str),
) -> Result<(), ForgeError> {
let temp_dir = temp_dir("apt-current-check")?;
let result = check_current_apt_sources_from(
Path::new("/etc/apt"),
&preview.source_file,
required_packages,
&temp_dir,
);
if let Ok(Some(warning)) = &result {
notice(warning);
}
if let Err(error) = remove_dir_all(&temp_dir) {
notice(&format!(
"Could not remove APT connectivity directory {}: {error}",
temp_dir.display()
));
}
result.map(|_| ())
}
fn check_current_apt_sources_from(
apt_dir: &Path,
managed_source: &Path,
required_packages: &BTreeSet<String>,
temp_dir: &Path,
) -> Result<Option<String>, ForgeError> {
let etc_dir = temp_dir.join("etc");
let source_parts = etc_dir.join("sources.list.d");
let source_list = etc_dir.join("sources.list");
let lists_dir = temp_dir.join("lists");
let cache_dir = temp_dir.join("cache");
create_dir_all(&source_parts)?;
create_dir_all(&lists_dir.join("partial"))?;
create_dir_all(&cache_dir.join("archives/partial"))?;
let mut active_sources = 0usize;
let system_source_list = apt_dir.join("sources.list");
if system_source_list.is_file() && system_source_list != managed_source {
active_sources += copy_active_source(&system_source_list, &source_list)? as usize;
}
if !source_list.is_file() {
write_file(&source_list, "")?;
}
let system_source_parts = apt_dir.join("sources.list.d");
if system_source_parts.is_dir() {
for entry in read_dir(&system_source_parts)? {
let source = entry?;
if source == managed_source || !is_apt_source_file(&source) {
continue;
}
let Some(file_name) = source.file_name() else {
continue;
};
active_sources += copy_active_source(&source, &source_parts.join(file_name))? as usize;
}
}
if active_sources == 0 {
return Err(ForgeError::Config(
"no valid APT source outside the bot-forge-managed sources was found".to_string(),
));
}
let deadline = Instant::now() + APT_CHECK_TIMEOUT;
let output = bounded_command("apt-get", deadline)?
.arg("-o")
.arg(format!("Dir::Etc::sourcelist={}", source_list.display()))
.arg("-o")
.arg(format!("Dir::Etc::sourceparts={}", source_parts.display()))
.arg("-o")
.arg(format!("Dir::State::lists={}", lists_dir.display()))
.arg("-o")
.arg(format!("Dir::Cache={}", cache_dir.display()))
.args([
"-o",
"Debug::NoLocking=true",
"-o",
"Acquire::Languages=none",
"-o",
"Acquire::Retries=1",
"-o",
"Acquire::http::Timeout=10",
"-o",
"Acquire::https::Timeout=10",
"-o",
"Acquire::AllowInsecureRepositories=false",
"-o",
"APT::Get::List-Cleanup=0",
"update",
])
.output()
.map_err(|source| ForgeError::Io {
path: PathBuf::from("timeout"),
source,
})?;
reject_timeout(&output, "current APT source check")?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let update_detail = if stderr.is_empty() { stdout } else { stderr };
if !contains_release_metadata(&lists_dir)? {
return Err(ForgeError::Command(
"current APT source check downloaded no verified Release/InRelease metadata"
.to_string(),
));
}
let unresolved = unresolved_packages(
&source_list,
&source_parts,
&lists_dir,
required_packages,
deadline,
)?;
if !unresolved.is_empty() {
return Err(ForgeError::Command(format!(
"current APT sources cannot provide the required packages: {}{}",
unresolved.join(", "),
if update_detail.is_empty() {
String::new()
} else {
format!(";apt-get update:{update_detail}")
}
)));
}
Ok((!output.status.success()).then(|| {
format!("ignored an APT source failure unrelated to required packages: {update_detail}")
}))
}
fn unresolved_packages(
source_list: &Path,
source_parts: &Path,
lists_dir: &Path,
required_packages: &BTreeSet<String>,
deadline: Instant,
) -> Result<Vec<String>, ForgeError> {
let mut unresolved = Vec::new();
for package in required_packages {
let output = bounded_command("apt-cache", deadline)?
.arg("-o")
.arg(format!("Dir::Etc::sourcelist={}", source_list.display()))
.arg("-o")
.arg(format!("Dir::Etc::sourceparts={}", source_parts.display()))
.arg("-o")
.arg(format!("Dir::State::lists={}", lists_dir.display()))
.arg("policy")
.arg(package)
.env("LC_ALL", "C")
.output()
.map_err(|source| ForgeError::Io {
path: PathBuf::from("timeout"),
source,
})?;
reject_timeout(&output, "APT package candidate check")?;
if !output.status.success() {
return Err(ForgeError::Command(format!(
"apt-cache policy {package} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let has_candidate = output_has_candidate(&String::from_utf8_lossy(&output.stdout));
if !has_candidate {
unresolved.push(package.clone());
}
}
Ok(unresolved)
}
fn bounded_command(program: &str, deadline: Instant) -> Result<Command, ForgeError> {
let remaining = deadline
.checked_duration_since(Instant::now())
.filter(|remaining| !remaining.is_zero())
.ok_or_else(|| {
ForgeError::Command("APT checks exceeded the 60-second total timeout".to_string())
})?;
let mut command = Command::new("timeout");
command.args([
"--signal=TERM",
"--kill-after=5s",
&format!("{}s", remaining.as_secs().max(1)),
program,
]);
Ok(command)
}
fn reject_timeout(output: &std::process::Output, operation: &str) -> Result<(), ForgeError> {
if matches!(output.status.code(), Some(124 | 137)) {
Err(ForgeError::Command(format!(
"{operation} exceeded the 60-second total timeout"
)))
} else {
Ok(())
}
}
fn output_has_candidate(output: &str) -> bool {
output
.lines()
.filter_map(|line| line.trim().strip_prefix("Candidate:"))
.any(|candidate| candidate.trim() != "(none)")
}
fn copy_active_source(source: &Path, target: &Path) -> Result<bool, ForgeError> {
let contents = read_to_string(source)?;
if !source_contents_are_active(source, &contents) {
return Ok(false);
}
copy_file(source, target)?;
Ok(true)
}
fn is_apt_source_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|value| value.to_str()),
Some("list" | "sources")
)
}
fn source_contents_are_active(path: &Path, contents: &str) -> bool {
if path.extension().and_then(|value| value.to_str()) == Some("list") {
return contents.lines().any(|line| {
let line = line.trim_start();
line.starts_with("deb ") || line.starts_with("deb-src ")
});
}
contents.split("\n\n").any(|stanza| {
let mut enabled = true;
let mut has_deb_type = false;
for line in stanza
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
{
let Some((name, value)) = line.split_once(':') else {
continue;
};
if name.eq_ignore_ascii_case("Enabled") && value.trim().eq_ignore_ascii_case("no") {
enabled = false;
}
if name.eq_ignore_ascii_case("Types")
&& value
.split_whitespace()
.any(|value| value == "deb" || value == "deb-src")
{
has_deb_type = true;
}
}
enabled && has_deb_type
})
}
fn contains_release_metadata(path: &Path) -> Result<bool, ForgeError> {
for entry in read_dir(path)? {
let entry = entry?;
if entry.is_dir() {
if contains_release_metadata(&entry)? {
return Ok(true);
}
continue;
}
let name = entry
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
if name.ends_with("InRelease") || name.ends_with("_Release") {
return Ok(true);
}
}
Ok(false)
}
fn check_source_file_name(preview: &AptMirrorPreview) -> &'static str {
let _ = preview;
"bot-forge.sources"
}
pub(crate) fn apply_apt_mirror(preview: &AptMirrorPreview) -> Result<(), ForgeError> {
with_source_lock(&preview.source_file, || apply_unlocked(preview))
}
fn apply_unlocked(preview: &AptMirrorPreview) -> Result<(), ForgeError> {
let parent = preview.source_file.parent().ok_or_else(|| {
ForgeError::Config(format!(
"APT source file path is invalid: {}",
preview.source_file.display()
))
})?;
create_dir_all(parent)?;
let backup = apt_backup_path(&preview.source_file);
if preview.source_file.is_file() {
std::fs::copy(&preview.source_file, &backup).map_err(|source| ForgeError::Io {
path: backup,
source,
})?;
}
atomic_write_file(&preview.source_file, preview.source_contents.as_bytes())
}
pub(crate) fn restore_apt_mirror(source_file: &std::path::Path) -> Result<(), ForgeError> {
with_source_lock(source_file, || restore_unlocked(source_file))
}
fn restore_unlocked(source_file: &std::path::Path) -> Result<(), ForgeError> {
let backup = apt_backup_path(source_file);
if !backup.is_file() {
return Err(ForgeError::Config(format!(
"no usable APT backup exists: {}",
backup.display()
)));
}
let contents = std::fs::read(&backup).map_err(|source| ForgeError::Io {
path: backup,
source,
})?;
atomic_write_file(source_file, &contents)
}
fn with_source_lock<T>(
source_file: &std::path::Path,
action: impl FnOnce() -> Result<T, ForgeError>,
) -> Result<T, ForgeError> {
let parent = source_file.parent().ok_or_else(|| {
ForgeError::Config(format!(
"APT source file path is invalid: {}",
source_file.display()
))
})?;
create_dir_all(parent)?;
let path = parent.join(".bot-forge-apt-mirror.lock");
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
lock_exclusive_cancellable(&file, &path, "APT mirror lock")?;
let result = action();
let _ = FileExt::unlock(&file);
result
}
fn apt_backup_path(source_file: &std::path::Path) -> PathBuf {
let extension = source_file
.extension()
.and_then(|value| value.to_str())
.map_or_else(
|| "bak".to_string(),
|value| format!("{value}.bot-forge.bak"),
);
source_file.with_extension(extension)
}
fn detect_apt_system() -> Result<AptSystem, ForgeError> {
if cfg!(windows) {
return Err(ForgeError::Config(
"APT mirror configuration is supported only on Linux; run bot-forge in WSL or Linux"
.to_string(),
));
}
let os_release = read_to_string(&PathBuf::from("/etc/os-release"))?;
let values = parse_os_release(&os_release);
let distribution = values
.get("ID")
.cloned()
.filter(|value| !value.is_empty())
.ok_or_else(|| ForgeError::Config("/etc/os-release is missing ID".to_string()))?;
let codename = values
.get("VERSION_CODENAME")
.or_else(|| values.get("UBUNTU_CODENAME"))
.cloned()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
ForgeError::Config(
"/etc/os-release is missing VERSION_CODENAME or UBUNTU_CODENAME; cannot select an APT suite"
.to_string(),
)
})?;
let output = Command::new("dpkg")
.arg("--print-architecture")
.output()
.map_err(|source| ForgeError::Io {
path: PathBuf::from("dpkg"),
source,
})?;
if !output.status.success() {
return Err(ForgeError::Command(
"dpkg --print-architecture failed; the current system may not be Debian or Ubuntu"
.to_string(),
));
}
let architecture = String::from_utf8_lossy(&output.stdout).trim().to_string();
if architecture.is_empty() {
return Err(ForgeError::Command(
"dpkg --print-architecture returned no architecture".to_string(),
));
}
Ok(AptSystem {
distribution,
codename,
architecture,
})
}
fn build_preview(
mirror: &AptMirrorDef,
system: &AptSystem,
) -> Result<AptMirrorPreview, ForgeError> {
let mirror = select_mirror(mirror, system)?;
let uri = expand_required(mirror.uri, "uri", system)?;
if !(uri.starts_with("http://") || uri.starts_with("https://")) {
return Err(ForgeError::Config(
"apt_mirror.uri must start with http:// or https://".to_string(),
));
}
validate_scalar("apt_mirror.uri", &uri)?;
let suites = expand_list(mirror.suites, "suites", system)?;
let components = expand_list(mirror.components, "components", system)?;
let architectures = if mirror.architectures.is_empty() {
vec![system.architecture.clone()]
} else {
expand_list(mirror.architectures, "architectures", system)?
};
let signed_by = mirror
.signed_by
.map(|value| path_text(value, "signed_by"))
.transpose()?
.map(|value| expand_template(value, system))
.transpose()?;
if let Some(value) = &signed_by {
validate_scalar("apt_mirror.signed_by", value)?;
}
let source_file = mirror
.source_file
.map(|value| path_text(value, "source_file"))
.transpose()?
.unwrap_or(DEFAULT_SOURCE_FILE);
validate_source_file(source_file)?;
let mut contents = format!(
"Types: deb\nURIs: {uri}\nSuites: {}\nComponents: {}\nArchitectures: {}\n",
suites.join(" "),
components.join(" "),
architectures.join(" ")
);
if let Some(signed_by) = signed_by {
contents.push_str(&format!("Signed-By: {signed_by}\n"));
}
Ok(AptMirrorPreview {
distribution: system.distribution.clone(),
codename: system.codename.clone(),
architecture: system.architecture.clone(),
source_file: PathBuf::from(source_file),
source_contents: contents,
})
}
fn path_text<'a>(path: &'a std::path::Path, name: &str) -> Result<&'a str, ForgeError> {
path.to_str()
.ok_or_else(|| ForgeError::Config(format!("apt_mirror.{name} must be a UTF-8 path")))
}
struct SelectedAptMirror<'a> {
uri: Option<&'a str>,
suites: &'a [String],
components: &'a [String],
architectures: &'a [String],
signed_by: Option<&'a std::path::Path>,
source_file: Option<&'a std::path::Path>,
}
fn select_mirror<'a>(
mirror: &'a AptMirrorDef,
system: &AptSystem,
) -> Result<SelectedAptMirror<'a>, ForgeError> {
if let Some(rule) = mirror.rules.iter().find(|rule| rule_matches(rule, system)) {
return Ok(SelectedAptMirror {
uri: rule.uri.as_deref().or(mirror.uri.as_deref()),
suites: if rule.suites.is_empty() {
&mirror.suites
} else {
&rule.suites
},
components: if rule.components.is_empty() {
&mirror.components
} else {
&rule.components
},
architectures: if rule.architectures.is_empty() {
&mirror.architectures
} else {
&rule.architectures
},
signed_by: rule.signed_by.as_deref().or(mirror.signed_by.as_deref()),
source_file: rule
.source_file
.as_deref()
.or(mirror.source_file.as_deref()),
});
}
if !mirror.rules.is_empty() && mirror.uri.is_none() {
return Err(ForgeError::Config(format!(
"apt_mirror.rules has no match for the current system: distribution={} codename={} architecture={}",
system.distribution, system.codename, system.architecture
)));
}
Ok(SelectedAptMirror {
uri: mirror.uri.as_deref(),
suites: &mirror.suites,
components: &mirror.components,
architectures: &mirror.architectures,
signed_by: mirror.signed_by.as_deref(),
source_file: mirror.source_file.as_deref(),
})
}
fn rule_matches(rule: &AptMirrorRuleDef, system: &AptSystem) -> bool {
selector_matches(rule.distribution.as_deref(), &system.distribution)
&& selector_matches(rule.codename.as_deref(), &system.codename)
&& selector_matches(rule.architecture.as_deref(), &system.architecture)
}
fn selector_matches(selector: Option<&str>, actual: &str) -> bool {
selector.is_none_or(|selector| selector == actual)
}
fn expand_required(
value: Option<&str>,
name: &str,
system: &AptSystem,
) -> Result<String, ForgeError> {
let value = value.ok_or_else(|| ForgeError::Config(format!("apt_mirror is missing {name}")))?;
expand_template(value, system)
}
fn expand_list(
values: &[String],
name: &str,
system: &AptSystem,
) -> Result<Vec<String>, ForgeError> {
if values.is_empty() {
return Err(ForgeError::Config(format!(
"apt_mirror.{name} cannot be empty"
)));
}
values
.iter()
.map(|value| {
let expanded = expand_template(value, system)?;
validate_scalar(&format!("apt_mirror.{name}"), &expanded)?;
Ok(expanded)
})
.collect()
}
fn expand_template(value: &str, system: &AptSystem) -> Result<String, ForgeError> {
let value = value
.replace("{distribution}", &system.distribution)
.replace("{codename}", &system.codename)
.replace("{architecture}", &system.architecture);
if value.contains('{') || value.contains('}') {
return Err(ForgeError::Config(format!(
"APT mirror configuration contains an unknown variable: {value}"
)));
}
Ok(value)
}
fn validate_source_file(source_file: &str) -> Result<(), ForgeError> {
if !source_file.starts_with('/') {
return Err(ForgeError::Config(
"apt_mirror.source_file must be an absolute Linux path".to_string(),
));
}
if !source_file.ends_with(".sources") {
return Err(ForgeError::Config(
"Deb822 APT mirror source_file should end with .sources".to_string(),
));
}
Ok(())
}
fn validate_scalar(name: &str, value: &str) -> Result<(), ForgeError> {
if value.trim().is_empty() || value.chars().any(char::is_whitespace) {
return Err(ForgeError::Config(format!(
"{name} cannot contain whitespace"
)));
}
Ok(())
}
fn parse_os_release(contents: &str) -> std::collections::BTreeMap<String, String> {
contents
.lines()
.filter_map(|line| line.split_once('='))
.map(|(key, value)| {
(
key.trim().to_string(),
value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string(),
)
})
.collect()
}
fn temp_dir(prefix: &str) -> Result<PathBuf, ForgeError> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| ForgeError::Config(format!("system time is invalid: {error}")))?
.as_nanos();
let path = env::temp_dir().join(format!("bot-forge-{prefix}-{}-{nonce}", std::process::id()));
create_dir_all(&path)?;
Ok(path)
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
use crate::backends::apt_mirror::reject_timeout;
use crate::backends::apt_mirror::{
AptMirrorPreview, AptSystem, apply_apt_mirror, bounded_command, build_preview,
contains_release_metadata, output_has_candidate, restore_apt_mirror,
source_contents_are_active, temp_dir,
};
use crate::config::schema::{AptMirrorDef, AptMirrorRuleDef};
use std::sync::Arc;
#[test]
fn renders_deb822_source_with_system_variables() {
let mirror = AptMirrorDef {
uri: Some("https://apt.example.internal/{distribution}".to_string()),
suites: vec!["{codename}".to_string(), "{codename}-updates".to_string()],
components: vec!["main".to_string(), "universe".to_string()],
architectures: Vec::new(),
signed_by: Some("/usr/share/keyrings/internal.gpg".into()),
source_file: Some("/etc/apt/sources.list.d/bot-forge.sources".into()),
rules: Vec::new(),
};
let system = AptSystem {
distribution: "ubuntu".to_string(),
codename: "noble".to_string(),
architecture: "amd64".to_string(),
};
let preview = build_preview(&mirror, &system).unwrap();
assert_eq!(
preview.source_contents,
"Types: deb\nURIs: https://apt.example.internal/ubuntu\nSuites: noble noble-updates\nComponents: main universe\nArchitectures: amd64\nSigned-By: /usr/share/keyrings/internal.gpg\n"
);
}
#[test]
fn selects_first_matching_rule_for_distribution_and_architecture() {
let mirror = AptMirrorDef {
suites: vec!["{codename}".to_string()],
components: vec!["main".to_string()],
source_file: Some("/etc/apt/sources.list.d/bot-forge.sources".into()),
rules: vec![
AptMirrorRuleDef {
distribution: Some("ubuntu".to_string()),
architecture: Some("amd64".to_string()),
uri: Some("https://amd64.example.internal/ubuntu".to_string()),
..AptMirrorRuleDef::default()
},
AptMirrorRuleDef {
distribution: Some("ubuntu".to_string()),
architecture: Some("arm64".to_string()),
uri: Some("https://arm64.example.internal/ubuntu".to_string()),
..AptMirrorRuleDef::default()
},
],
..AptMirrorDef::default()
};
let system = AptSystem {
distribution: "ubuntu".to_string(),
codename: "noble".to_string(),
architecture: "arm64".to_string(),
};
let preview = build_preview(&mirror, &system).unwrap();
assert!(
preview
.source_contents
.contains("URIs: https://arm64.example.internal/ubuntu")
);
assert!(preview.source_contents.contains("Architectures: arm64"));
}
#[test]
fn concurrent_apply_is_locked_and_never_tears_source() {
let directory = temp_dir("apt-lock").unwrap();
let source = directory.join("bot-forge.sources");
let contents = "Types: deb\nURIs: https://example.invalid/ubuntu\n".repeat(512);
let preview = Arc::new(AptMirrorPreview {
distribution: "ubuntu".into(),
codename: "noble".into(),
architecture: "amd64".into(),
source_file: source.clone(),
source_contents: contents.clone(),
});
let threads = (0..8)
.map(|_| {
let preview = Arc::clone(&preview);
std::thread::spawn(move || apply_apt_mirror(&preview).unwrap())
})
.collect::<Vec<_>>();
for thread in threads {
thread.join().unwrap();
}
assert_eq!(std::fs::read_to_string(source).unwrap(), contents);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn apply_creates_backup_and_restore_reinstates_it() {
let dir = temp_dir("apt-backup-test").unwrap();
let source_file = dir.join("bot-forge.sources");
std::fs::write(&source_file, "old\n").unwrap();
let preview = AptMirrorPreview {
distribution: "test".to_string(),
codename: "test".to_string(),
architecture: "amd64".to_string(),
source_file: source_file.clone(),
source_contents: "new\n".to_string(),
};
apply_apt_mirror(&preview).unwrap();
assert_eq!(std::fs::read_to_string(&source_file).unwrap(), "new\n");
restore_apt_mirror(&source_file).unwrap();
assert_eq!(std::fs::read_to_string(&source_file).unwrap(), "old\n");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn recognizes_only_enabled_apt_source_entries() {
assert!(source_contents_are_active(
std::path::Path::new("ubuntu.list"),
"# disabled\ndeb https://archive.example/ubuntu noble main\n"
));
assert!(!source_contents_are_active(
std::path::Path::new("ubuntu.list"),
"# deb https://archive.example/ubuntu noble main\n"
));
assert!(source_contents_are_active(
std::path::Path::new("ubuntu.sources"),
"Types: deb\nURIs: https://archive.example/ubuntu\nSuites: noble\n"
));
assert!(!source_contents_are_active(
std::path::Path::new("ubuntu.sources"),
"Types: deb\nEnabled: no\nURIs: https://archive.example/ubuntu\nSuites: noble\n"
));
}
#[test]
fn requires_downloaded_release_metadata_for_connectivity_success() {
let directory = temp_dir("apt-metadata-test").unwrap();
std::fs::write(directory.join("archive_InRelease"), "signed metadata").unwrap();
assert!(contains_release_metadata(&directory).unwrap());
std::fs::remove_file(directory.join("archive_InRelease")).unwrap();
std::fs::write(directory.join("Packages"), "package index only").unwrap();
assert!(!contains_release_metadata(&directory).unwrap());
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn apt_cache_policy_requires_a_real_candidate() {
assert!(output_has_candidate(
"demo:\n Installed: (none)\n Candidate: 1.2.3\n"
));
assert!(!output_has_candidate(
"demo:\n Installed: (none)\n Candidate: (none)\n"
));
assert!(!output_has_candidate("N: Unable to locate package demo\n"));
}
#[test]
fn exhausted_apt_budget_is_rejected_before_spawn() {
let deadline = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(1))
.unwrap();
assert!(bounded_command("apt-get", deadline).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn apt_subprocess_is_killed_at_the_total_deadline() {
let started = std::time::Instant::now();
let output = bounded_command("sh", started + std::time::Duration::from_secs(1))
.unwrap()
.args(["-c", "sleep 5"])
.output()
.unwrap();
assert!(reject_timeout(&output, "test APT probe").is_err());
assert!(started.elapsed() < std::time::Duration::from_secs(3));
}
}