1use async_trait::async_trait;
7use reqwest::Client;
8
9use crate::clock::ClockRef;
10use crate::keys::UserKeypair;
11
12use super::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
13
14pub struct HttpCloudHome {
16 base_url: String,
17 keypair: UserKeypair,
18 client: Client,
19 clock: ClockRef,
20}
21
22impl HttpCloudHome {
23 pub fn new(base_url: String, keypair: UserKeypair, clock: ClockRef) -> Self {
24 Self {
25 base_url: base_url.trim_end_matches('/').to_string(),
26 keypair,
27 client: Client::new(),
28 clock,
29 }
30 }
31
32 fn sign_request(&self, method: &str, path: &str) -> [(&'static str, String); 3] {
34 let timestamp = self.clock.now().timestamp() as u64;
35
36 let message = format!("{}\n{}\n{}", method, path, timestamp);
37 let signature = self.keypair.sign(message.as_bytes());
38
39 [
40 ("X-Bae-Pubkey", hex::encode(self.keypair.public_key)),
41 ("X-Bae-Timestamp", timestamp.to_string()),
42 ("X-Bae-Signature", hex::encode(signature)),
43 ]
44 }
45
46 async fn map_error(key: &str, resp: reqwest::Response) -> CloudHomeError {
48 let status = resp.status();
49 let body = resp
50 .text()
51 .await
52 .unwrap_or_else(|e| format!("<body read failed: {e}>"));
53
54 if status == reqwest::StatusCode::NOT_FOUND {
55 CloudHomeError::NotFound(key.to_string())
56 } else if status == reqwest::StatusCode::UNAUTHORIZED
57 || status == reqwest::StatusCode::FORBIDDEN
58 {
59 CloudHomeError::Storage(format!("unauthorized: {body}"))
60 } else {
61 CloudHomeError::Storage(format!("{status}: {body}"))
62 }
63 }
64}
65
66#[async_trait]
67impl CloudHome for HttpCloudHome {
68 async fn write(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
69 let path = format!("/cloud/{key}");
70 let url = format!("{}{}", self.base_url, path);
71 let headers = self.sign_request("PUT", &path);
72
73 let resp = self
74 .client
75 .put(&url)
76 .header(headers[0].0, &headers[0].1)
77 .header(headers[1].0, &headers[1].1)
78 .header(headers[2].0, &headers[2].1)
79 .body(data)
80 .send()
81 .await
82 .map_err(|e| CloudHomeError::Storage(format!("write {key}: {e}")))?;
83
84 if resp.status().is_success() {
85 Ok(())
86 } else {
87 Err(Self::map_error(key, resp).await)
88 }
89 }
90
91 async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
92 let path = format!("/cloud/{key}");
93 let url = format!("{}{}", self.base_url, path);
94 let headers = self.sign_request("GET", &path);
95
96 let resp = self
97 .client
98 .get(&url)
99 .header(headers[0].0, &headers[0].1)
100 .header(headers[1].0, &headers[1].1)
101 .header(headers[2].0, &headers[2].1)
102 .send()
103 .await
104 .map_err(|e| CloudHomeError::Storage(format!("read {key}: {e}")))?;
105
106 if resp.status().is_success() {
107 let bytes = resp
108 .bytes()
109 .await
110 .map_err(|e| CloudHomeError::Storage(format!("read body {key}: {e}")))?;
111 Ok(bytes.to_vec())
112 } else {
113 Err(Self::map_error(key, resp).await)
114 }
115 }
116
117 async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError> {
118 let path = format!("/cloud/{key}");
119 let url = format!("{}{}", self.base_url, path);
120 let headers = self.sign_request("GET", &path);
121 let range_value = format!("bytes={}-{}", start, end.saturating_sub(1));
122
123 let resp = self
124 .client
125 .get(&url)
126 .header(headers[0].0, &headers[0].1)
127 .header(headers[1].0, &headers[1].1)
128 .header(headers[2].0, &headers[2].1)
129 .header("Range", &range_value)
130 .send()
131 .await
132 .map_err(|e| CloudHomeError::Storage(format!("read_range {key}: {e}")))?;
133
134 if resp.status().is_success() {
135 let bytes = resp
136 .bytes()
137 .await
138 .map_err(|e| CloudHomeError::Storage(format!("read_range body {key}: {e}")))?;
139 Ok(bytes.to_vec())
140 } else {
141 Err(Self::map_error(key, resp).await)
142 }
143 }
144
145 async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError> {
146 let url = format!(
147 "{}/cloud?prefix={}",
148 self.base_url,
149 urlencoding::encode(prefix)
150 );
151 let headers = self.sign_request("GET", "/cloud");
152
153 let resp = self
154 .client
155 .get(&url)
156 .header(headers[0].0, &headers[0].1)
157 .header(headers[1].0, &headers[1].1)
158 .header(headers[2].0, &headers[2].1)
159 .send()
160 .await
161 .map_err(|e| CloudHomeError::Storage(format!("list {prefix}: {e}")))?;
162
163 if resp.status().is_success() {
164 let keys: Vec<String> = resp
165 .json()
166 .await
167 .map_err(|e| CloudHomeError::Storage(format!("list parse {prefix}: {e}")))?;
168 Ok(keys)
169 } else {
170 Err(Self::map_error(prefix, resp).await)
171 }
172 }
173
174 async fn delete(&self, key: &str) -> Result<(), CloudHomeError> {
175 let path = format!("/cloud/{key}");
176 let url = format!("{}{}", self.base_url, path);
177 let headers = self.sign_request("DELETE", &path);
178
179 let resp = self
180 .client
181 .delete(&url)
182 .header(headers[0].0, &headers[0].1)
183 .header(headers[1].0, &headers[1].1)
184 .header(headers[2].0, &headers[2].1)
185 .send()
186 .await
187 .map_err(|e| CloudHomeError::Storage(format!("delete {key}: {e}")))?;
188
189 if resp.status().is_success() {
190 Ok(())
191 } else {
192 Err(Self::map_error(key, resp).await)
193 }
194 }
195
196 async fn exists(&self, key: &str) -> Result<bool, CloudHomeError> {
197 let path = format!("/cloud/{key}");
198 let url = format!("{}{}", self.base_url, path);
199 let headers = self.sign_request("HEAD", &path);
200
201 let resp = self
202 .client
203 .head(&url)
204 .header(headers[0].0, &headers[0].1)
205 .header(headers[1].0, &headers[1].1)
206 .header(headers[2].0, &headers[2].1)
207 .send()
208 .await
209 .map_err(|e| CloudHomeError::Storage(format!("exists {key}: {e}")))?;
210
211 if resp.status() == reqwest::StatusCode::NOT_FOUND {
212 Ok(false)
213 } else if resp.status().is_success() {
214 Ok(true)
215 } else {
216 Err(Self::map_error(key, resp).await)
217 }
218 }
219
220 async fn grant_access(&self, _member_id: &str) -> Result<CloudHomeJoinInfo, CloudHomeError> {
221 Ok(CloudHomeJoinInfo::HttpProxy {
222 url: self.base_url.clone(),
223 })
224 }
225
226 async fn revoke_access(&self, _member_id: &str) -> Result<(), CloudHomeError> {
227 Ok(())
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::clock::FixedClock;
235 use crate::keys::{verify_signature, UserKeypair};
236 use chrono::{DateTime, Utc};
237 use std::sync::Arc;
238
239 fn test_keypair() -> UserKeypair {
240 UserKeypair::generate()
241 }
242
243 fn fixed_instant() -> DateTime<Utc> {
246 DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
247 .unwrap()
248 .with_timezone(&Utc)
249 }
250
251 fn test_cloud_home(base_url: &str, keypair: UserKeypair) -> HttpCloudHome {
252 HttpCloudHome::new(
253 base_url.to_string(),
254 keypair,
255 Arc::new(FixedClock(fixed_instant())),
256 )
257 }
258
259 #[test]
260 fn sign_request_produces_three_headers() {
261 let kp = test_keypair();
262 let cloud_home = test_cloud_home("https://example.com", kp);
263 let headers = cloud_home.sign_request("PUT", "/cloud/changes/dev1/42.enc");
264
265 assert_eq!(headers[0].0, "X-Bae-Pubkey");
266 assert_eq!(headers[1].0, "X-Bae-Timestamp");
267 assert_eq!(headers[2].0, "X-Bae-Signature");
268
269 assert_eq!(headers[0].1.len(), 64);
271
272 let ts: u64 = headers[1].1.parse().unwrap();
274 assert_eq!(ts, fixed_instant().timestamp() as u64);
275
276 assert_eq!(headers[2].1.len(), 128);
278 }
279
280 #[test]
281 fn sign_request_signature_verifies() {
282 let kp = test_keypair();
283 let cloud_home = test_cloud_home("https://example.com", kp.clone());
284 let headers = cloud_home.sign_request("GET", "/cloud/some/key");
285
286 let message = format!("GET\n/cloud/some/key\n{}", headers[1].1);
287 let sig_bytes: [u8; crate::keys::SIGN_BYTES] =
288 hex::decode(&headers[2].1).unwrap().try_into().unwrap();
289
290 assert!(verify_signature(
291 &sig_bytes,
292 message.as_bytes(),
293 &kp.public_key
294 ));
295 }
296
297 #[test]
298 fn sign_request_different_methods_produce_different_signatures() {
299 let kp = test_keypair();
300 let cloud_home = test_cloud_home("https://example.com", kp);
301
302 let h1 = cloud_home.sign_request("GET", "/cloud/key");
303 let h2 = cloud_home.sign_request("PUT", "/cloud/key");
304
305 assert_eq!(h1[1].1, h2[1].1);
309 assert_ne!(h1[2].1, h2[2].1);
310 }
311
312 #[test]
313 fn base_url_trailing_slash_stripped() {
314 let kp = test_keypair();
315 let cloud_home = test_cloud_home("https://example.com/", kp);
316 assert_eq!(cloud_home.base_url, "https://example.com");
317 }
318
319 #[test]
320 fn grant_access_returns_join_info() {
321 let kp = test_keypair();
322 let cloud_home = test_cloud_home("https://example.com", kp);
323
324 let result = tokio::runtime::Runtime::new()
325 .unwrap()
326 .block_on(cloud_home.grant_access("some-member"));
327
328 assert!(result.is_ok());
329 let join_info = result.unwrap();
330 match join_info {
331 CloudHomeJoinInfo::HttpProxy { url } => {
332 assert_eq!(url, "https://example.com");
333 }
334 _ => panic!("expected HttpProxy join info"),
335 }
336 }
337
338 #[test]
339 fn revoke_access_succeeds() {
340 let kp = test_keypair();
341 let cloud_home = test_cloud_home("https://example.com", kp);
342
343 let result = tokio::runtime::Runtime::new()
344 .unwrap()
345 .block_on(cloud_home.revoke_access("some-member"));
346
347 assert!(result.is_ok());
348 }
349}