Skip to main content

syncular_protocol/
blob.rs

1use 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}