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");
}
}