wsi-dicom 0.7.0

Convert whole-slide imaging files to DICOM VL Whole Slide Microscopy
Documentation
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};

use serde::Serialize;

use crate::routing::transfer_syntax_from_uid;
use crate::{Error, TransferSyntax};

pub(super) const WSI_DICOM_AUTO_ROUTE_CACHE_ENV: &str = "WSI_DICOM_AUTO_ROUTE_CACHE";
const ROUTE_CACHE_JSON_MAX_BYTES: u64 = 64 * 1024 * 1024;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum AutoLosslessJ2kRouteDecision {
    Undecided,
    CpuOnly,
    CpuInputDeviceEncode,
    GpuInputDeviceEncode,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(super) struct AutoMetalInputRouteCacheKey {
    pub(super) source_path: PathBuf,
    pub(super) scene_idx: usize,
    pub(super) series_idx: usize,
    pub(super) level: u32,
    pub(super) z: u32,
    pub(super) c: u32,
    pub(super) t: u32,
    pub(super) tile_size: u32,
    pub(super) transfer_syntax: TransferSyntax,
    pub(super) route_scope_frames: u64,
}

#[derive(Debug, Default)]
struct AutoMetalInputRouteCache {
    entries: HashMap<AutoMetalInputRouteCacheKey, AutoLosslessJ2kRouteDecision>,
    loaded_path: Option<PathBuf>,
    dirty: bool,
}

static AUTO_METAL_INPUT_ROUTE_CACHE: OnceLock<Mutex<AutoMetalInputRouteCache>> = OnceLock::new();

#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
struct PersistentAutoMetalInputRouteCacheEntry {
    source_path: PathBuf,
    #[serde(default)]
    scene_idx: usize,
    #[serde(default)]
    series_idx: usize,
    level: u32,
    #[serde(default)]
    z: u32,
    #[serde(default)]
    c: u32,
    #[serde(default)]
    t: u32,
    tile_size: u32,
    transfer_syntax_uid: String,
    #[serde(default)]
    route_scope_frames: u64,
    #[serde(default)]
    route: Option<AutoLosslessJ2kRouteDecision>,
}

fn auto_metal_input_route_cache() -> &'static Mutex<AutoMetalInputRouteCache> {
    AUTO_METAL_INPUT_ROUTE_CACHE.get_or_init(|| Mutex::new(AutoMetalInputRouteCache::default()))
}

pub(super) fn cached_auto_metal_input_decision(
    key: &AutoMetalInputRouteCacheKey,
) -> Option<AutoLosslessJ2kRouteDecision> {
    match auto_metal_input_route_cache().lock() {
        Ok(cache) => cache.entries.get(key).copied(),
        Err(_) => {
            eprintln!("wsi-dicom: auto Metal input route cache mutex is poisoned");
            None
        }
    }
}

pub(super) fn store_cached_auto_metal_input_decision(
    key: &AutoMetalInputRouteCacheKey,
    route: AutoLosslessJ2kRouteDecision,
) {
    if route == AutoLosslessJ2kRouteDecision::Undecided {
        return;
    }
    match auto_metal_input_route_cache().lock() {
        Ok(mut cache) => {
            cache.entries.insert(key.clone(), route);
            cache.dirty = true;
        }
        Err(_) => {
            eprintln!("wsi-dicom: auto Metal input route cache state mutex is poisoned");
        }
    }
}

#[cfg(all(test, feature = "metal", target_os = "macos"))]
pub(super) fn clear_auto_metal_input_route_cache_for_tests() {
    auto_metal_input_route_cache()
        .lock()
        .expect("auto Metal input route cache mutex poisoned")
        .entries
        .clear();
}

#[cfg(all(test, feature = "metal", target_os = "macos"))]
pub(super) fn clear_auto_metal_input_route_cache_state_for_tests() {
    *auto_metal_input_route_cache()
        .lock()
        .expect("auto Metal input route cache state mutex poisoned") =
        AutoMetalInputRouteCache::default();
}

