oidn 2.4.1

A wrapper for the Intel OpenImageDenoise image denoising library.
Documentation
pub fn oidn_dir(root: &std::path::Path) -> Option<std::path::PathBuf> {
    if let Some(dir) = non_empty_env_path("OIDN_DIR") {
        return Some(dir);
    }

    let version = std::env::var("OIDN_VERSION")
        .ok()
        .filter(|version| !version.is_empty())
        .or_else(|| package_version(root))?;

    oidn_package_dirs(root, &version)
        .into_iter()
        .find(|dir| dir.is_dir())
}

pub fn oidn_package_dirs(root: &std::path::Path, version: &str) -> Vec<std::path::PathBuf> {
    platform_package_suffixes()
        .iter()
        .map(|suffix| root.join(format!("oidn-{version}.{suffix}")))
        .collect()
}

pub fn non_empty_env_path(name: &str) -> Option<std::path::PathBuf> {
    std::env::var_os(name)
        .filter(|value| !value.is_empty())
        .map(std::path::PathBuf::from)
}

#[cfg(feature = "bundled")]
pub fn download_and_extract_oidn(
    root: &std::path::Path,
) -> std::result::Result<std::path::PathBuf, Box<dyn std::error::Error>> {
    let version = std::env::var("OIDN_VERSION")
        .ok()
        .filter(|v| !v.is_empty())
        .or_else(|| package_version(root))
        .ok_or("OIDN version could not be determined")?;

    let info = package_asset_info()?;
    let archive_name = format!("oidn-{version}.{}", info.archive_suffix);
    let archive_path = root.join(&archive_name);
    let package_dir = root.join(format!("oidn-{version}.{}", info.package_suffix));

    ensure_archive(
        &archive_path,
        &format!(
            "https://github.com/OpenImageDenoise/oidn/releases/download/v{version}/{archive_name}",
            version = version,
            archive_name = archive_name
        ),
        info.sha256,
    )?;

    if !package_dir.is_dir() {
        extract_archive(root, &archive_path)?;
    }

    Ok(package_dir)
}

pub fn find_bundled_oidn_dir(root: &std::path::Path) -> Option<std::path::PathBuf> {
    if let Some(dir) = std::env::var_os("OIDN_BUNDLED_DIR")
        .filter(|value| !value.is_empty())
        .map(std::path::PathBuf::from)
        .filter(|dir| dir.is_dir())
    {
        return Some(dir);
    }

    let version = package_version(root)?;
    let dirs = platform_package_suffixes()
        .iter()
        .map(|s| root.join(format!("oidn-{version}.{s}")));

    dirs.into_iter().find(|d| d.is_dir())
}

#[cfg(feature = "bundled")]
struct PackageAssetInfo {
    package_suffix: &'static str,
    archive_suffix: &'static str,
    sha256: &'static str,
}

#[cfg(feature = "bundled")]
fn ensure_archive(
    archive_path: &std::path::Path,
    url: &str,
    expected_sha: &str,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
    if archive_path.exists() {
        if verify_sha256(archive_path, expected_sha)? {
            println!("Using cached archive {}", archive_path.display());
            return Ok(());
        }
        std::fs::remove_file(archive_path)?;
    }

    download_archive(archive_path, url)?;
    if !verify_sha256(archive_path, expected_sha)? {
        return Err(format!(
            "downloaded archive {} failed SHA-256 verification",
            archive_path.display()
        )
        .into());
    }
    Ok(())
}

#[cfg(feature = "bundled")]
fn download_archive(
    archive_path: &std::path::Path,
    url: &str,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
    println!("Downloading {}", url);
    let response = ureq::get(url).call()?;
    let mut reader = response.into_parts().1.into_reader();
    let mut file = std::fs::File::create(archive_path)?;
    std::io::copy(&mut reader, &mut file)?;
    Ok(())
}

#[cfg(feature = "bundled")]
fn verify_sha256(
    path: &std::path::Path,
    expected_hex: &str,
) -> std::result::Result<bool, Box<dyn std::error::Error>> {
    let mut file = std::fs::File::open(path)?;
    let mut hasher = <sha2::Sha256 as sha2::Digest>::new();
    let mut buffer = [0u8; 8192];
    loop {
        let count = std::io::Read::read(&mut file, &mut buffer)?;
        if count == 0 {
            break;
        }
        sha2::Digest::update(&mut hasher, &buffer[..count]);
    }
    let actual_hex = sha2::Digest::finalize(hasher)
        .iter()
        .map(|b| format!("{:02x}", b))
        .collect::<String>();
    Ok(actual_hex == expected_hex)
}

#[cfg(feature = "bundled")]
fn extract_archive(
    root: &std::path::Path,
    archive_path: &std::path::Path,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
    let name = archive_path
        .file_name()
        .and_then(|n| n.to_str())
        .ok_or("invalid archive name")?;

    if name.ends_with(".zip") {
        extract_zip_archive(root, archive_path)
    } else if name.ends_with(".tar.gz") {
        extract_tar_gz_archive(root, archive_path)
    } else {
        Err("unsupported archive format".into())
    }
}

