use std::{
collections::HashMap,
convert::{TryFrom, TryInto},
fs,
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
thread,
};
use serde::{Deserialize, Serialize};
use toml::{Table, Value};
use crate::error::{BinaryError, Error};
#[derive(Debug)]
pub enum Extension {
#[cfg(feature = "gz")]
TarGz,
#[cfg(feature = "xz")]
TarXz,
#[cfg(feature = "zip")]
Zip,
#[cfg(all(feature = "pkg", target_os = "macos"))]
Pkg,
#[cfg(all(feature = "inno", target_os = "windows"))]
Inno,
Folder,
}
impl TryFrom<&str> for Extension {
type Error = BinaryError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Path::new(value).try_into()
}
}
impl TryFrom<&Path> for Extension {
type Error = BinaryError;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
if path.is_dir() {
return Ok(Self::Folder);
};
let extension = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("<none>");
match extension {
#[cfg(all(feature = "pkg", target_os = "macos"))]
"pkg" => Ok(Extension::Pkg),
#[cfg(all(feature = "inno", target_os = "windows"))]
"exe" => Ok(Extension::Inno),
#[cfg(feature = "gz")]
"gz" | "tgz" => Ok(Extension::TarGz),
#[cfg(feature = "xz")]
"xz" => Ok(Extension::TarXz),
#[cfg(feature = "zip")]
"zip" => Ok(Extension::Zip),
e => Err(BinaryError::UnsupportedExtension(e.into())),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Binary {
Follow(FollowBinary),
Url(UrlBinary),
}
impl TryFrom<Value> for Binary {
type Error = Error;
fn try_from(value: Value) -> Result<Self, Self::Error> {
Ok(value.try_into()?)
}
}
#[derive(Debug, Deserialize)]
pub struct FollowBinary {
follows: String,
}
#[derive(Debug, Deserialize)]
pub struct UrlBinary {
url: String,
checksum: Option<String>,
paths: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Paths {
paths: HashMap<String, Vec<PathBuf>>,
follows: HashMap<String, String>,
wildcards: HashMap<String, String>,
}
impl Paths {
pub fn from_binaries<T>(binaries: impl IntoIterator<Item = (String, T)>) -> Result<Self, Error>
where
Binary: TryFrom<T>,
{
let mut res = Self::default();
let mut auto_detect = std::collections::HashSet::new();
let (url_binaries, follow_binaries): (Vec<_>, Vec<_>) = binaries
.into_iter()
.filter_map(|(k, v)| Some((k, v.try_into().ok()?)))
.partition(|(_, bin)| matches!(bin, Binary::Url(_)));
let errors: Vec<BinaryError> = thread::scope(|s| {
let mut handles = Vec::new();
for (name, bin) in url_binaries {
let Binary::Url(bin) = bin else {
unreachable!();
};
let dst = Path::new(&crate::TARGET_DIR).join(&name);
if let Some(ref paths) = bin.paths {
res.paths
.insert(name, paths.iter().map(|p| dst.join(p)).collect());
} else {
auto_detect.insert(name.clone());
res.paths.insert(name, Vec::new());
}
let valid = check_valid_dir(&dst, bin.checksum.as_deref())?;
if !valid {
handles.push(s.spawn(move || make_available(bin, &dst)));
}
}
Ok::<_, BinaryError>(
handles
.into_iter()
.filter_map(|h| h.join().expect("download thread panicked").err())
.collect(),
)
})?;
if let Some(e) = errors.into_iter().next() {
return Err(e.into());
}
for name in &auto_detect {
let dst = Path::new(&crate::TARGET_DIR).join(name);
if let Some(list) = res.paths.get_mut(name) {
*list = find_pkgconfig_dirs(&dst);
}
}
for (name, list) in res.paths.iter_mut() {
let dst = Path::new(&crate::TARGET_DIR).join(name);
let Ok(info) = fs::read_to_string(dst.join("info.toml")) else {
continue;
};
let Ok(table) = toml::from_str::<Table>(&info) else {
continue;
};
if let Some(Value::Array(paths)) = table.get("paths") {
for p in paths.iter().filter_map(|p| p.as_str()) {
let p = dst.join(p);
if !list.contains(&p) {
list.push(p);
}
}
}
}
for (name, bin) in follow_binaries {
let Binary::Follow(bin) = bin else {
unreachable!();
};
if !res.paths.contains_key(&bin.follows) {
return Err(BinaryError::InvalidFollows(name, bin.follows).into());
};
match name.strip_suffix("*") {
Some(wildcard) => res.wildcards.insert(wildcard.into(), bin.follows),
None => res.follows.insert(name, bin.follows),
};
}
Ok(res)
}
}
impl Paths {
pub fn get(&self, key: &str) -> Option<&Vec<PathBuf>> {
if let Some(paths) = self.paths.get(key) {
return Some(paths);
};
if let Some(follows) = self.follows.get(key) {
return self.paths.get(follows);
};
self.wildcards.iter().find_map(|(k, v)| {
key.starts_with(k)
.then_some(v)
.and_then(|v| self.paths.get(v))
})
}
pub fn to_string(&self) -> Result<String, Error> {
Ok(toml::to_string(self)?)
}
pub fn pkg_config_binary(&self) -> Option<PathBuf> {
let exe = if cfg!(windows) {
"pkg-config.exe"
} else {
"pkg-config"
};
self.paths.keys().find_map(|name| {
let candidate = Path::new(&crate::TARGET_DIR)
.join(name)
.join("bin")
.join(exe);
which::which(&candidate).ok()
})
}
#[cfg(windows)]
pub fn dll_search_dirs(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for name in self.paths.keys() {
let bin = Path::new(&crate::TARGET_DIR).join(name).join("bin");
if bin.is_dir() && seen.insert(bin.clone()) {
out.push(bin);
}
}
out
}
}
fn find_pkgconfig_dirs(dir: &Path) -> Vec<PathBuf> {
const MAX_DEPTH: usize = 10;
let mut result = Vec::new();
let mut stack = vec![(dir.to_path_buf(), 0usize)];
while let Some((current, depth)) = stack.pop() {
let Ok(entries) = fs::read_dir(¤t) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = fs::symlink_metadata(&path) else {
continue;
};
if meta.is_dir() {
if path.file_name().is_some_and(|n| n == "pkgconfig") {
result.push(path);
} else if depth < MAX_DEPTH {
stack.push((path, depth + 1));
}
}
}
}
result
}
fn check_valid_dir(dst: &Path, checksum: Option<&str>) -> Result<bool, BinaryError> {
if !dst.try_exists().map_err(BinaryError::InvalidDirectory)? {
return Ok(false);
}
if dst.is_file() {
return Err(BinaryError::DirectoryIsFile(dst.display().to_string()));
}
if let Some(ch) = checksum {
let file = dst.join("checksum");
Ok(file.is_file()
&& ch == fs::read_to_string(file).map_err(BinaryError::InvalidDirectory)?)
} else {
Ok(false)
}
}
fn make_available(bin: UrlBinary, dst: &Path) -> Result<(), BinaryError> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let (url, local) = match bin.url.strip_prefix("file://") {
#[cfg(windows)]
Some(file) => (file.strip_prefix('/').unwrap_or(file), true),
#[cfg(not(windows))]
Some(file) => (file, true),
None => (bin.url.as_str(), false),
};
let ext = url.try_into()?;
if matches!(ext, Extension::Folder) {
if !local {
return Err(BinaryError::UnsupportedExtension("<folder>".into()));
}
let _l = LOCK.get_or_init(|| Mutex::new(())).lock();
create_link(Path::new(url), dst)?;
return Ok(());
}
let file = if local {
fs::read(url).map_err(BinaryError::LocalFileError)?
} else {
let res = attohttpc::get(url)
.header("User-Agent", "system-deps")
.send()?;
if res.status() == attohttpc::StatusCode::IM_A_TEAPOT {
return Err(BinaryError::AntiBot(url.into()));
}
res.error_for_status()?.bytes()?
};
let calculated = sha256::digest(&*file);
let checksum = match bin.checksum {
Some(ch) if *ch == calculated => Ok(ch),
_ => Err(BinaryError::InvalidChecksum(
url.into(),
bin.checksum.unwrap_or("<empty>".into()),
calculated,
)),
}?;
fs::create_dir_all(dst).map_err(BinaryError::DecompressError)?;
decompress(&file, dst, ext)?;
create_info_file(dst)?;
fs::write(dst.join("checksum"), checksum).map_err(BinaryError::DecompressError)?;
Ok(())
}
fn create_info_file(dst: &Path) -> Result<(), BinaryError> {
let info_path = dst.join("info.toml");
if info_path.exists() {
return Ok(());
}
let pc_dirs: Vec<String> = find_pkgconfig_dirs(dst)
.into_iter()
.filter_map(|p| {
p.strip_prefix(dst)
.ok()
.map(|rel| rel.to_string_lossy().into_owned())
})
.collect();
let mut table = toml::Table::new();
table.insert(
"paths".to_string(),
toml::Value::Array(pc_dirs.into_iter().map(toml::Value::String).collect()),
);
fs::write(
info_path,
toml::to_string(&table)
.map_err(|e| BinaryError::DecompressError(std::io::Error::other(e)))?,
)
.map_err(BinaryError::DecompressError)
}
fn decompress(_file: &[u8], _dst: &Path, ext: Extension) -> Result<(), BinaryError> {
match ext {
#[cfg(feature = "gz")]
Extension::TarGz => {
let reader = flate2::read::GzDecoder::new(_file);
let mut archive = tar::Archive::new(reader);
archive.unpack(_dst).map_err(BinaryError::DecompressError)
}
#[cfg(feature = "xz")]
Extension::TarXz => {
let reader = lzma_rust2::XzReader::new(_file, true);
let mut archive = tar::Archive::new(reader);
archive.unpack(_dst).map_err(BinaryError::DecompressError)
}
#[cfg(feature = "zip")]
Extension::Zip => {
let reader = std::io::Cursor::new(_file);
let mut archive =
zip::ZipArchive::new(reader).map_err(|e| BinaryError::DecompressError(e.into()))?;
archive
.extract(_dst)
.map_err(|e| BinaryError::DecompressError(e.into()))
}
#[cfg(all(feature = "pkg", target_os = "macos"))]
Extension::Pkg => {
let reader = std::io::Cursor::new(_file);
pkg_extractor::PkgExtractor::new(reader, Some(_dst.into()))
.extract()
.map_err(|e| BinaryError::DecompressError(std::io::Error::other(format!("{e:?}"))))
}
#[cfg(all(feature = "inno", target_os = "windows"))]
Extension::Inno => {
let cursor = std::io::Cursor::new(_file);
let mut inno = inno::Inno::new(cursor)
.map_err(|e| BinaryError::DecompressError(std::io::Error::other(e.to_string())))?;
for file in inno.files() {
let (entry, data) = file.map_err(|e| {
BinaryError::DecompressError(std::io::Error::other(e.to_string()))
})?;
let Some(destination) = entry.file().normalized_destination() else {
continue;
};
if destination.is_empty() {
continue;
}
let path = _dst.join(destination);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(BinaryError::DecompressError)?;
}
fs::write(&path, &data).map_err(BinaryError::DecompressError)?;
}
Ok(())
}
_ => unreachable!(),
}
}
fn create_link(src: &Path, dst: &Path) -> Result<(), BinaryError> {
if dst.read_link().is_ok_and(|l| l == src) {
return Ok(());
}
if dst.read_link().is_ok() {
#[cfg(unix)]
fs::remove_file(dst).map_err(BinaryError::SymlinkError)?;
#[cfg(windows)]
fs::remove_dir(dst).map_err(BinaryError::SymlinkError)?;
}
#[cfg(unix)]
std::os::unix::fs::symlink(src, dst).map_err(BinaryError::SymlinkError)?;
#[cfg(windows)]
junction::create(src, dst).map_err(BinaryError::SymlinkError)?;
Ok(())
}
pub fn merge(rhs: &mut Table, lhs: Table, force: bool) -> Result<(), Error> {
if force {
for (key, value) in lhs.iter() {
if value.get("url").is_some() {
if let Some(Value::Table(pkg)) = rhs.get_mut(key) {
pkg.remove("follows");
}
}
if let Some(Value::Array(provides)) = value.get("provides") {
for name in provides {
let name = name.as_str().ok_or(Error::IncompatibleMerge)?;
let pkg = rhs
.entry(name)
.or_insert(Value::Table(Table::new()))
.as_table_mut()
.unwrap();
pkg.insert("follows".into(), Value::String(key.into()));
pkg.remove("url");
}
}
}
}
crate::parse::merge(rhs, lhs, force)?;
for value in rhs.values() {
if value.get("url").is_some() && value.get("follows").is_some() {
return Err(Error::IncompatibleMerge);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("system_deps_test_{}", name));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn find_pkgconfig_empty_dir() {
let dir = create_temp_dir("empty");
let result = find_pkgconfig_dirs(&dir);
assert!(result.is_empty());
}
#[test]
fn find_pkgconfig_nonexistent_dir() {
let dir = PathBuf::from("/tmp/system_deps_test_nonexistent_dir_that_does_not_exist");
let result = find_pkgconfig_dirs(&dir);
assert!(result.is_empty());
}
#[test]
fn find_pkgconfig_single() {
let dir = create_temp_dir("single");
fs::create_dir_all(dir.join("lib/pkgconfig")).unwrap();
let result = find_pkgconfig_dirs(&dir);
assert_eq!(result, vec![dir.join("lib/pkgconfig")]);
}
#[test]
fn find_pkgconfig_multiple() {
let dir = create_temp_dir("multiple");
fs::create_dir_all(dir.join("lib/pkgconfig")).unwrap();
fs::create_dir_all(dir.join("share/pkgconfig")).unwrap();
let mut result = find_pkgconfig_dirs(&dir);
result.sort();
let mut expected = vec![dir.join("lib/pkgconfig"), dir.join("share/pkgconfig")];
expected.sort();
assert_eq!(result, expected);
}
#[test]
fn find_pkgconfig_nested_depths() {
let dir = create_temp_dir("nested");
fs::create_dir_all(dir.join("a/b/pkgconfig")).unwrap();
fs::create_dir_all(dir.join("c/pkgconfig")).unwrap();
let mut result = find_pkgconfig_dirs(&dir);
result.sort();
let mut expected = vec![dir.join("a/b/pkgconfig"), dir.join("c/pkgconfig")];
expected.sort();
assert_eq!(result, expected);
}
#[test]
fn find_pkgconfig_file_ignored() {
let dir = create_temp_dir("file_ignored");
fs::write(dir.join("pkgconfig"), "not a directory").unwrap();
fs::create_dir_all(dir.join("lib/pkgconfig")).unwrap();
let result = find_pkgconfig_dirs(&dir);
assert_eq!(result, vec![dir.join("lib/pkgconfig")]);
}
}