fn persistent_auto_metal_input_route_cache_path() -> Option<PathBuf> {
    std::env::var_os(WSI_DICOM_AUTO_ROUTE_CACHE_ENV)
        .filter(|path| !path.is_empty())
        .map(PathBuf::from)
}

pub(super) fn load_persistent_auto_metal_input_route_cache_if_requested() -> Result<(), Error> {
    let Some(path) = persistent_auto_metal_input_route_cache_path() else {
        return Ok(());
    };
    let mut cache = auto_metal_input_route_cache()
        .lock()
        .map_err(|_| Error::Unsupported {
            reason: "auto Metal input route cache mutex is poisoned".into(),
        })?;
    if cache.loaded_path.as_ref() == Some(&path) {
        return Ok(());
    }
    if cache.dirty {
        return Err(Error::Unsupported {
            reason: "auto Metal input route cache path changed while unsaved decisions remain"
                .into(),
        });
    }

    let bytes = match read_route_cache_file_capped(&path) {
        Ok(bytes) => bytes,
        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Vec::new(),
        Err(source) => {
            return Err(Error::Io { path, source });
        }
    };

    let mut loaded_entries = HashMap::new();
    if !bytes.is_empty() {
        let entries: Vec<PersistentAutoMetalInputRouteCacheEntry> = serde_json::from_slice(&bytes)
            .map_err(|source| Error::Json {
                path: path.clone(),
                source,
            })?;
        for entry in entries {
            let Some(route) = entry
                .route
                .filter(|route| *route != AutoLosslessJ2kRouteDecision::Undecided)
            else {
                continue;
            };
            let transfer_syntax =
                transfer_syntax_from_uid(&entry.transfer_syntax_uid).ok_or_else(|| {
                    Error::Unsupported {
                        reason: format!(
                            "auto route cache {} contains unsupported transfer syntax UID {}",
                            path.display(),
                            entry.transfer_syntax_uid
                        ),
                    }
                })?;
            loaded_entries.insert(
                AutoMetalInputRouteCacheKey {
                    source_path: entry.source_path,
                    scene_idx: entry.scene_idx,
                    series_idx: entry.series_idx,
                    level: entry.level,
                    z: entry.z,
                    c: entry.c,
                    t: entry.t,
                    tile_size: entry.tile_size,
                    transfer_syntax,
                    route_scope_frames: entry.route_scope_frames,
                },
                route,
            );
        }
    }

    cache.entries = loaded_entries;
    cache.loaded_path = Some(path);
    cache.dirty = false;
    Ok(())
}

pub(super) fn flush_persistent_auto_metal_input_route_cache_if_requested() -> Result<(), Error> {
    let Some(path) = persistent_auto_metal_input_route_cache_path() else {
        return Ok(());
    };
    let mut cache = auto_metal_input_route_cache()
        .lock()
        .map_err(|_| Error::Unsupported {
            reason: "auto Metal input route cache mutex is poisoned".into(),
        })?;
    if !cache.dirty && cache.loaded_path.as_ref() == Some(&path) {
        return Ok(());
    }

    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            fs::create_dir_all(parent).map_err(|source| Error::Io {
                path: parent.to_path_buf(),
                source,
            })?;
        }
    }

    reject_symlink_route_cache_path(&path)?;

    let mut entries: Vec<_> = cache
        .entries
        .iter()
        .map(|(key, route)| PersistentAutoMetalInputRouteCacheEntry {
            source_path: key.source_path.clone(),
            scene_idx: key.scene_idx,
            series_idx: key.series_idx,
            level: key.level,
            z: key.z,
            c: key.c,
            t: key.t,
            tile_size: key.tile_size,
            transfer_syntax_uid: key.transfer_syntax.uid().to_string(),
            route_scope_frames: key.route_scope_frames,
            route: Some(*route),
        })
        .collect();
    entries.sort_by(|left, right| {
        left.source_path
            .cmp(&right.source_path)
            .then(left.scene_idx.cmp(&right.scene_idx))
            .then(left.series_idx.cmp(&right.series_idx))
            .then(left.level.cmp(&right.level))
            .then(left.z.cmp(&right.z))
            .then(left.c.cmp(&right.c))
            .then(left.t.cmp(&right.t))
            .then(left.tile_size.cmp(&right.tile_size))
            .then(left.transfer_syntax_uid.cmp(&right.transfer_syntax_uid))
            .then(left.route_scope_frames.cmp(&right.route_scope_frames))
    });
    let bytes = serde_json::to_vec_pretty(&entries).map_err(|source| Error::JsonSerialize {
        message: format!("auto route cache serialization failed: {source}"),
    })?;
    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > ROUTE_CACHE_JSON_MAX_BYTES {
        return Err(Error::Unsupported {
            reason: format!(
                "auto route cache serialization exceeds {ROUTE_CACHE_JSON_MAX_BYTES} byte limit"
            ),
        });
    }
    atomic_write_route_cache(&path, &bytes)?;

    cache.loaded_path = Some(path);
    cache.dirty = false;
    Ok(())
}

