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