use std::borrow::Cow;
use futures::StreamExt as _;
use jmap_types::Id;
use reqwest::header::{HeaderValue, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::client::JmapClient;
use crate::error::ClientError;
#[derive(Debug, Clone, Copy)]
pub struct DownloadBlobParams<'a> {
pub download_url_template: &'a str,
pub account_id: &'a str,
pub blob_id: &'a str,
pub name: &'a str,
pub accept_type: Option<&'a str>,
pub expected_sha256: Option<&'a str>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlobUploadResponse {
pub account_id: Id,
pub blob_id: Id,
#[serde(rename = "type")]
pub content_type: String,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<jmap_cid_types::Sha256>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
pub fn expand_url_template(template: &str, vars: &[(&str, &str)]) -> Result<String, ClientError> {
let mut result = String::with_capacity(template.len() + 64);
let mut rest = template;
while let Some(open) = rest.find('{') {
result.push_str(&rest[..open]);
rest = &rest[open + 1..];
let close = rest.find('}').ok_or_else(|| {
ClientError::InvalidSession("URL template has unmatched '{'".to_owned())
})?;
let name = &rest[..close];
rest = &rest[close + 1..];
if let Some((_, value)) = vars.iter().find(|(n, _)| *n == name) {
result.push_str(&percent_encode(value));
} else {
return Err(ClientError::InvalidSession(format!(
"URL template variable not supplied: {{{name}}}"
)));
}
}
result.push_str(rest);
Ok(result)
}
fn percent_encode(value: &str) -> Cow<'_, str> {
let first_escape = value.bytes().position(|b| !is_unreserved(b));
let Some(first) = first_escape else {
return Cow::Borrowed(value);
};
let mut out = String::with_capacity(value.len() + 2);
out.push_str(&value[..first]);
for byte in value.as_bytes()[first..].iter().copied() {
if is_unreserved(byte) {
out.push(char::from(byte));
} else {
out.push('%');
out.push(hex_nibble_upper(byte >> 4));
out.push(hex_nibble_upper(byte & 0x0f));
}
}
Cow::Owned(out)
}
fn is_unreserved(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'.' || byte == b'_' || byte == b'~'
}
fn hex_nibble_upper(nibble: u8) -> char {
match nibble {
0..=9 => char::from(b'0' + nibble),
10..=15 => char::from(b'A' + nibble - 10),
_ => unreachable!("nibble must be 0–15"),
}
}
fn hex_nibble_lower(nibble: u8) -> char {
match nibble {
0..=9 => char::from(b'0' + nibble),
10..=15 => char::from(b'a' + nibble - 10),
_ => unreachable!("nibble must be 0–15"),
}
}
impl JmapClient {
pub async fn upload_blob(
&self,
upload_url_template: &str,
account_id: &str,
data: bytes::Bytes,
content_type: &str,
) -> Result<BlobUploadResponse, ClientError> {
crate::client::require_http_url(upload_url_template)?;
let ct_hv =
HeaderValue::from_str(content_type).map_err(ClientError::from_invalid_header)?;
let url = expand_url_template(upload_url_template, &[("accountId", account_id)])?;
let local_sha256 = compute_sha256_hex(&data);
let local_size = data.len() as u64;
let req = self.inject_auth(
self.http
.post(&url)
.header(CONTENT_TYPE, ct_hv)
.timeout(self.config.request_timeout)
.body(data),
);
let resp = req.send().await.map_err(ClientError::from_reqwest)?;
let status = resp.status();
Self::check_auth_status(status)?;
let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
let upload_limit = self.config.max_upload_response_body;
if let Some(len) = resp.content_length() {
if len > upload_limit {
return Err(ClientError::ResponseTooLarge {
actual: len,
limit: upload_limit,
});
}
}
let bytes = resp.bytes().await.map_err(ClientError::from_reqwest)?;
if bytes.len() as u64 > upload_limit {
return Err(ClientError::ResponseTooLarge {
actual: bytes.len() as u64,
limit: upload_limit,
});
}
let upload_resp: BlobUploadResponse =
serde_json::from_slice(&bytes).map_err(ClientError::Parse)?;
if upload_resp.size != local_size {
return Err(ClientError::UnexpectedResponse(format!(
"blob upload size mismatch: client uploaded {local_size} bytes, server reports \
{server_size} bytes",
server_size = upload_resp.size,
)));
}
if let Some(ref server_sha256) = upload_resp.sha256 {
if local_sha256 != server_sha256.as_str() {
return Err(ClientError::BlobIntegrityMismatch {
expected: local_sha256,
actual: server_sha256.as_str().to_owned(),
});
}
}
Ok(upload_resp)
}
pub async fn download_blob(
&self,
params: DownloadBlobParams<'_>,
) -> Result<bytes::Bytes, ClientError> {
let DownloadBlobParams {
download_url_template,
account_id,
blob_id,
name,
accept_type,
expected_sha256,
} = params;
crate::client::require_http_url(download_url_template)?;
let vars = [
("accountId", account_id),
("blobId", blob_id),
("name", name),
("type", accept_type.unwrap_or("")),
];
let url = expand_url_template(download_url_template, &vars)?;
let req = self.inject_auth(self.http.get(&url).timeout(self.config.request_timeout));
let resp = req.send().await.map_err(ClientError::from_reqwest)?;
let status = resp.status();
Self::check_auth_status(status)?;
let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
let download_limit = self.config.max_download_body;
if let Some(len) = resp.content_length() {
if len > download_limit {
return Err(ClientError::ResponseTooLarge {
actual: len,
limit: download_limit,
});
}
}
let mut stream = resp.bytes_stream();
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(ClientError::from_reqwest)?;
let new_len = body.len() as u64 + chunk.len() as u64;
if new_len > download_limit {
return Err(ClientError::ResponseTooLarge {
actual: new_len,
limit: download_limit,
});
}
body.extend_from_slice(&chunk);
}
let bytes = bytes::Bytes::from(body);
if let Some(expected) = expected_sha256 {
if !is_valid_sha256_hex(expected) {
return Err(ClientError::InvalidArgument(format!(
"expected_sha256 is not 64-char hex: {expected:?}"
)));
}
let actual = compute_sha256_hex(&bytes);
let expected_lower = expected.to_ascii_lowercase();
if actual != expected_lower {
return Err(ClientError::BlobIntegrityMismatch {
expected: expected_lower,
actual,
});
}
}
Ok(bytes)
}
}
fn is_valid_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
fn compute_sha256_hex(data: &[u8]) -> String {
let hash = Sha256::digest(data);
hash.iter().fold(String::with_capacity(64), |mut s, b| {
s.push(hex_nibble_lower(*b >> 4));
s.push(hex_nibble_lower(*b & 0x0f));
s
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_upload_url() {
let result = expand_url_template(
"https://example.com/upload/{accountId}/",
&[("accountId", "account1")],
)
.expect("must succeed");
assert_eq!(result, "https://example.com/upload/account1/");
}
#[test]
fn expand_download_url_with_spaces() {
let result = expand_url_template(
"/download/{accountId}/{blobId}/{name}",
&[
("accountId", "acc1"),
("blobId", "blob-123"),
("name", "my file.png"),
],
)
.expect("must succeed");
assert_eq!(result, "/download/acc1/blob-123/my%20file.png");
}
#[test]
fn expand_with_slash_in_type() {
let result = expand_url_template(
"/dl/{accountId}/{blobId}/{name}?accept={type}",
&[
("accountId", "a"),
("blobId", "b"),
("name", "x.jpg"),
("type", "image/png"),
],
)
.expect("must succeed");
assert_eq!(result, "/dl/a/b/x.jpg?accept=image%2Fpng");
}
#[test]
fn expand_unknown_variable_returns_error() {
let err = expand_url_template(
"https://example.com/upload/{accountId}/{unknownVar}/",
&[("accountId", "acc1")],
)
.expect_err("must fail when template variable is not supplied");
assert!(
matches!(err, crate::error::ClientError::InvalidSession(_)),
"expected InvalidSession, got {err:?}"
);
}
#[test]
fn expand_unmatched_open_brace_returns_error() {
let err = expand_url_template("https://example.com/{unclosed", &[])
.expect_err("unmatched '{' must return an error");
assert!(
matches!(err, crate::error::ClientError::InvalidSession(_)),
"expected InvalidSession for unmatched brace, got {err:?}"
);
}
#[test]
fn expand_unused_var_is_ignored() {
let result = expand_url_template(
"https://example.com/upload/{accountId}/",
&[("accountId", "acc1"), ("extraVar", "value")],
)
.expect("extra vars must be silently ignored");
assert_eq!(result, "https://example.com/upload/acc1/");
}
#[test]
fn percent_encode_unreserved_input_borrows() {
let input = "abc.DEF_123-xyz~tilde";
let result = percent_encode(input);
assert!(matches!(result, Cow::Borrowed(_)));
assert_eq!(result.as_ref(), input);
}
#[test]
fn percent_encode_with_space_is_owned_and_uppercase() {
let input = "hello world";
let result = percent_encode(input);
assert!(matches!(result, Cow::Owned(_)));
assert_eq!(result.as_ref(), "hello%20world");
}
#[test]
fn percent_encode_mixed_input_preserves_prefix() {
let input = "image/png";
let result = percent_encode(input);
assert!(matches!(result, Cow::Owned(_)));
assert_eq!(result.as_ref(), "image%2Fpng");
}
#[test]
fn percent_encode_empty_input_borrows() {
let result = percent_encode("");
assert!(matches!(result, Cow::Borrowed(_)));
assert_eq!(result.as_ref(), "");
}
#[test]
fn blob_upload_response_deserializes() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/blob/upload_response.json");
let text =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read fixture: {e}"));
let resp: BlobUploadResponse =
serde_json::from_str(&text).expect("fixture must deserialize as BlobUploadResponse");
assert_eq!(resp.account_id, "account1");
assert_eq!(resp.blob_id, "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a");
assert_eq!(resp.content_type, "image/png");
assert_eq!(resp.size, 48291);
assert_eq!(
resp.sha256.as_ref().map(jmap_cid_types::Sha256::as_str),
Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
);
}
#[test]
fn blob_upload_response_preserves_vendor_extras() {
let raw = serde_json::json!({
"accountId": "account1",
"blobId": "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a",
"type": "image/png",
"size": 48291,
"acmeCorpScanResult": "clean"
});
let obj: BlobUploadResponse =
serde_json::from_value(raw).expect("BlobUploadResponse must deserialize");
assert_eq!(
obj.extra.get("acmeCorpScanResult").and_then(|v| v.as_str()),
Some("clean")
);
}
#[test]
fn blob_upload_response_deserializes_sha256_typed() {
let raw = serde_json::json!({
"accountId": "account1",
"blobId": "blob1",
"type": "text/plain",
"size": 3,
"sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
});
let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
let s = obj.sha256.expect("sha256 must be Some");
assert_eq!(
s.as_str(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn blob_upload_response_serializes_without_sha256_when_none() {
let resp = BlobUploadResponse {
account_id: Id::from("a1"),
blob_id: Id::from("b1"),
content_type: "text/plain".to_owned(),
size: 0,
sha256: None,
extra: serde_json::Map::new(),
};
let v = serde_json::to_value(&resp).expect("must serialize");
let obj = v.as_object().expect("object");
assert!(
!obj.contains_key("sha256"),
"None must elide the sha256 key: {v:?}"
);
}
#[test]
fn blob_upload_response_no_sha256_round_trip() {
let raw = serde_json::json!({
"accountId": "account1",
"blobId": "blob1",
"type": "text/plain",
"size": 3
});
let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
assert!(obj.sha256.is_none());
let re = serde_json::to_value(&obj).expect("must serialize");
let map = re.as_object().expect("object");
assert!(
!map.contains_key("sha256"),
"round-trip must not introduce sha256 key: {re:?}"
);
}
#[test]
fn blob_upload_response_rejects_uppercase_sha256() {
let raw = serde_json::json!({
"accountId": "account1",
"blobId": "blob1",
"type": "text/plain",
"size": 3,
"sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"
});
let err = serde_json::from_value::<BlobUploadResponse>(raw)
.expect_err("uppercase hex must fail to deserialize");
let msg = err.to_string();
assert!(
msg.contains("non-lowercase-hex") || msg.contains("at") || msg.contains("hex"),
"error must explain the hex constraint: {msg}"
);
}
}