fn reject_symlink_route_cache_path(path: &Path) -> Result<(), Error> {
    match fs::symlink_metadata(path) {
        Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::Io {
            path: path.to_path_buf(),
            source: std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "auto route cache path must not be a symbolic link",
            ),
        }),
        Ok(_) => Ok(()),
        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(source) => Err(Error::Io {
            path: path.to_path_buf(),
            source,
        }),
    }
}

fn atomic_write_route_cache(path: &Path, bytes: &[u8]) -> Result<(), Error> {
    let parent = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let mut temporary = tempfile::Builder::new()
        .prefix(".wsi-dicom-route-cache-")
        .tempfile_in(parent)
        .map_err(|source| Error::Io {
            path: parent.to_path_buf(),
            source,
        })?;
    temporary.write_all(bytes).map_err(|source| Error::Io {
        path: temporary.path().to_path_buf(),
        source,
    })?;
    temporary.flush().map_err(|source| Error::Io {
        path: temporary.path().to_path_buf(),
        source,
    })?;
    temporary.as_file().sync_all().map_err(|source| Error::Io {
        path: temporary.path().to_path_buf(),
        source,
    })?;
    temporary.persist(path).map_err(|error| Error::Io {
        path: path.to_path_buf(),
        source: error.error,
    })?;
    #[cfg(unix)]
    fs::File::open(parent)
        .and_then(|directory| directory.sync_all())
        .map_err(|source| Error::Io {
            path: parent.to_path_buf(),
            source,
        })?;
    Ok(())
}

fn read_route_cache_file_capped(path: &PathBuf) -> std::io::Result<Vec<u8>> {
    use std::io::Read;

    let file = fs::File::open(path)?;
    let mut limited = file.take(ROUTE_CACHE_JSON_MAX_BYTES.saturating_add(1));
    let mut bytes = Vec::new();
    limited.read_to_end(&mut bytes)?;
    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > ROUTE_CACHE_JSON_MAX_BYTES {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!(
                "auto route cache exceeds {} byte limit",
                ROUTE_CACHE_JSON_MAX_BYTES
            ),
        ));
    }
    Ok(bytes)
}

#[cfg(test)]
mod tests {
    use super::reject_symlink_route_cache_path;

    #[cfg(unix)]
    #[test]
    fn route_cache_path_rejects_symlinks_without_touching_the_target() {
        let temp = tempfile::tempdir().expect("create temporary directory");
        let target = temp.path().join("target.json");
        let link = temp.path().join("cache.json");
        std::fs::write(&target, b"trusted").expect("write target");
        std::os::unix::fs::symlink(&target, &link).expect("create symlink");

        let error = reject_symlink_route_cache_path(&link).expect_err("reject symlink");

        assert!(error.to_string().contains("symbolic link"));
        assert_eq!(std::fs::read(&target).expect("read target"), b"trusted");
        assert!(std::fs::symlink_metadata(&link)
            .expect("read symlink metadata")
            .file_type()
            .is_symlink());
    }
}