nexus-gateway 0.0.1-alpha

S3-compatible object gateway and embedded Nexus console.
use anyhow::{Context, Result};
use md5::Context as Md5Context;

pub fn sanitize_etag(raw: &str) -> String {
    raw.trim().trim_matches('"').to_string()
}

pub fn compute_multipart_etag(part_etags: &[String]) -> Result<String> {
    if part_etags.is_empty() {
        anyhow::bail!("multipart completion requires at least one part");
    }

    let mut digest = Md5Context::new();
    for etag in part_etags {
        let bytes = decode_hex(&sanitize_etag(etag))
            .with_context(|| format!("invalid part etag hex: {etag}"))?;
        digest.consume(&bytes);
    }

    let hash = digest.compute();
    let hash_hex = encode_hex(&hash.0);
    Ok(format!("{}-{}", hash_hex, part_etags.len()))
}

pub fn md5_hex(input: &[u8]) -> String {
    let digest = md5::compute(input);
    encode_hex(&digest.0)
}

fn decode_hex(text: &str) -> Result<Vec<u8>> {
    if text.len() % 2 != 0 {
        anyhow::bail!("hex string must have even length");
    }

    let mut out = Vec::with_capacity(text.len() / 2);
    let bytes = text.as_bytes();
    for idx in (0..bytes.len()).step_by(2) {
        let hi = hex_nibble(bytes[idx]).with_context(|| format!("invalid hex at index {}", idx))?;
        let lo = hex_nibble(bytes[idx + 1])
            .with_context(|| format!("invalid hex at index {}", idx + 1))?;
        out.push((hi << 4) | lo);
    }
    Ok(out)
}

fn hex_nibble(value: u8) -> Result<u8> {
    match value {
        b'0'..=b'9' => Ok(value - b'0'),
        b'a'..=b'f' => Ok(value - b'a' + 10),
        b'A'..=b'F' => Ok(value - b'A' + 10),
        _ => anyhow::bail!("invalid hex nibble"),
    }
}

fn encode_hex(input: &[u8]) -> String {
    let mut out = String::with_capacity(input.len() * 2);
    for byte in input {
        out.push(char::from(b"0123456789abcdef"[(byte >> 4) as usize]));
        out.push(char::from(b"0123456789abcdef"[(byte & 0x0F) as usize]));
    }
    out
}

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

    #[test]
    fn multipart_etag_matches_known_vector() {
        let etag = compute_multipart_etag(&[
            "d41d8cd98f00b204e9800998ecf8427e".to_string(),
            "d41d8cd98f00b204e9800998ecf8427e".to_string(),
        ])
        .expect("etag calc should succeed");

        assert_eq!(etag, "5873dd45edd01f09c1ef2e7819369e8e-2");
    }

    #[test]
    fn sanitize_etag_removes_quotes() {
        assert_eq!(sanitize_etag("\"abc\""), "abc");
    }

    #[test]
    fn multipart_etag_rejects_non_hex_parts() {
        let err = compute_multipart_etag(&["zzz".to_string()]).expect_err("should fail");
        assert!(err.to_string().contains("invalid part etag hex"));
    }

    #[test]
    fn md5_hex_matches_known_value() {
        assert_eq!(md5_hex(b"hello"), "5d41402abc4b2a76b9719d911017c592");
    }
}