use std::fs::{self, File};
use std::io::{self, BufReader, Read, Seek, Write};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock, mpsc};
use std::time::Duration;
use flate2::read::GzDecoder;
use reqwest::blocking::{Client, Response};
use sha2::{Digest, Sha256};
use tar::Archive;
use xz2::read::XzDecoder;
use zip::ZipArchive;
use crate::cancellation::{delay, requested};
use crate::config::schema::{ArchiveInstall, NetworkPolicy};
use crate::error::ForgeError;
use crate::events::{LifecycleEvent, emit};
use crate::fsutil::{
acquire_named_lock, atomic_write_file, create_dir_all, lock_exclusive_cancellable, remove_file,
};
use crate::model::ArchiveFormat;
use crate::paths::app_home;
use crate::util::now_secs;
const DEFAULT_TIMEOUT_SECS: u64 = 60;
const DEFAULT_RETRIES: u32 = 2;
const CONNECT_TIMEOUT_SECS: u64 = 10;
const DOWNLOAD_POLL_MILLIS: u64 = 50;
const READ_RETRY_MILLIS: u64 = 10;
static DOWNLOAD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub(crate) fn resolved_download_url(download: &ArchiveInstall) -> String {
download
.url
.replace("{os}", std::env::consts::OS)
.replace("{arch}", canonical_arch(std::env::consts::ARCH))
.replace("{abi}", current_abi())
}
pub(crate) fn resolved_target(download: &ArchiveInstall) -> PathBuf {
expand_target(&download.target)
}
pub(crate) fn install_download(
download: &ArchiveInstall,
network: NetworkPolicy,
) -> Result<PathBuf, ForgeError> {
let url = resolved_download_url(download);
if !url.starts_with("https://") {
return Err(ForgeError::Config(format!(
"structured downloads only allow HTTPS URLs: {url}"
)));
}
validate_sha256(&download.sha256)?;
let target = resolved_target(download);
validate_managed_target(&target)?;
let cache = cached_download(&url, download, network)?;
let _target_lease = acquire_named_lock("archive-target", &target.to_string_lossy())?;
install_cached_archive(
&cache,
download.format,
&target,
download.strip_components,
download.allow_links,
)?;
Ok(target)
}
fn validate_managed_target(target: &Path) -> Result<(), ForgeError> {
let root = app_home();
validate_target_with_root(target, &root)
}
fn validate_target_with_root(target: &Path, root: &Path) -> Result<(), ForgeError> {
create_dir_all(root)?;
let relative = target.strip_prefix(root).map_err(|_| {
ForgeError::Config(format!(
"archive target escapes BOT_FORGE_HOME: {}",
target.display()
))
})?;
if relative.as_os_str().is_empty() {
return Err(ForgeError::Config(
"archive target cannot be the BOT_FORGE_HOME root".into(),
));
}
let canonical_root = root.canonicalize().map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?;
let mut current = root.to_path_buf();
for component in relative.components() {
current.push(component.as_os_str());
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(ForgeError::Config(format!(
"archive target path cannot traverse a symbolic link: {}",
current.display()
)));
}
Ok(_) => {
let canonical = current.canonicalize().map_err(|source| ForgeError::Io {
path: current.clone(),
source,
})?;
if !canonical.starts_with(&canonical_root) {
return Err(ForgeError::Config(format!(
"archive target escapes BOT_FORGE_HOME: {}",
target.display()
)));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(ForgeError::Io {
path: current,
source,
});
}
}
}
Ok(())
}
fn cached_download(
url: &str,
download: &ArchiveInstall,
network: NetworkPolicy,
) -> Result<PathBuf, ForgeError> {
let cache_dir = app_home().join("cache").join("downloads");
create_dir_all(&cache_dir)?;
let cache = cache_dir.join(download.sha256.to_ascii_lowercase());
if cache.is_file() && checksum_matches(&cache, &download.sha256)? {
return Ok(cache);
}
if network != NetworkPolicy::Online {
return Err(ForgeError::Network(format!(
"network={} and the archive verification cache missed: {}",
match network {
NetworkPolicy::CacheOnly => "cache-only",
NetworkPolicy::Offline => "offline",
NetworkPolicy::Online => unreachable!(),
},
download.sha256
)));
}
let lock = {
let mut locks = download_locks()
.lock()
.map_err(|_| ForgeError::Command("download lock map poisoned".into()))?;
locks
.entry(download.sha256.to_ascii_lowercase())
.or_insert_with(|| Arc::new(DownloadLock::default()))
.clone()
};
let _guard = lock.acquire()?;
let lock_path = cache.with_extension("lock");
let lock_file = File::options()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.map_err(|source| ForgeError::Io {
path: lock_path.clone(),
source,
})?;
lock_exclusive_cancellable(&lock_file, &lock_path, "archive download lock")?;
if cache.is_file() && checksum_matches(&cache, &download.sha256)? {
return Ok(cache);
}
if cache.exists() {
quarantine(&cache, &download.sha256)?;
}
let retries = DEFAULT_RETRIES;
let timeout = Duration::from_secs(DEFAULT_TIMEOUT_SECS);
let client = Client::builder()
.connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
.timeout(timeout)
.build()
.map_err(network_error)?;
let mut last_error = None;
for attempt in 0..=retries {
let temporary = cache.with_extension(format!(
"part-{}-{}",
std::process::id(),
DOWNLOAD_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
match download_once_cancellable(&client, url, &temporary, timeout) {
Ok(()) if checksum_matches(&temporary, &download.sha256)? => {
fs::rename(&temporary, &cache).map_err(|source| ForgeError::Io {
path: cache.clone(),
source,
})?;
return Ok(cache);
}
Ok(()) => {
quarantine(&temporary, &download.sha256)?;
last_error = Some("SHA-256 verification failed".to_string());
}
Err(error) => {
if requested() {
let _ = remove_file(&temporary);
return Err(error);
}
last_error = Some(error.to_string());
}
}
let _ = remove_file(&temporary);
if attempt < retries {
delay(
Duration::from_millis(250 * 2_u64.pow(attempt.min(5))),
"archive download retry backoff",
)?;
}
}
Err(ForgeError::Network(format!(
"download failed: {}",
last_error.unwrap_or_else(|| "unknown error".to_string())
)))
}
fn quarantine(path: &Path, digest: &str) -> Result<(), ForgeError> {
if !path.exists() {
return Ok(());
}
let directory = app_home().join("cache").join("quarantine");
create_dir_all(&directory)?;
let destination = directory.join(format!(
"{}-{}-{}",
digest.to_ascii_lowercase(),
std::process::id(),
now_secs()
));
fs::rename(path, &destination).map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?;
let metadata = serde_json::json!({
"kind": "download",
"identity": digest.to_ascii_lowercase(),
"reason": "checksum mismatch or incomplete download",
"quarantined_at": now_secs(),
});
atomic_write_file(
&destination.with_extension("json"),
&serde_json::to_vec_pretty(&metadata).map_err(|error| {
ForgeError::Parse(format!("failed to serialize quarantine metadata: {error}"))
})?,
)
}
#[derive(Default)]
struct DownloadLock {
state: Mutex<bool>,
wake: Condvar,
}
impl DownloadLock {
fn acquire(&self) -> Result<DownloadLockGuard<'_>, ForgeError> {
emit(
None,
None,
Some("archive"),
LifecycleEvent::ResourceWait {
resource: "in-process archive download lock".into(),
},
);
let started = std::time::Instant::now();
let mut active = self
.state
.lock()
.map_err(|_| ForgeError::Command("download lock poisoned".into()))?;
while *active {
if requested() {
return Err(ForgeError::Command(
"cancelled while waiting for the in-process archive download lock".into(),
));
}
let (next, _) = self
.wake
.wait_timeout(active, Duration::from_millis(50))
.map_err(|_| ForgeError::Command("download lock poisoned".into()))?;
active = next;
}
*active = true;
emit(
None,
None,
Some("archive"),
LifecycleEvent::ResourceAcquired {
resource: "in-process archive download lock".into(),
wait_ms: started.elapsed().as_millis(),
},
);
Ok(DownloadLockGuard { lock: self })
}
}
struct DownloadLockGuard<'a> {
lock: &'a DownloadLock,
}
impl Drop for DownloadLockGuard<'_> {
fn drop(&mut self) {
if let Ok(mut active) = self.lock.state.lock() {
*active = false;
self.lock.wake.notify_one();
}
}
}
fn download_locks() -> &'static Mutex<std::collections::BTreeMap<String, Arc<DownloadLock>>> {
static LOCKS: OnceLock<Mutex<std::collections::BTreeMap<String, Arc<DownloadLock>>>> =
OnceLock::new();
LOCKS.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()))
}
fn download_once_cancellable(
client: &Client,
url: &str,
path: &Path,
timeout: Duration,
) -> Result<(), ForgeError> {
let cancellation = Arc::new(AtomicBool::new(false));
let worker_cancellation = Arc::clone(&cancellation);
let worker_client = client.clone();
let worker_url = url.to_string();
let worker_path = path.to_path_buf();
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let result = download_once(&worker_client, &worker_url, &worker_path, timeout, || {
worker_cancellation.load(Ordering::SeqCst) || requested()
});
let delivered = sender.send(result).is_ok();
if !delivered || worker_cancellation.load(Ordering::SeqCst) || requested() {
let _ = remove_file(&worker_path);
}
});
wait_for_download_result(&receiver, &cancellation, requested)
}
fn wait_for_download_result<T, C: Fn() -> bool>(
receiver: &mpsc::Receiver<Result<T, ForgeError>>,
cancellation: &AtomicBool,
cancelled: C,
) -> Result<T, ForgeError> {
loop {
if cancelled() {
cancellation.store(true, Ordering::SeqCst);
return Err(ForgeError::Command(
"cancelled while downloading the archive".into(),
));
}
match receiver.recv_timeout(Duration::from_millis(DOWNLOAD_POLL_MILLIS)) {
Ok(result) => return result,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Err(ForgeError::Command(
"archive download worker exited unexpectedly".into(),
));
}
}
}
}
fn download_once<C: Fn() -> bool>(
client: &Client,
url: &str,
path: &Path,
timeout: Duration,
cancelled: C,
) -> Result<(), ForgeError> {
let response = client.get(url).send().map_err(network_error)?;
let mut response = successful_response(response)?;
if cancelled() {
return Err(ForgeError::Command(
"cancelled while downloading the archive".into(),
));
}
let mut file = File::create(path).map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})?;
copy_reader_cancellable(&mut response, &mut file, path, timeout, cancelled)?;
file.flush().map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})?;
file.sync_all().map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})
}
fn copy_reader_cancellable<R: Read, C: Fn() -> bool>(
reader: &mut R,
file: &mut File,
path: &Path,
timeout: Duration,
cancelled: C,
) -> Result<(), ForgeError> {
let started = std::time::Instant::now();
let mut buffer = [0_u8; 64 * 1024];
loop {
if cancelled() {
return Err(ForgeError::Command(
"cancelled while downloading the archive".into(),
));
}
if started.elapsed() >= timeout {
return Err(ForgeError::Network(format!(
"archive download read exceeded {} seconds",
timeout.as_secs()
)));
}
match reader.read(&mut buffer) {
Ok(0) => return Ok(()),
Ok(read) => file
.write_all(&buffer[..read])
.map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})?,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) if error.kind() == io::ErrorKind::TimedOut => {
std::thread::sleep(Duration::from_millis(READ_RETRY_MILLIS));
}
Err(source) => {
return Err(ForgeError::Io {
path: path.to_path_buf(),
source,
});
}
}
}
}
fn successful_response(response: Response) -> Result<Response, ForgeError> {
response.error_for_status().map_err(network_error)
}
fn network_error(error: reqwest::Error) -> ForgeError {
ForgeError::Network(error.to_string())
}
fn validate_sha256(value: &str) -> Result<(), ForgeError> {
if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
Ok(())
} else {
Err(ForgeError::Config(
"download.sha256 must be a 64-character hexadecimal digest".to_string(),
))
}
}
fn checksum_matches(path: &Path, expected: &str) -> Result<bool, ForgeError> {
let file = File::open(path).map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = reader.read(&mut buffer).map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
let actual = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
Ok(actual.eq_ignore_ascii_case(expected))
}
fn install_cached_archive(
cache: &Path,
format: ArchiveFormat,
target: &Path,
strip_components: usize,
allow_links: bool,
) -> Result<(), ForgeError> {
let parent = target.parent().ok_or_else(|| {
ForgeError::Config(format!(
"archive target is missing a parent directory: {}",
target.display()
))
})?;
create_dir_all(parent)?;
let name = target
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
ForgeError::Config(format!(
"archive target has an invalid file name: {}",
target.display()
))
})?;
let suffix = format!("{}-{}", std::process::id(), now_secs());
let staging = parent.join(format!(".{name}.staging-{suffix}"));
let previous = parent.join(format!(".{name}.previous-{suffix}"));
remove_any(&staging)?;
let extraction = match format {
ArchiveFormat::File => fs::copy(cache, &staging)
.map_err(|source| ForgeError::Io {
path: staging.clone(),
source,
})
.map(|_| ()),
ArchiveFormat::TarGz => unpack_tar(
GzDecoder::new(open(cache)?),
&staging,
strip_components,
allow_links,
),
ArchiveFormat::TarXz => unpack_tar(
XzDecoder::new(open(cache)?),
&staging,
strip_components,
allow_links,
),
ArchiveFormat::Zip => unpack_zip(open(cache)?, &staging, strip_components),
};
if let Err(error) = extraction {
let _ = remove_any(&staging);
return Err(error);
}
if !staging.exists() {
return Err(ForgeError::Config(
"archive staging produced no installable content".into(),
));
}
let had_previous = target.exists();
commit_archive_target(&staging, target, &previous, had_previous)
}
fn commit_archive_target(
staging: &Path,
target: &Path,
previous: &Path,
had_previous: bool,
) -> Result<(), ForgeError> {
commit_archive_target_with(staging, target, previous, had_previous, |source, target| {
fs::rename(source, target)
})
}
fn commit_archive_target_with(
staging: &Path,
target: &Path,
previous: &Path,
had_previous: bool,
mut rename: impl FnMut(&Path, &Path) -> io::Result<()>,
) -> Result<(), ForgeError> {
if had_previous {
rename(target, previous).map_err(|source| ForgeError::Io {
path: target.to_path_buf(),
source,
})?;
}
if let Err(source) = rename(staging, target) {
if had_previous && let Err(restore) = rename(previous, target) {
return Err(ForgeError::Command(format!(
"archive activation failed and restoring the previous version also failed; backup retained at {}: activation error: {source}; restore error: {restore}",
previous.display()
)));
}
let _ = remove_any(staging);
return Err(ForgeError::Io {
path: target.to_path_buf(),
source,
});
}
if had_previous {
let _ = remove_any(previous);
}
Ok(())
}
fn remove_any(path: &Path) -> Result<(), ForgeError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(ForgeError::Io {
path: path.to_path_buf(),
source,
});
}
};
if metadata.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
}
.map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})
}
fn open(path: &Path) -> Result<File, ForgeError> {
File::open(path).map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})
}
fn unpack_tar<R: Read>(
reader: R,
target: &Path,
strip_components: usize,
allow_links: bool,
) -> Result<(), ForgeError> {
create_dir_all(target)?;
let mut archive = Archive::new(reader);
let mut extracted = false;
let mut pending_links = Vec::new();
for entry in archive.entries().map_err(|source| ForgeError::Io {
path: target.to_path_buf(),
source,
})? {
let mut entry = entry.map_err(|source| ForgeError::Io {
path: target.to_path_buf(),
source,
})?;
let path = entry.path().map_err(|source| ForgeError::Io {
path: target.to_path_buf(),
source,
})?;
if !is_safe_relative_path(path.as_ref()) {
return Err(ForgeError::Config(
"TAR contains a path that escapes the destination".to_string(),
));
}
let Some(relative) = strip_path(path.as_ref(), strip_components) else {
continue;
};
let destination = target.join(relative);
let entry_type = entry.header().entry_type();
if entry_type.is_symlink() || entry_type.is_hard_link() {
if !allow_links {
return Err(ForgeError::Config(
"TAR contains a symbolic or hard link".to_string(),
));
}
let link_name = entry
.link_name()
.map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?
.ok_or_else(|| ForgeError::Config("TAR link is missing its target".to_string()))?
.into_owned();
let resolved = if entry_type.is_symlink() {
resolve_symlink_target(target, &destination, &link_name)?
} else {
resolve_hardlink_target(target, &link_name, strip_components)?
};
pending_links.push((destination, link_name, resolved, entry_type.is_hard_link()));
continue;
}
if entry_type.is_dir() {
create_dir_all(&destination)?;
} else {
if let Some(parent) = destination.parent() {
create_dir_all(parent)?;
}
let mut output = File::create(&destination).map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?;
io::copy(&mut entry, &mut output).map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?;
#[cfg(unix)]
{
let mode = entry.header().mode().map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?;
fs::set_permissions(&destination, fs::Permissions::from_mode(mode & 0o777))
.map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?;
}
extracted = true;
}
}
for (destination, link_name, resolved, hard_link) in pending_links {
if let Some(parent) = destination.parent() {
create_dir_all(parent)?;
}
if hard_link {
fs::hard_link(&resolved, &destination).map_err(|source| ForgeError::Io {
path: destination.clone(),
source,
})?;
} else {
create_archive_symlink(&link_name, &resolved, &destination)?;
}
extracted = true;
}
if !extracted {
return Err(ForgeError::Config(format!(
"TAR has no installable files after strip_components={strip_components}"
)));
}
Ok(())
}
fn is_safe_relative_path(path: &Path) -> bool {
!path.is_absolute()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
}
fn resolve_symlink_target(
root: &Path,
destination: &Path,
link: &Path,
) -> Result<PathBuf, ForgeError> {
if link.is_absolute() {
return Err(ForgeError::Config(
"TAR symbolic-link target escapes the install directory".to_string(),
));
}
let parent = destination
.parent()
.and_then(|value| value.strip_prefix(root).ok())
.ok_or_else(|| ForgeError::Config("TAR symbolic-link path is invalid".to_string()))?;
let mut resolved = parent.to_path_buf();
for component in link.components() {
match component {
Component::Normal(value) => resolved.push(value),
Component::CurDir => {}
Component::ParentDir if resolved.pop() => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(ForgeError::Config(
"TAR symbolic-link target escapes the install directory".to_string(),
));
}
}
}
Ok(root.join(resolved))
}
fn resolve_hardlink_target(
root: &Path,
link: &Path,
strip_components: usize,
) -> Result<PathBuf, ForgeError> {
if !is_safe_relative_path(link) {
return Err(ForgeError::Config(
"TAR hard-link target escapes the install directory".to_string(),
));
}
let relative = strip_path(link, strip_components).ok_or_else(|| {
ForgeError::Config("TAR hard-link target was removed by strip_components".to_string())
})?;
Ok(root.join(relative))
}
#[cfg(unix)]
fn create_archive_symlink(link: &Path, _: &Path, destination: &Path) -> Result<(), ForgeError> {
std::os::unix::fs::symlink(link, destination).map_err(|source| ForgeError::Io {
path: destination.to_path_buf(),
source,
})
}
#[cfg(windows)]
fn create_archive_symlink(
link: &Path,
resolved: &Path,
destination: &Path,
) -> Result<(), ForgeError> {
let result = if resolved.is_dir() {
std::os::windows::fs::symlink_dir(link, destination)
} else {
std::os::windows::fs::symlink_file(link, destination)
};
result.map_err(|source| ForgeError::Io {
path: destination.to_path_buf(),
source,
})
}
fn unpack_zip<R: Read + Seek>(
reader: R,
target: &Path,
strip_components: usize,
) -> Result<(), ForgeError> {
create_dir_all(target)?;
let mut archive = ZipArchive::new(reader)
.map_err(|error| ForgeError::Parse(format!("invalid ZIP: {error}")))?;
let mut extracted = false;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| ForgeError::Parse(format!("invalid ZIP entry: {error}")))?;
let Some(relative) = entry.enclosed_name() else {
return Err(ForgeError::Config(
"ZIP contains a path that escapes the destination".to_string(),
));
};
let Some(relative) = strip_path(&relative, strip_components) else {
continue;
};
let path = target.join(relative);
if entry.is_dir() {
create_dir_all(&path)?;
} else {
if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}
let mut output = File::create(&path).map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
io::copy(&mut entry, &mut output).map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
fs::set_permissions(&path, fs::Permissions::from_mode(mode & 0o777)).map_err(
|source| ForgeError::Io {
path: path.clone(),
source,
},
)?;
}
extracted = true;
}
}
if !extracted {
return Err(ForgeError::Config(format!(
"ZIP has no installable files after strip_components={strip_components}"
)));
}
Ok(())
}
fn strip_path(path: &Path, count: usize) -> Option<PathBuf> {
let stripped = path.components().skip(count).collect::<PathBuf>();
(!stripped.as_os_str().is_empty()).then_some(stripped)
}
fn expand_target(path: &Path) -> PathBuf {
let mut components = path.components();
if matches!(components.next(), Some(Component::Normal(value)) if value == "$BOT_FORGE_HOME") {
return components.fold(app_home(), |path, component| {
path.join(component.as_os_str())
});
}
path.to_path_buf()
}
fn canonical_arch(arch: &str) -> &str {
arch
}
fn current_abi() -> &'static str {
if cfg!(target_env = "msvc") {
"msvc"
} else if cfg!(target_env = "gnu") {
"gnu"
} else if cfg!(target_env = "musl") {
"musl"
} else {
"unknown"
}
}
#[cfg(test)]
mod tests {
use std::fs::{self, File};
#[cfg(unix)]
use std::io::Write;
use std::io::{self, Read};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::backends::archive::{
ArchiveFormat, ArchiveInstall, checksum_matches, commit_archive_target_with,
copy_reader_cancellable, install_cached_archive, resolved_download_url, unpack_tar,
validate_sha256, wait_for_download_result,
};
#[cfg(unix)]
use crate::backends::archive::{unpack_zip, validate_target_with_root};
use crate::error::ForgeError;
use crate::util::now_secs;
#[cfg(unix)]
use zip::write::SimpleFileOptions;
fn temporary(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("bot-forge-archive-{name}-{nonce}"))
}
#[test]
fn resolves_platform_placeholders() {
let download = ArchiveInstall {
url: "https://example.invalid/{os}/{arch}/{abi}".to_string(),
sha256: "0".repeat(64),
format: ArchiveFormat::File,
target: PathBuf::from("$BOT_FORGE_HOME/bin/demo"),
strip_components: 0,
allow_links: false,
};
let resolved = resolved_download_url(&download);
assert!(!resolved.contains('{'));
assert!(resolved.contains(std::env::consts::OS));
}
#[test]
fn rejects_tar_symlink_entries() {
let target = temporary("tar-symlink");
let mut bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut bytes);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_path("escape").unwrap();
header.set_link_name("../../outside").unwrap();
header.set_size(0);
header.set_cksum();
builder.append(&header, io::empty()).unwrap();
builder.finish().unwrap();
}
let error = unpack_tar(bytes.as_slice(), &target, 0, false)
.unwrap_err()
.to_string();
assert!(error.contains("symbolic or hard link"), "{error}");
fs::remove_dir_all(target).unwrap();
}
#[cfg(unix)]
#[test]
fn extracts_opted_in_internal_tar_symlink_and_preserves_mode() {
let target = temporary("tar-safe-symlink");
let mut bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut bytes);
let payload = b"#!/bin/sh\nexit 0\n";
let mut file = tar::Header::new_gnu();
file.set_path("node/lib/npm-cli.js").unwrap();
file.set_size(payload.len() as u64);
file.set_mode(0o755);
file.set_cksum();
builder.append(&file, payload.as_slice()).unwrap();
let mut link = tar::Header::new_gnu();
link.set_entry_type(tar::EntryType::Symlink);
link.set_path("node/bin/npm").unwrap();
link.set_link_name("../lib/npm-cli.js").unwrap();
link.set_size(0);
link.set_cksum();
builder.append(&link, io::empty()).unwrap();
builder.finish().unwrap();
}
unpack_tar(bytes.as_slice(), &target, 1, true).unwrap();
assert_eq!(
fs::read(target.join("bin/npm")).unwrap(),
b"#!/bin/sh\nexit 0\n"
);
assert_eq!(
fs::metadata(target.join("lib/npm-cli.js"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o755
);
fs::remove_dir_all(target).unwrap();
}
#[test]
fn opted_in_tar_link_still_rejects_escape() {
let target = temporary("tar-link-escape");
let mut bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut bytes);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_path("node/bin/escape").unwrap();
header.set_link_name("../../../outside").unwrap();
header.set_size(0);
header.set_cksum();
builder.append(&header, io::empty()).unwrap();
builder.finish().unwrap();
}
let error = unpack_tar(bytes.as_slice(), &target, 1, true)
.unwrap_err()
.to_string();
assert!(error.contains("escapes the install directory"), "{error}");
fs::remove_dir_all(target).unwrap();
}
#[test]
fn extracts_opted_in_internal_tar_hardlink() {
let target = temporary("tar-safe-hardlink");
let mut bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut bytes);
let payload = b"payload";
let mut file = tar::Header::new_gnu();
file.set_path("node/lib/source").unwrap();
file.set_size(payload.len() as u64);
file.set_mode(0o644);
file.set_cksum();
builder.append(&file, payload.as_slice()).unwrap();
let mut link = tar::Header::new_gnu();
link.set_entry_type(tar::EntryType::Link);
link.set_path("node/lib/copy").unwrap();
link.set_link_name("node/lib/source").unwrap();
link.set_size(0);
link.set_cksum();
builder.append(&link, io::empty()).unwrap();
builder.finish().unwrap();
}
unpack_tar(bytes.as_slice(), &target, 1, true).unwrap();
assert_eq!(fs::read(target.join("lib/copy")).unwrap(), b"payload");
fs::remove_dir_all(target).unwrap();
}
#[test]
fn rejects_invalid_checksum() {
let error = validate_sha256("not-a-checksum").unwrap_err();
assert!(error.to_string().contains("64-character"));
}
#[cfg(unix)]
#[test]
fn zip_extraction_preserves_executable_permissions() {
let target = temporary("zip-mode");
let mut bytes = std::io::Cursor::new(Vec::new());
{
let mut archive = zip::ZipWriter::new(&mut bytes);
archive
.start_file(
"bin/demo",
SimpleFileOptions::default().unix_permissions(0o755),
)
.unwrap();
archive.write_all(b"#!/bin/sh\nexit 0\n").unwrap();
archive.finish().unwrap();
}
bytes.set_position(0);
unpack_zip(bytes, &target, 0).unwrap();
assert_eq!(
fs::metadata(target.join("bin/demo"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o755
);
fs::remove_dir_all(target).unwrap();
}
#[cfg(unix)]
#[test]
fn rejects_managed_target_through_symlink() {
let root = temporary("target-root");
let outside = temporary("target-outside");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let error = validate_target_with_root(&root.join("escape/demo"), &root)
.unwrap_err()
.to_string();
assert!(error.contains("symbolic link"), "{error}");
fs::remove_dir_all(root).unwrap();
fs::remove_dir_all(outside).unwrap();
}
#[test]
fn corrupted_cache_payload_is_not_accepted() {
let root = std::env::temp_dir().join(format!(
"bot-forge-corrupt-cache-{}-{}",
std::process::id(),
now_secs()
));
fs::create_dir_all(&root).unwrap();
let path = root.join("payload");
fs::write(&path, b"corrupted").unwrap();
assert!(!checksum_matches(&path, &"0".repeat(64)).unwrap());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn copies_verified_file_backend_payload() {
let root = std::env::temp_dir().join(format!(
"bot-forge-download-test-{}-{}",
std::process::id(),
now_secs()
));
fs::create_dir_all(&root).unwrap();
let cache = root.join("cache");
fs::write(&cache, b"verified payload").unwrap();
let target = root.join("target").join("tool.bin");
install_cached_archive(&cache, ArchiveFormat::File, &target, 0, false).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"verified payload");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn archive_activation_atomically_replaces_an_existing_target() {
let root = temporary("atomic-replace");
fs::create_dir_all(&root).unwrap();
let cache = root.join("cache");
let target = root.join("bin").join("tool");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&cache, b"next").unwrap();
fs::write(&target, b"previous").unwrap();
install_cached_archive(&cache, ArchiveFormat::File, &target, 0, false).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"next");
assert_eq!(fs::read_dir(target.parent().unwrap()).unwrap().count(), 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn archive_activation_failure_restores_the_previous_target() {
let root = temporary("atomic-restore");
fs::create_dir_all(&root).unwrap();
let staging = root.join("staging");
let target = root.join("target");
let previous = root.join("previous");
fs::write(&staging, b"next").unwrap();
fs::write(&target, b"previous").unwrap();
let mut calls = 0;
let result = commit_archive_target_with(
&staging,
&target,
&previous,
true,
|source, destination| {
calls += 1;
if calls == 2 {
Err(io::Error::other("injected rename failure"))
} else {
fs::rename(source, destination)
}
},
);
assert!(result.is_err());
assert_eq!(fs::read(&target).unwrap(), b"previous");
assert!(!previous.exists());
assert!(!staging.exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn strips_leading_archive_components() {
let target = temporary("strip-components");
let mut bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut bytes);
let payload = b"payload";
let mut header = tar::Header::new_gnu();
header.set_path("release/bin/tool").unwrap();
header.set_size(payload.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append(&header, payload.as_slice()).unwrap();
builder.finish().unwrap();
}
unpack_tar(bytes.as_slice(), &target, 1, false).unwrap();
assert_eq!(fs::read(target.join("bin/tool")).unwrap(), b"payload");
fs::remove_dir_all(target).unwrap();
}
struct TimeoutThenPayload {
state: u8,
}
impl Read for TimeoutThenPayload {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if self.state == 0 {
self.state = 1;
return Err(io::Error::new(io::ErrorKind::TimedOut, "poll again"));
}
if self.state == 1 {
self.state = 2;
buffer[..7].copy_from_slice(b"payload");
return Ok(7);
}
Ok(0)
}
}
#[test]
fn archive_reader_retries_short_read_timeouts() {
let root = temporary("read-timeout");
fs::create_dir_all(&root).unwrap();
let path = root.join("download.part");
let mut reader = TimeoutThenPayload { state: 0 };
let mut file = File::create(&path).unwrap();
copy_reader_cancellable(
&mut reader,
&mut file,
&path,
Duration::from_secs(1),
|| false,
)
.unwrap();
file.sync_all().unwrap();
assert_eq!(fs::read(&path).unwrap(), b"payload");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn archive_reader_cancellation_is_observed_before_blocking_retry() {
let root = temporary("cancel-read");
fs::create_dir_all(&root).unwrap();
let path = root.join("download.part");
let mut reader = std::io::Cursor::new(Vec::<u8>::new());
let mut file = File::create(&path).unwrap();
let started = std::time::Instant::now();
let error = copy_reader_cancellable(
&mut reader,
&mut file,
&path,
Duration::from_secs(60),
|| true,
)
.unwrap_err();
assert!(error.to_string().contains("cancelled"));
assert!(started.elapsed() < Duration::from_millis(150));
drop(file);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn archive_download_wait_cancels_without_waiting_for_worker() {
let (_sender, receiver) = mpsc::channel::<Result<(), ForgeError>>();
let cancellation = AtomicBool::new(false);
let started = std::time::Instant::now();
let error = wait_for_download_result(&receiver, &cancellation, || true).unwrap_err();
assert!(error.to_string().contains("cancelled"));
assert!(cancellation.load(Ordering::SeqCst));
assert!(started.elapsed() < Duration::from_millis(150));
}
}