earthsciio 0.1.4

EarthSciIO Rust core: URL download + shared content-addressed cache (transport/store registries, offline mode).
Documentation
//! The shared content-addressed cache key and content hashing.
//!
//! `key = lowercase_hex(sha256(utf8(resolved_url)))` (`spec/cache-format.md` §1).
//! The URL is hashed **exactly as resolved** — no normalization, no trailing
//! newline. All three language tracks MUST hash the identical byte string so a
//! blob fetched by one is reused, byte-for-byte, by the others.

use std::path::Path;

use sha2::{Digest, Sha256};

/// Compute the cache key for a resolved URL: `sha256(utf8(url))`, lowercase hex.
///
/// ```
/// assert_eq!(
///     earthsciio::cache_key("https://data.earthsci.dev/era5/2018/11/20181108.nc"),
///     "11cdcec111409f586e6afc432e1a6da47e6f97ccf3715e5db8554632b00671c1",
/// );
/// ```
pub fn cache_key(resolved_url: &str) -> String {
    hex_lower(&Sha256::digest(resolved_url.as_bytes()))
}

/// Cache key for a byte sub-range request. The spec appends `#bytes=<a>-<b>` to
/// the URL **before** hashing, so a sub-slice is its own cache entry
/// (`spec/cache-format.md` §1).
pub fn cache_key_range(resolved_url: &str, start: u64, end: u64) -> String {
    cache_key(&format!("{resolved_url}#bytes={start}-{end}"))
}

/// sha256 of a byte slice as lowercase hex (the manifest's `sha256_content`).
pub fn sha256_hex(data: &[u8]) -> String {
    hex_lower(&Sha256::digest(data))
}

/// Stream a file through sha256 without loading it fully into memory. Used for
/// integrity re-verification (cheap; off by default, on for CI/conformance).
pub fn sha256_file(path: &Path) -> std::io::Result<String> {
    let mut file = std::fs::File::open(path)?;
    let mut hasher = Sha256::new();
    std::io::copy(&mut file, &mut hasher)?;
    Ok(hex_lower(&hasher.finalize()))
}

/// A writer that folds every byte it forwards into a sha256, so a transport
/// can produce the manifest digest while the download streams to the staging
/// file — no second full read at commit. Only bytes the inner writer accepts
/// are hashed, so the digest always matches the file content.
pub(crate) struct Sha256Writer<W> {
    inner: W,
    hasher: Sha256,
}

impl<W: std::io::Write> Sha256Writer<W> {
    pub(crate) fn new(inner: W) -> Self {
        Self {
            inner,
            hasher: Sha256::new(),
        }
    }

    /// The digest of every byte written, as lowercase hex.
    pub(crate) fn finalize(self) -> String {
        hex_lower(&self.hasher.finalize())
    }
}

impl<W: std::io::Write> std::io::Write for Sha256Writer<W> {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        let n = self.inner.write(buf)?;
        self.hasher.update(&buf[..n]);
        Ok(n)
    }

    fn flush(&mut self) -> std::io::Result<()> {
        self.inner.flush()
    }
}

fn hex_lower(bytes: &[u8]) -> String {
    use std::fmt::Write as _;
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        let _ = write!(s, "{b:02x}");
    }
    s
}

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

    #[test]
    fn era5_key_matches_corpus() {
        // Worked example pinned in spec/cache-format.md §1.
        assert_eq!(
            cache_key("https://data.earthsci.dev/era5/2018/11/20181108.nc"),
            "11cdcec111409f586e6afc432e1a6da47e6f97ccf3715e5db8554632b00671c1"
        );
    }

    #[test]
    fn openaq_key_matches_corpus() {
        assert_eq!(
            cache_key(
                "https://openaq-data-archive.s3.amazonaws.com/records/openaq/locationid=1/2018-11-08.csv"
            ),
            "69dd26b950e43cb2182e3b4d02e89e09bfb798b13469183ca2dad15c5794379a"
        );
    }

    #[test]
    fn key_is_exact_bytes_no_newline() {
        // A trailing newline must change the key — proves "no normalization".
        assert_ne!(cache_key("https://x/y"), cache_key("https://x/y\n"));
    }

    #[test]
    fn range_key_differs_from_whole() {
        let url = "https://x/y.nc";
        assert_ne!(cache_key(url), cache_key_range(url, 0, 1023));
        assert_eq!(
            cache_key_range(url, 0, 1023),
            cache_key("https://x/y.nc#bytes=0-1023")
        );
    }

    #[test]
    fn sha256_writer_matches_whole_buffer_digest() {
        // The in-transit digest must equal the digest of the final file content.
        use std::io::Write as _;
        let mut w = Sha256Writer::new(Vec::new());
        w.write_all(b"hello-").unwrap();
        w.write_all(b"bytes").unwrap();
        assert_eq!(w.finalize(), sha256_hex(b"hello-bytes"));
    }

    #[test]
    fn sha256_hex_known_vector() {
        // sha256("") well-known digest.
        assert_eq!(
            sha256_hex(b""),
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }
}