use futures::StreamExt as _;
use jmap_types::Id;
use reqwest::header::{HeaderValue, CONTENT_TYPE};
use serde::Deserialize;
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)]
#[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,
pub sha256: Option<String>,
}
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) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric()
|| byte == b'-'
|| byte == b'.'
|| byte == b'_'
|| byte == b'~'
{
out.push(char::from(byte));
} else {
out.push('%');
out.push(hex_nibble_upper(byte >> 4));
out.push(hex_nibble_upper(byte & 0x0f));
}
}
out
}
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::InvalidHeaderValue)?;
let url = expand_url_template(upload_url_template, &[("accountId", account_id)])?;
let local_sha256 = compute_sha256_hex(&data);
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::Http)?;
let status = resp.status();
Self::check_auth_status(status)?;
let resp = resp.error_for_status().map_err(ClientError::Http)?;
let upload_limit = self.config.max_upload_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::Http)?;
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 let Some(ref server_sha256) = upload_resp.sha256 {
if !is_valid_sha256_hex(server_sha256) {
return Err(ClientError::InvalidSession(format!(
"server sha256 field is not 64-char hex: {server_sha256:?}"
)));
}
let server_lower = server_sha256.to_ascii_lowercase();
if local_sha256 != server_lower {
return Err(ClientError::BlobIntegrityMismatch {
expected: local_sha256,
actual: server_lower,
});
}
}
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::Http)?;
let status = resp.status();
Self::check_auth_status(status)?;
let resp = resp.error_for_status().map_err(ClientError::Http)?;
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::Http)?;
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 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_deref(),
Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
);
}
}