syncular_protocol/
blob.rs1use crate::{ProtocolError, Result};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct BlobRef {
8 pub hash: String,
9 pub size: i64,
10 #[serde(rename = "mimeType")]
11 pub mime_type: String,
12 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
13 pub encrypted: bool,
14 #[serde(rename = "keyId", skip_serializing_if = "Option::is_none")]
15 pub key_id: Option<String>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BlobUploadInitRequest {
20 pub hash: String,
21 pub size: i64,
22 #[serde(rename = "mimeType")]
23 pub mime_type: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct BlobUploadInitResponse {
28 pub exists: bool,
29 #[serde(rename = "uploadId", skip_serializing_if = "Option::is_none")]
30 pub upload_id: Option<String>,
31 #[serde(rename = "uploadUrl", skip_serializing_if = "Option::is_none")]
32 pub upload_url: Option<String>,
33 #[serde(rename = "uploadMethod", skip_serializing_if = "Option::is_none")]
34 pub upload_method: Option<String>,
35 #[serde(rename = "uploadHeaders", default)]
36 pub upload_headers: BTreeMap<String, String>,
37 #[serde(rename = "chunkSize", skip_serializing_if = "Option::is_none")]
38 pub chunk_size: Option<i64>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BlobUploadCompleteResponse {
43 pub ok: bool,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub error: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct BlobDownloadUrlResponse {
50 pub url: String,
51 #[serde(rename = "expiresAt")]
52 pub expires_at: String,
53}
54
55pub fn blob_hash(data: &[u8]) -> String {
56 format!("sha256:{}", hex::encode(Sha256::digest(data)))
57}
58
59pub fn normalize_blob_mime_type(mime_type: &str) -> String {
60 let trimmed = mime_type.trim();
61 if trimmed.is_empty() {
62 "application/octet-stream".to_string()
63 } else {
64 trimmed.to_string()
65 }
66}
67
68pub fn validate_blob_hash(hash: &str) -> Result<()> {
69 let Some(hex) = hash.strip_prefix("sha256:") else {
70 return Err(ProtocolError::message(format!("invalid blob hash: {hash}")));
71 };
72 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
73 return Err(ProtocolError::message(format!("invalid blob hash: {hash}")));
74 }
75 Ok(())
76}
77
78pub fn validate_blob_ref(blob: &BlobRef) -> Result<()> {
79 validate_blob_hash(&blob.hash)?;
80 if blob.size < 0 {
81 return Err(ProtocolError::message(format!(
82 "blob size must be non-negative: {}",
83 blob.size
84 )));
85 }
86 if blob.mime_type.trim().is_empty() {
87 return Err(ProtocolError::message("blob mimeType must not be empty"));
88 }
89 if blob.key_id.as_deref().is_some_and(str::is_empty) {
90 return Err(ProtocolError::message("blob keyId must not be empty"));
91 }
92 Ok(())
93}
94
95pub fn validate_blob_bytes(blob: &BlobRef, data: &[u8]) -> Result<()> {
96 validate_blob_ref(blob)?;
97 let actual_size =
98 i64::try_from(data.len()).map_err(|_| ProtocolError::message("blob is too large"))?;
99 validate_blob_digest(blob, &blob_hash(data), actual_size)
100}
101
102pub fn validate_blob_digest(blob: &BlobRef, actual_hash: &str, actual_size: i64) -> Result<()> {
103 validate_blob_ref(blob)?;
104 if blob.size != actual_size {
105 return Err(ProtocolError::message(format!(
106 "blob size mismatch: expected {}, got {}",
107 blob.size, actual_size
108 )));
109 }
110 if actual_hash != blob.hash {
111 return Err(ProtocolError::message(format!(
112 "blob hash mismatch: expected {}, got {}",
113 blob.hash, actual_hash
114 )));
115 }
116 Ok(())
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn validates_blob_ref_bytes() {
125 let bytes = b"hello syncular";
126 let blob = BlobRef {
127 hash: blob_hash(bytes),
128 size: bytes.len() as i64,
129 mime_type: "text/plain".to_string(),
130 encrypted: false,
131 key_id: None,
132 };
133
134 validate_blob_bytes(&blob, bytes).expect("valid blob bytes");
135 validate_blob_ref(&blob).expect("valid blob ref");
136 let error = validate_blob_digest(&blob, "sha256:bad", blob.size).unwrap_err();
137 assert!(error.to_string().contains("blob hash mismatch"));
138 }
139
140 #[test]
141 fn rejects_invalid_blob_ref_shape() {
142 let blob = BlobRef {
143 hash: "sha256:bad".to_string(),
144 size: -1,
145 mime_type: "".to_string(),
146 encrypted: false,
147 key_id: None,
148 };
149 assert!(validate_blob_ref(&blob).is_err());
150 }
151}