1use std::sync::Arc;
33
34use async_trait::async_trait;
35use serde::Deserialize;
36
37use super::{Artifact, ArtifactError, ArtifactMetadata, ArtifactService, now_secs};
38
39const STORAGE_BASE: &str = "https://storage.googleapis.com/storage/v1";
40const UPLOAD_BASE: &str = "https://storage.googleapis.com/upload/storage/v1";
41
42enum TokenProvider {
48 None,
50 Static(String),
52 Refresher(Arc<dyn Fn() -> String + Send + Sync>),
54}
55
56impl TokenProvider {
57 fn get(&self) -> Result<String, ArtifactError> {
59 match self {
60 TokenProvider::None => Err(ArtifactError::Storage(
61 "missing auth token: call .with_token() or .with_token_refresher()".into(),
62 )),
63 TokenProvider::Static(t) => Ok(t.clone()),
64 TokenProvider::Refresher(f) => Ok(f()),
65 }
66 }
67}
68
69#[derive(Debug, Deserialize)]
75struct GcsObject {
76 name: String,
78}
79
80#[derive(Debug, Default, Deserialize)]
82struct ListObjectsResponse {
83 #[serde(default)]
84 items: Vec<GcsObject>,
85 #[serde(rename = "nextPageToken", default)]
86 next_page_token: Option<String>,
87}
88
89fn file_has_user_namespace(filename: &str) -> bool {
95 filename.starts_with("user:")
96}
97
98fn blob_prefix(app_name: &str, user_id: &str, session_id: &str, filename: &str) -> String {
100 if file_has_user_namespace(filename) {
101 format!("{app_name}/{user_id}/user/{filename}")
102 } else {
103 format!("{app_name}/{user_id}/{session_id}/{filename}")
104 }
105}
106
107fn blob_name(
109 app_name: &str,
110 user_id: &str,
111 session_id: &str,
112 filename: &str,
113 version: u64,
114) -> String {
115 format!(
116 "{}/{}",
117 blob_prefix(app_name, user_id, session_id, filename),
118 version
119 )
120}
121
122fn version_from_blob_name(name: &str) -> Option<u64> {
124 name.rsplit('/')
125 .next()
126 .and_then(|seg| seg.parse::<u64>().ok())
127}
128
129pub struct GcsArtifactService {
149 bucket: String,
150 app_name: String,
151 user_id: String,
152 client: reqwest::Client,
153 token_provider: TokenProvider,
154}
155
156impl GcsArtifactService {
157 pub fn new(bucket: impl Into<String>, app_name: impl Into<String>) -> Self {
164 Self {
165 bucket: bucket.into(),
166 app_name: app_name.into(),
167 user_id: "user".to_string(),
168 client: reqwest::Client::new(),
169 token_provider: TokenProvider::None,
170 }
171 }
172
173 pub fn with_token(mut self, token: impl Into<String>) -> Self {
175 self.token_provider = TokenProvider::Static(token.into());
176 self
177 }
178
179 pub fn with_token_refresher(mut self, f: impl Fn() -> String + Send + Sync + 'static) -> Self {
184 self.token_provider = TokenProvider::Refresher(Arc::new(f));
185 self
186 }
187
188 pub fn user_id(mut self, user_id: impl Into<String>) -> Self {
194 self.user_id = user_id.into();
195 self
196 }
197
198 pub fn bucket(&self) -> &str {
200 &self.bucket
201 }
202
203 pub fn app_name(&self) -> &str {
205 &self.app_name
206 }
207
208 fn token(&self) -> Result<String, ArtifactError> {
211 self.token_provider.get()
212 }
213
214 async fn list_wire_versions(
217 &self,
218 session_id: &str,
219 filename: &str,
220 ) -> Result<Vec<u64>, ArtifactError> {
221 let prefix = format!(
222 "{}/",
223 blob_prefix(&self.app_name, &self.user_id, session_id, filename)
224 );
225 let mut versions: Vec<u64> = self
226 .list_object_names(&prefix)
227 .await?
228 .into_iter()
229 .filter_map(|name| version_from_blob_name(&name))
230 .collect();
231 versions.sort_unstable();
232 Ok(versions)
233 }
234
235 async fn list_object_names(&self, prefix: &str) -> Result<Vec<String>, ArtifactError> {
237 let token = self.token()?;
238 let base = format!("{STORAGE_BASE}/b/{}/o", urlencode(&self.bucket));
239 let mut names = Vec::new();
240 let mut page_token: Option<String> = None;
241
242 loop {
243 let mut req = self
244 .client
245 .get(&base)
246 .header("Authorization", format!("Bearer {token}"))
247 .query(&[("prefix", prefix)]);
248 if let Some(tok) = &page_token {
249 req = req.query(&[("pageToken", tok.as_str())]);
250 }
251
252 let resp = req
253 .send()
254 .await
255 .map_err(|e| ArtifactError::Storage(format!("HTTP request failed: {e}")))?;
256 let parsed: ListObjectsResponse = parse_json(resp).await?.unwrap_or_default();
257 names.extend(parsed.items.into_iter().map(|o| o.name));
258
259 match parsed.next_page_token {
260 Some(t) if !t.is_empty() => page_token = Some(t),
261 _ => break,
262 }
263 }
264 Ok(names)
265 }
266
267 async fn upload_blob(
269 &self,
270 blob: &str,
271 data: &[u8],
272 content_type: &str,
273 ) -> Result<(), ArtifactError> {
274 let token = self.token()?;
275 let url = format!("{UPLOAD_BASE}/b/{}/o", urlencode(&self.bucket));
276 let resp = self
277 .client
278 .post(&url)
279 .header("Authorization", format!("Bearer {token}"))
280 .header("Content-Type", content_type)
281 .query(&[("uploadType", "media"), ("name", blob)])
282 .body(data.to_vec())
283 .send()
284 .await
285 .map_err(|e| ArtifactError::Storage(format!("HTTP request failed: {e}")))?;
286 check_success(resp).await
287 }
288
289 async fn download_blob(&self, blob: &str) -> Result<Option<(Vec<u8>, String)>, ArtifactError> {
291 let token = self.token()?;
292 let url = format!(
293 "{STORAGE_BASE}/b/{}/o/{}",
294 urlencode(&self.bucket),
295 urlencode(blob)
296 );
297 let resp = self
298 .client
299 .get(&url)
300 .header("Authorization", format!("Bearer {token}"))
301 .query(&[("alt", "media")])
302 .send()
303 .await
304 .map_err(|e| ArtifactError::Storage(format!("HTTP request failed: {e}")))?;
305
306 let status = resp.status().as_u16();
307 if status == 404 {
308 return Ok(None);
309 }
310 if !(200..300).contains(&status) {
311 let body = resp.text().await.unwrap_or_else(|_| "<unreadable>".into());
312 return Err(ArtifactError::Storage(format!(
313 "GCS request failed [{status}]: {body}"
314 )));
315 }
316 let content_type = resp
317 .headers()
318 .get(reqwest::header::CONTENT_TYPE)
319 .and_then(|v| v.to_str().ok())
320 .map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
321 .unwrap_or_else(|| "application/octet-stream".to_string());
322 let bytes = resp
323 .bytes()
324 .await
325 .map_err(|e| ArtifactError::Storage(format!("failed to read body: {e}")))?;
326 Ok(Some((bytes.to_vec(), content_type)))
327 }
328
329 async fn delete_blob(&self, blob: &str) -> Result<(), ArtifactError> {
331 let token = self.token()?;
332 let url = format!(
333 "{STORAGE_BASE}/b/{}/o/{}",
334 urlencode(&self.bucket),
335 urlencode(blob)
336 );
337 let resp = self
338 .client
339 .delete(&url)
340 .header("Authorization", format!("Bearer {token}"))
341 .send()
342 .await
343 .map_err(|e| ArtifactError::Storage(format!("HTTP request failed: {e}")))?;
344 let status = resp.status().as_u16();
345 if status == 404 || (200..300).contains(&status) {
346 return Ok(());
347 }
348 let body = resp.text().await.unwrap_or_else(|_| "<unreadable>".into());
349 Err(ArtifactError::Storage(format!(
350 "GCS request failed [{status}]: {body}"
351 )))
352 }
353}
354
355async fn parse_json<T: for<'de> Deserialize<'de>>(
361 resp: reqwest::Response,
362) -> Result<Option<T>, ArtifactError> {
363 let status = resp.status().as_u16();
364 if status == 404 {
365 return Ok(None);
366 }
367 if (200..300).contains(&status) {
368 let parsed: T = resp
369 .json()
370 .await
371 .map_err(|e| ArtifactError::Storage(format!("failed to parse response: {e}")))?;
372 return Ok(Some(parsed));
373 }
374 let body = resp.text().await.unwrap_or_else(|_| "<unreadable>".into());
375 Err(ArtifactError::Storage(format!(
376 "GCS request failed [{status}]: {body}"
377 )))
378}
379
380async fn check_success(resp: reqwest::Response) -> Result<(), ArtifactError> {
382 let status = resp.status().as_u16();
383 if (200..300).contains(&status) {
384 return Ok(());
385 }
386 let body = resp.text().await.unwrap_or_else(|_| "<unreadable>".into());
387 Err(ArtifactError::Storage(format!(
388 "GCS request failed [{status}]: {body}"
389 )))
390}
391
392fn urlencode(s: &str) -> String {
397 let mut out = String::with_capacity(s.len());
398 for b in s.bytes() {
399 match b {
400 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
401 out.push(b as char);
402 }
403 _ => out.push_str(&format!("%{b:02X}")),
404 }
405 }
406 out
407}
408
409#[async_trait]
414impl ArtifactService for GcsArtifactService {
415 async fn save(
416 &self,
417 session_id: &str,
418 artifact: Artifact,
419 ) -> Result<ArtifactMetadata, ArtifactError> {
420 let filename = &artifact.metadata.name;
421
422 let versions = self.list_wire_versions(session_id, filename).await?;
424 let wire_version = versions.iter().copied().max().map(|v| v + 1).unwrap_or(0);
425
426 let blob = blob_name(
427 &self.app_name,
428 &self.user_id,
429 session_id,
430 filename,
431 wire_version,
432 );
433 self.upload_blob(&blob, &artifact.data, &artifact.metadata.mime_type)
434 .await?;
435
436 let mut metadata = artifact.metadata;
437 metadata.version = (wire_version + 1) as u32;
440 metadata.updated_at = now_secs();
441 if wire_version == 0 {
442 metadata.created_at = metadata.updated_at;
443 }
444 Ok(metadata)
445 }
446
447 async fn load(&self, session_id: &str, name: &str) -> Result<Option<Artifact>, ArtifactError> {
448 let versions = self.list_wire_versions(session_id, name).await?;
449 let Some(latest) = versions.iter().copied().max() else {
450 return Ok(None);
451 };
452 self.load_version(session_id, name, (latest + 1) as u32)
453 .await
454 }
455
456 async fn load_version(
457 &self,
458 session_id: &str,
459 name: &str,
460 version: u32,
461 ) -> Result<Option<Artifact>, ArtifactError> {
462 if version == 0 {
463 return Ok(None);
464 }
465 let wire_version = (version - 1) as u64;
467 let blob = blob_name(
468 &self.app_name,
469 &self.user_id,
470 session_id,
471 name,
472 wire_version,
473 );
474
475 let Some((data, content_type)) = self.download_blob(&blob).await? else {
476 return Ok(None);
477 };
478
479 let now = now_secs();
480 let size = data.len();
481 Ok(Some(Artifact {
482 metadata: ArtifactMetadata {
483 name: name.to_string(),
484 mime_type: content_type,
485 version,
486 size,
487 created_at: now,
488 updated_at: now,
489 },
490 data,
491 }))
492 }
493
494 async fn list(&self, session_id: &str) -> Result<Vec<ArtifactMetadata>, ArtifactError> {
495 use std::collections::BTreeSet;
496
497 let session_prefix = format!("{}/{}/{}/", self.app_name, self.user_id, session_id);
499 let user_prefix = format!("{}/{}/user/", self.app_name, self.user_id);
501
502 let mut filenames: BTreeSet<String> = BTreeSet::new();
503
504 for name in self.list_object_names(&session_prefix).await? {
505 if let Some(rest) = name.strip_prefix(&session_prefix) {
506 if let Some(idx) = rest.rfind('/') {
508 filenames.insert(rest[..idx].to_string());
509 }
510 }
511 }
512 for name in self.list_object_names(&user_prefix).await? {
513 if let Some(rest) = name.strip_prefix(&user_prefix)
514 && let Some(idx) = rest.rfind('/')
515 {
516 filenames.insert(rest[..idx].to_string());
520 }
521 }
522
523 let mut result = Vec::with_capacity(filenames.len());
525 for filename in filenames {
526 if let Some(artifact) = self.load(session_id, &filename).await? {
527 result.push(artifact.metadata);
528 }
529 }
530 Ok(result)
531 }
532
533 async fn delete(&self, session_id: &str, name: &str) -> Result<(), ArtifactError> {
534 let versions = self.list_wire_versions(session_id, name).await?;
535 for wire_version in versions {
536 let blob = blob_name(
537 &self.app_name,
538 &self.user_id,
539 session_id,
540 name,
541 wire_version,
542 );
543 self.delete_blob(&blob).await?;
544 }
545 Ok(())
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn can_construct() {
555 let svc = GcsArtifactService::new("my-bucket", "my-app");
556 assert_eq!(svc.bucket(), "my-bucket");
557 assert_eq!(svc.app_name(), "my-app");
558 }
559
560 #[test]
561 fn default_user_id_is_user() {
562 let svc = GcsArtifactService::new("b", "a");
563 assert_eq!(svc.user_id, "user");
564 }
565
566 #[test]
567 fn user_id_override() {
568 let svc = GcsArtifactService::new("b", "a").user_id("alice");
569 assert_eq!(svc.user_id, "alice");
570 }
571
572 #[test]
573 fn session_scoped_blob_name() {
574 assert_eq!(
575 blob_name("app", "user", "sess1", "file.bin", 3),
576 "app/user/sess1/file.bin/3"
577 );
578 }
579
580 #[test]
581 fn user_namespaced_blob_name_ignores_session() {
582 assert_eq!(
584 blob_name("app", "alice", "sess1", "user:prefs.json", 0),
585 "app/alice/user/user:prefs.json/0"
586 );
587 }
588
589 #[test]
590 fn blob_prefix_session_scoped() {
591 assert_eq!(blob_prefix("app", "u", "s", "doc.txt"), "app/u/s/doc.txt");
592 }
593
594 #[test]
595 fn blob_prefix_user_scoped() {
596 assert_eq!(
597 blob_prefix("app", "u", "s", "user:doc.txt"),
598 "app/u/user/user:doc.txt"
599 );
600 }
601
602 #[test]
603 fn detects_user_namespace() {
604 assert!(file_has_user_namespace("user:settings"));
605 assert!(!file_has_user_namespace("settings"));
606 }
607
608 #[test]
609 fn parses_version_suffix() {
610 assert_eq!(version_from_blob_name("app/u/s/file/7"), Some(7));
611 assert_eq!(version_from_blob_name("app/u/s/file/notanum"), None);
612 assert_eq!(version_from_blob_name("0"), Some(0));
613 }
614
615 #[test]
616 fn urlencode_encodes_slashes() {
617 assert_eq!(urlencode("a/b/c"), "a%2Fb%2Fc");
618 assert_eq!(urlencode("user:f.json"), "user%3Af.json");
619 assert_eq!(urlencode("plain-name_1.txt"), "plain-name_1.txt");
620 }
621
622 #[test]
623 fn missing_token_returns_storage_error() {
624 let svc = GcsArtifactService::new("b", "a");
625 let err = svc.token().unwrap_err();
626 assert!(matches!(err, ArtifactError::Storage(_)));
627 assert!(err.to_string().contains("missing auth token"));
628 }
629
630 #[test]
631 fn with_token_sets_provider() {
632 let svc = GcsArtifactService::new("b", "a").with_token("tok123");
633 assert_eq!(svc.token().unwrap(), "tok123");
634 }
635
636 #[test]
637 fn with_token_refresher_calls_closure() {
638 let svc =
639 GcsArtifactService::new("b", "a").with_token_refresher(|| "dynamic-token".to_string());
640 assert_eq!(svc.token().unwrap(), "dynamic-token");
641 }
642
643 #[test]
644 fn implements_artifact_service_trait() {
645 fn _assert_trait(_: &dyn ArtifactService) {}
646 let svc = GcsArtifactService::new("b", "a");
647 _assert_trait(&svc);
648 }
649}