telar-dynamic 0.2.1

Runtime decoders and transports for Telar assets: the formats and the sources a Telar application resolves at run time rather than baking at build time.
//! Reading asset bytes from a directory on disk.

use std::path::{Component, Path, PathBuf};

use ui_core::{AssetError, AssetKey, AssetTransport, Reply};

/// An [`AssetTransport`] that resolves each id against a directory.
///
/// Unlike `DiskCache`, which flattens an id into one file name because it owns its own storage, this keeps the id's shape: `"icons/mdi/home.svg"` is `root/icons/mdi/home.svg`. A directory of content is a tree, and reading it as anything else would make half of one unreachable.
///
/// **`root` is the application's decision, not the library's**, because only the application knows whether the files ship with it or arrive afterwards. The two real answers are [`telar::paths::data()`](https://docs.rs/telar) — the per-user data directory, for content downloaded or authored after install — and a directory beside `std::env::current_exe()`, for content shipped alongside the binary. A relative path is neither: it resolves against the working directory, which is wherever the user happened to launch from.
///
/// ```ignore
/// let transport = DirTransport::new(telar::paths::data().join("icons"));
/// let icons = AssetLoader::new(Arc::new(transport), None, Arc::new(SvgDecoder));
/// ```
pub struct DirTransport {
    root: PathBuf,
}

impl DirTransport {
    /// A transport that reads from `root`. The directory is not required to exist yet — one that appears later starts resolving without a restart, and until then every id fails the way a missing file does.
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }

    fn read(&self, id: &str) -> Result<Vec<u8>, AssetError> {
        let path = resolve(&self.root, id)?;
        std::fs::read(&path).map_err(|e| AssetError(format!("{}: {e}", path.display())))
    }
}

impl AssetTransport for DirTransport {
    /// Blocking, like the native HTTP transport and for the same reason: the loader runs this on a job in `reactive_core`'s elastic pool, so a read that waits parks one pooled thread and nothing else.
    fn load(&self, key: &AssetKey, reply: Reply) {
        reply(self.read(&key.id));
    }
}

/// `root`-relative `id` as an absolute path, or an error if it would leave `root`.
///
/// Two layers, because neither catches what the other does. The first rejects the id's *spelling* — a `..`, an absolute path, a Windows volume prefix — before touching the filesystem, which is what stops `../../etc/passwd` and costs nothing. The second canonicalizes and checks containment, which is the only thing that catches a symlink inside `root` pointing out of it: that id is spelled innocently and only the resolved path gives it away.
fn resolve(root: &Path, id: &str) -> Result<PathBuf, AssetError> {
    let relative = Path::new(id);
    for component in relative.components() {
        match component {
            Component::Normal(_) | Component::CurDir => {}
            _ => {
                return Err(AssetError(format!(
                    "asset id `{id}` is not a path inside the asset directory"
                )));
            }
        }
    }

    let candidate = root.join(relative);
    // A path that cannot be canonicalized does not exist, and reporting that is the caller's read to do — but it must not be handed back as if containment had been checked, so it fails here.
    let resolved = candidate
        .canonicalize()
        .map_err(|e| AssetError(format!("{}: {e}", candidate.display())))?;
    let root = root
        .canonicalize()
        .map_err(|e| AssetError(format!("{}: {e}", root.display())))?;
    if !resolved.starts_with(&root) {
        return Err(AssetError(format!(
            "asset id `{id}` resolves outside the asset directory"
        )));
    }
    Ok(resolved)
}

#[cfg(test)]
#[path = "dir_test.rs"]
mod tests;