Skip to main content

cufflink_types/host/
storage.rs

1//! `s3_upload` and `s3_head`.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7pub struct UploadRequest {
8    pub bucket: String,
9    pub key: String,
10    pub content_type: String,
11    #[serde(rename = "body_b64", with = "crate::serde_util::base64_bytes")]
12    pub body: Vec<u8>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub content_disposition: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub cache_control: Option<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub acl: Option<CannedAcl>,
19}
20
21/// A canned ACL an upload asks the store to set, admitted per tenant bucket.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum CannedAcl {
25    Private,
26    PublicRead,
27}
28
29impl CannedAcl {
30    pub const ALL: [CannedAcl; 2] = [CannedAcl::Private, CannedAcl::PublicRead];
31
32    pub fn as_str(self) -> &'static str {
33        match self {
34            CannedAcl::Private => "private",
35            CannedAcl::PublicRead => "public-read",
36        }
37    }
38
39    pub fn parse(name: &str) -> Option<Self> {
40        Self::ALL.into_iter().find(|acl| acl.as_str() == name)
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "result", rename_all = "snake_case")]
46pub enum UploadResponse {
47    Ok { etag: Option<String>, size: u64 },
48    Error(StorageErrorEnvelope),
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct HeadRequest {
54    pub bucket: String,
55    pub key: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "result", rename_all = "snake_case")]
60pub enum HeadResponse {
61    Found {
62        size: u64,
63        content_type: Option<String>,
64        etag: Option<String>,
65        /// RFC 3339.
66        last_modified: Option<String>,
67    },
68    NotFound,
69    Error(StorageErrorEnvelope),
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct StorageErrorEnvelope {
75    pub kind: StorageErrorKind,
76    pub message: String,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum StorageErrorKind {
82    Denied,
83    TooLarge,
84    NotConfigured,
85    Timeout,
86    Backend,
87}
88
89impl StorageErrorKind {
90    pub fn as_str(self) -> &'static str {
91        match self {
92            StorageErrorKind::Denied => "denied",
93            StorageErrorKind::TooLarge => "too_large",
94            StorageErrorKind::NotConfigured => "not_configured",
95            StorageErrorKind::Timeout => "timeout",
96            StorageErrorKind::Backend => "backend",
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::host::tests::{refused, round_trip};
105    use serde_json::json;
106
107    const KINDS: [StorageErrorKind; 5] = [
108        StorageErrorKind::Denied,
109        StorageErrorKind::TooLarge,
110        StorageErrorKind::NotConfigured,
111        StorageErrorKind::Timeout,
112        StorageErrorKind::Backend,
113    ];
114
115    #[test]
116    fn upload_round_trips() {
117        round_trip(
118            &UploadRequest {
119                bucket: "receipts".into(),
120                key: "book-1/receipt-2.pdf".into(),
121                content_type: "application/pdf".into(),
122                body: b"%PDF".to_vec(),
123                ..Default::default()
124            },
125            json!({
126                "bucket": "receipts",
127                "key": "book-1/receipt-2.pdf",
128                "content_type": "application/pdf",
129                "body_b64": "JVBERg=="
130            }),
131        );
132        round_trip(
133            &UploadRequest {
134                bucket: "receipts".into(),
135                key: "k".into(),
136                content_type: "text/plain".into(),
137                body: Vec::new(),
138                content_disposition: Some("attachment; filename=\"q.pdf\"".into()),
139                cache_control: Some("no-store".into()),
140                acl: Some(CannedAcl::PublicRead),
141            },
142            json!({
143                "bucket": "receipts",
144                "key": "k",
145                "content_type": "text/plain",
146                "body_b64": "",
147                "content_disposition": "attachment; filename=\"q.pdf\"",
148                "cache_control": "no-store",
149                "acl": "public-read"
150            }),
151        );
152        round_trip(
153            &UploadResponse::Ok {
154                etag: Some("\"abc\"".into()),
155                size: 4,
156            },
157            json!({"result": "ok", "etag": "\"abc\"", "size": 4}),
158        );
159        round_trip(
160            &UploadResponse::Ok {
161                etag: None,
162                size: 0,
163            },
164            json!({"result": "ok", "etag": null, "size": 0}),
165        );
166    }
167
168    #[test]
169    fn a_canned_acl_is_one_of_a_closed_set() {
170        for acl in CannedAcl::ALL {
171            assert_eq!(CannedAcl::parse(acl.as_str()), Some(acl));
172            assert_eq!(
173                serde_json::to_value(acl).expect("json"),
174                json!(acl.as_str())
175            );
176        }
177        for unknown in ["public-read-write", "authenticated-read", "PUBLIC-READ", ""] {
178            assert_eq!(CannedAcl::parse(unknown), None, "{unknown}");
179        }
180        refused::<UploadRequest>(json!({
181            "bucket": "b",
182            "key": "k",
183            "content_type": "t",
184            "body_b64": "",
185            "acl": "public-read-write"
186        }));
187    }
188
189    #[test]
190    fn head_round_trips() {
191        round_trip(
192            &HeadRequest {
193                bucket: "receipts".into(),
194                key: "k".into(),
195            },
196            json!({"bucket": "receipts", "key": "k"}),
197        );
198        round_trip(
199            &HeadResponse::Found {
200                size: 9,
201                content_type: Some("application/pdf".into()),
202                etag: None,
203                last_modified: Some("2026-09-28T10:00:00Z".into()),
204            },
205            json!({
206                "result": "found",
207                "size": 9,
208                "content_type": "application/pdf",
209                "etag": null,
210                "last_modified": "2026-09-28T10:00:00Z"
211            }),
212        );
213        round_trip(&HeadResponse::NotFound, json!({"result": "not_found"}));
214    }
215
216    #[test]
217    fn errors_name_their_kind() {
218        for kind in KINDS {
219            let envelope = StorageErrorEnvelope {
220                kind,
221                message: "why".into(),
222            };
223            let wire = json!({"result": "error", "kind": kind.as_str(), "message": "why"});
224            round_trip(&UploadResponse::Error(envelope.clone()), wire.clone());
225            round_trip(&HeadResponse::Error(envelope), wire);
226        }
227    }
228
229    #[test]
230    fn unknown_tags_kinds_and_bodies_are_refused() {
231        refused::<UploadResponse>(json!({"result": "stored"}));
232        refused::<HeadResponse>(json!({"result": "gone"}));
233        refused::<HeadResponse>(json!({"result": "error", "kind": "quota", "message": ""}));
234        refused::<UploadRequest>(
235            json!({"bucket": "b", "key": "k", "content_type": "t", "body_b64": "*"}),
236        );
237        refused::<UploadRequest>(json!({"bucket": "b", "key": "k", "content_type": "t"}));
238        refused::<HeadRequest>(json!({"bucket": "b", "key": "k", "version": "1"}));
239    }
240}