#[cfg(feature = "bundled")]
fn extract_zip_archive(
    root: &std::path::Path,
    archive_path: &std::path::Path,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
    let file = std::fs::File::open(archive_path)?;
    let mut archive = zip::ZipArchive::new(file)?;

    for index in 0..archive.len() {
        let mut entry = archive.by_index(index)?;
        let Some(enclosed_name) = entry
            .enclosed_name()
            .as_deref()
            .map(std::path::Path::to_path_buf)
        else {
            return Err(format!("zip archive contains unsafe path: {}", entry.name()).into());
        };

        let output_path = safe_archive_path(root, &enclosed_name)?;

        if entry.is_dir() {
            std::fs::create_dir_all(&output_path)?;
            continue;
        }

        if let Some(parent) = output_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let mut output = std::fs::File::create(&output_path)?;
        std::io::copy(&mut entry, &mut output)?;
    }

    Ok(())
}

#[cfg(feature = "bundled")]
fn extract_tar_gz_archive(
    root: &std::path::Path,
    archive_path: &std::path::Path,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
    let file = std::fs::File::open(archive_path)?;
    let decoder = flate2::read::GzDecoder::new(file);
    let mut archive = tar::Archive::new(decoder);

    for entry in archive.entries()? {
        let mut entry = entry?;
        let entry_path = entry.path()?.into_owned();
        let output_path = safe_archive_path(root, &entry_path)?;

        if let Some(parent) = output_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        entry.unpack(&output_path)?;
    }

    Ok(())
}

#[cfg(feature = "bundled")]
fn safe_archive_path(
    root: &std::path::Path,
    entry_name: &std::path::Path,
) -> std::result::Result<std::path::PathBuf, Box<dyn std::error::Error>> {
    use std::path::Component;

    if entry_name.is_absolute() {
        return Err(format!("archive entry has absolute path: {}", entry_name.display()).into());
    }

    if entry_name.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::Prefix(_) | Component::RootDir
        )
    }) {
        return Err(format!(
            "archive entry escapes extraction directory: {}",
            entry_name.display()
        )
        .into());
    }

    Ok(root.join(entry_name))
}

pub fn package_version(root: &std::path::Path) -> Option<String> {
    let manifest = std::fs::read_to_string(root.join("Cargo.toml")).ok()?;
    let mut in_package = false;
    for line in manifest.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') {
            in_package = trimmed == "[package]";
            continue;
        }
        if !in_package || !trimmed.starts_with("version") {
            continue;
        }
        let (_, value) = trimmed.split_once('=')?;
        return Some(value.trim().trim_matches('"').to_string());
    }
    None
}

#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
pub fn platform_package_suffixes() -> &'static [&'static str] {
    &["x86_64.linux"]
}

#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub fn platform_package_suffixes() -> &'static [&'static str] {
    &["arm64.macos"]
}

#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
pub fn platform_package_suffixes() -> &'static [&'static str] {
    &["x86_64.macos"]
}

#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
pub fn platform_package_suffixes() -> &'static [&'static str] {
    &["x64.windows"]
}

#[cfg(not(any(
    all(target_os = "linux", target_arch = "x86_64"),
    all(target_os = "macos", target_arch = "aarch64"),
    all(target_os = "macos", target_arch = "x86_64"),
    all(target_os = "windows", target_arch = "x86_64"),
)))]
compile_error!("unsupported host platform for OIDN download");

#[cfg(feature = "bundled")]
fn package_asset_info() -> Result<PackageAssetInfo, Box<dyn std::error::Error>> {
    Ok(package_asset_info_for_target())
}

#[cfg(all(feature = "bundled", target_os = "linux", target_arch = "x86_64"))]
fn package_asset_info_for_target() -> PackageAssetInfo {
    PackageAssetInfo {
        package_suffix: "x86_64.linux",
        archive_suffix: "x86_64.linux.tar.gz",
        sha256: include_str!("../oidn_hashes/x86_64.linux.sha256").trim(),
    }
}

#[cfg(all(feature = "bundled", target_os = "macos", target_arch = "x86_64"))]
fn package_asset_info_for_target() -> PackageAssetInfo {
    PackageAssetInfo {
        package_suffix: "x86_64.macos",
        archive_suffix: "x86_64.macos.tar.gz",
        sha256: include_str!("../oidn_hashes/x86_64.macos.sha256").trim(),
    }
}

#[cfg(all(feature = "bundled", target_os = "macos", target_arch = "aarch64"))]
fn package_asset_info_for_target() -> PackageAssetInfo {
    PackageAssetInfo {
        package_suffix: "arm64.macos",
        archive_suffix: "arm64.macos.tar.gz",
        sha256: include_str!("../oidn_hashes/arm64.macos.sha256").trim(),
    }
}

#[cfg(all(feature = "bundled", target_os = "windows", target_arch = "x86_64"))]
fn package_asset_info_for_target() -> PackageAssetInfo {
    PackageAssetInfo {
        package_suffix: "x64.windows",
        archive_suffix: "x64.windows.zip",
        sha256: include_str!("../oidn_hashes/x64.windows.sha256").trim(),
    }
}

#[cfg(not(any(
    all(target_os = "linux", target_arch = "x86_64"),
    all(target_os = "macos", target_arch = "x86_64"),
    all(target_os = "macos", target_arch = "aarch64"),
    all(target_os = "windows", target_arch = "x86_64"),
)))]
compile_error!("unsupported host platform for OIDN download");