1use std::collections::BTreeMap;
27use std::path::PathBuf;
28use std::sync::Arc;
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use sha2::{Digest, Sha256};
34
35use super::SessionSpec;
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct BundleVersion {
40 pub name: String,
42 pub version: String,
44 pub digest: String,
46 pub created_at: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub message: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum BundleRef {
56 Latest,
58 Version(String),
60 Label(String),
62}
63
64impl BundleRef {
65 pub fn parse(reference: &str) -> (String, BundleRef) {
68 if let Some((name, version)) = reference.split_once('@') {
69 (name.to_string(), BundleRef::Version(version.to_string()))
70 } else if let Some((name, label)) = reference.split_once(':') {
71 (name.to_string(), BundleRef::Label(label.to_string()))
72 } else {
73 (reference.to_string(), BundleRef::Latest)
74 }
75 }
76}
77
78impl std::fmt::Display for BundleRef {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 BundleRef::Latest => f.write_str("latest"),
82 BundleRef::Version(v) => write!(f, "@{v}"),
83 BundleRef::Label(l) => write!(f, ":{l}"),
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum StoreError {
91 NotFound(String),
93 Invalid(Vec<String>),
95 Backend(String),
97}
98
99impl std::fmt::Display for StoreError {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 match self {
102 StoreError::NotFound(what) => write!(f, "not found: {what}"),
103 StoreError::Invalid(errors) => write!(f, "invalid: {}", errors.join("; ")),
104 StoreError::Backend(message) => write!(f, "bundle store: {message}"),
105 }
106 }
107}
108
109impl std::error::Error for StoreError {}
110
111#[async_trait]
113pub trait BundleStore: Send + Sync {
114 async fn push(
117 &self,
118 name: &str,
119 spec: &SessionSpec,
120 message: Option<&str>,
121 ) -> Result<BundleVersion, StoreError>;
122
123 async fn get(
125 &self,
126 name: &str,
127 reference: &BundleRef,
128 ) -> Result<(BundleVersion, SessionSpec), StoreError>;
129
130 async fn versions(&self, name: &str) -> Result<Vec<BundleVersion>, StoreError>;
132
133 async fn names(&self) -> Result<Vec<String>, StoreError>;
135
136 async fn set_label(&self, name: &str, label: &str, version: &str)
139 -> Result<String, StoreError>;
140
141 async fn labels(&self, name: &str) -> Result<BTreeMap<String, String>, StoreError>;
143}
144
145#[async_trait]
148pub trait BundleObjects: Send + Sync {
149 async fn read(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError>;
151 async fn write(&self, key: &str, bytes: Vec<u8>) -> Result<(), StoreError>;
153 async fn list(&self, prefix: &str) -> Result<Vec<String>, StoreError>;
155}
156
157pub struct ObjectBundleStore<O> {
164 objects: O,
165}
166
167impl<O: BundleObjects> ObjectBundleStore<O> {
168 pub fn new(objects: O) -> Self {
170 Self { objects }
171 }
172
173 async fn read_json<T: serde::de::DeserializeOwned>(
174 &self,
175 key: &str,
176 ) -> Result<Option<T>, StoreError> {
177 match self.objects.read(key).await? {
178 Some(bytes) => serde_json::from_slice(&bytes)
179 .map(Some)
180 .map_err(|e| StoreError::Backend(format!("{key}: {e}"))),
181 None => Ok(None),
182 }
183 }
184
185 async fn resolve(&self, name: &str, reference: &BundleRef) -> Result<String, StoreError> {
186 match reference {
187 BundleRef::Latest => self
188 .versions(name)
189 .await?
190 .pop()
191 .map(|v| v.version)
192 .ok_or_else(|| StoreError::NotFound(format!("bundle '{name}'"))),
193 BundleRef::Label(label) => {
194 check_name("label", label)?;
195 let key = format!("{name}/labels/{label}");
196 match self.objects.read(&key).await? {
197 Some(bytes) => Ok(String::from_utf8_lossy(&bytes).trim().to_string()),
198 None => Err(StoreError::NotFound(format!("{name}:{label}"))),
199 }
200 }
201 BundleRef::Version(prefix) => {
202 if prefix.len() < 4 || !prefix.chars().all(|c| c.is_ascii_hexdigit()) {
203 return Err(StoreError::Invalid(vec![format!(
204 "version '{prefix}': use at least 4 hex digits"
205 )]));
206 }
207 let matches: Vec<String> = self
208 .versions(name)
209 .await?
210 .into_iter()
211 .map(|v| v.version)
212 .filter(|v| v.starts_with(prefix.as_str()))
213 .collect();
214 match matches.as_slice() {
215 [one] => Ok(one.clone()),
216 [] => Err(StoreError::NotFound(format!("{name}@{prefix}"))),
217 _ => Err(StoreError::Invalid(vec![format!(
218 "{name}@{prefix} matches {} versions; give more digits",
219 matches.len()
220 )])),
221 }
222 }
223 }
224 }
225}
226
227#[async_trait]
228impl<O: BundleObjects> BundleStore for ObjectBundleStore<O> {
229 async fn push(
230 &self,
231 name: &str,
232 spec: &SessionSpec,
233 message: Option<&str>,
234 ) -> Result<BundleVersion, StoreError> {
235 check_name("bundle", name)?;
236 let validation = spec.validate();
237 if !validation.valid {
238 return Err(StoreError::Invalid(validation.errors));
239 }
240 let value = serde_json::to_value(spec).map_err(|e| StoreError::Backend(e.to_string()))?;
241 let digest = hex(&Sha256::digest(canonical(&value).as_bytes()));
242 let version = digest[..12].to_string();
243 let meta_key = format!("{name}/versions/{version}.meta.json");
244 if let Some(existing) = self.read_json::<BundleVersion>(&meta_key).await? {
245 return Ok(existing);
246 }
247 let mut document =
248 serde_json::to_vec_pretty(&value).map_err(|e| StoreError::Backend(e.to_string()))?;
249 document.push(b'\n');
250 self.objects
251 .write(&format!("{name}/versions/{version}.json"), document)
252 .await?;
253 let meta = BundleVersion {
254 name: name.to_string(),
255 version,
256 digest,
257 created_at: now_rfc3339(),
258 message: message.map(str::to_string).filter(|m| !m.trim().is_empty()),
259 };
260 let bytes =
261 serde_json::to_vec_pretty(&meta).map_err(|e| StoreError::Backend(e.to_string()))?;
262 self.objects.write(&meta_key, bytes).await?;
263 Ok(meta)
264 }
265
266 async fn get(
267 &self,
268 name: &str,
269 reference: &BundleRef,
270 ) -> Result<(BundleVersion, SessionSpec), StoreError> {
271 check_name("bundle", name)?;
272 let version = self.resolve(name, reference).await?;
273 let missing = || StoreError::NotFound(format!("{name}@{version}"));
274 let meta: BundleVersion = self
275 .read_json(&format!("{name}/versions/{version}.meta.json"))
276 .await?
277 .ok_or_else(missing)?;
278 let value: Value = self
279 .read_json(&format!("{name}/versions/{version}.json"))
280 .await?
281 .ok_or_else(missing)?;
282 let spec = SessionSpec::from_value(value).map_err(StoreError::Backend)?;
283 Ok((meta, spec))
284 }
285
286 async fn versions(&self, name: &str) -> Result<Vec<BundleVersion>, StoreError> {
287 check_name("bundle", name)?;
288 let mut versions = Vec::new();
289 for key in self.objects.list(&format!("{name}/versions/")).await? {
290 if key.ends_with(".meta.json")
291 && let Some(meta) = self.read_json::<BundleVersion>(&key).await?
292 {
293 versions.push(meta);
294 }
295 }
296 versions.sort_by(|a, b| {
297 (a.created_at.as_str(), a.version.as_str())
298 .cmp(&(b.created_at.as_str(), b.version.as_str()))
299 });
300 Ok(versions)
301 }
302
303 async fn names(&self) -> Result<Vec<String>, StoreError> {
304 let mut names: Vec<String> = self
305 .objects
306 .list("")
307 .await?
308 .iter()
309 .filter(|key| key.ends_with(".meta.json"))
310 .filter_map(|key| {
311 key.split_once("/versions/")
312 .map(|(name, _)| name.to_string())
313 })
314 .collect();
315 names.sort();
316 names.dedup();
317 Ok(names)
318 }
319
320 async fn set_label(
321 &self,
322 name: &str,
323 label: &str,
324 version: &str,
325 ) -> Result<String, StoreError> {
326 check_name("bundle", name)?;
327 check_name("label", label)?;
328 let version = self
329 .resolve(name, &BundleRef::Version(version.to_string()))
330 .await?;
331 self.objects
332 .write(
333 &format!("{name}/labels/{label}"),
334 version.clone().into_bytes(),
335 )
336 .await?;
337 Ok(version)
338 }
339
340 async fn labels(&self, name: &str) -> Result<BTreeMap<String, String>, StoreError> {
341 check_name("bundle", name)?;
342 let prefix = format!("{name}/labels/");
343 let mut labels = BTreeMap::new();
344 for key in self.objects.list(&prefix).await? {
345 if let Some(bytes) = self.objects.read(&key).await? {
346 let label = key.trim_start_matches(prefix.as_str()).to_string();
347 labels.insert(label, String::from_utf8_lossy(&bytes).trim().to_string());
348 }
349 }
350 Ok(labels)
351 }
352}
353
354pub fn open_store(uri: &str) -> Result<Arc<dyn BundleStore>, StoreError> {
358 if let Some(rest) = uri.strip_prefix("gs://") {
359 #[cfg(feature = "gcs-store")]
360 {
361 let (bucket, prefix) = rest.split_once('/').unwrap_or((rest, ""));
362 if bucket.is_empty() {
363 return Err(StoreError::Invalid(vec![format!(
364 "'{uri}' names no bucket"
365 )]));
366 }
367 return Ok(Arc::new(ObjectBundleStore::new(GcsObjects::new(
368 bucket,
369 prefix,
370 Arc::new(gemini_genai_rs::transport::auth::GoogleAccessToken::from_env()),
371 ))));
372 }
373 #[cfg(not(feature = "gcs-store"))]
374 {
375 let _ = rest;
376 return Err(StoreError::Invalid(vec![format!(
377 "'{uri}' needs the `gcs-store` feature"
378 )]));
379 }
380 }
381 if uri.contains("://") && !uri.starts_with("file://") {
382 return Err(StoreError::Invalid(vec![format!(
383 "'{uri}': use a directory path, file://, or gs://"
384 )]));
385 }
386 let path = uri.strip_prefix("file://").unwrap_or(uri);
387 Ok(Arc::new(ObjectBundleStore::new(DirObjects::new(path))))
388}
389
390pub struct DirObjects {
394 root: PathBuf,
395}
396
397impl DirObjects {
398 pub fn new(root: impl Into<PathBuf>) -> Self {
400 Self { root: root.into() }
401 }
402
403 fn path(&self, key: &str) -> Result<PathBuf, StoreError> {
404 if key
405 .split('/')
406 .any(|part| part.is_empty() || part == "." || part == "..")
407 {
408 return Err(StoreError::Invalid(vec![format!("object key '{key}'")]));
409 }
410 Ok(self.root.join(key))
411 }
412}
413
414#[async_trait]
415impl BundleObjects for DirObjects {
416 async fn read(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
417 match tokio::fs::read(self.path(key)?).await {
418 Ok(bytes) => Ok(Some(bytes)),
419 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
420 Err(e) => Err(StoreError::Backend(format!("{key}: {e}"))),
421 }
422 }
423
424 async fn write(&self, key: &str, bytes: Vec<u8>) -> Result<(), StoreError> {
425 let path = self.path(key)?;
426 let backend = |e: std::io::Error| StoreError::Backend(format!("{key}: {e}"));
427 if let Some(parent) = path.parent() {
428 tokio::fs::create_dir_all(parent).await.map_err(backend)?;
429 }
430 let staging = path.with_extension(format!("tmp-{}", std::process::id()));
432 tokio::fs::write(&staging, bytes).await.map_err(backend)?;
433 tokio::fs::rename(&staging, &path).await.map_err(backend)
434 }
435
436 async fn list(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
437 let mut keys = Vec::new();
438 let mut pending = vec![self.root.clone()];
439 while let Some(dir) = pending.pop() {
440 let mut entries = match tokio::fs::read_dir(&dir).await {
441 Ok(entries) => entries,
442 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
443 Err(e) => return Err(StoreError::Backend(format!("{}: {e}", dir.display()))),
444 };
445 while let Some(entry) = entries
446 .next_entry()
447 .await
448 .map_err(|e| StoreError::Backend(e.to_string()))?
449 {
450 let path = entry.path();
451 if path.is_dir() {
452 pending.push(path);
453 } else if let Ok(relative) = path.strip_prefix(&self.root) {
454 let key = relative
455 .components()
456 .map(|c| c.as_os_str().to_string_lossy())
457 .collect::<Vec<_>>()
458 .join("/");
459 if key.starts_with(prefix) && !key.contains(".tmp-") {
460 keys.push(key);
461 }
462 }
463 }
464 }
465 keys.sort();
466 Ok(keys)
467 }
468}
469
470#[cfg(feature = "gcs-store")]
477pub struct GcsObjects {
478 bucket: String,
479 prefix: String,
480 endpoint: String,
481 token: Arc<gemini_genai_rs::transport::auth::GoogleAccessToken>,
482 client: reqwest::Client,
483}
484
485#[cfg(feature = "gcs-store")]
486impl GcsObjects {
487 pub fn new(
489 bucket: impl Into<String>,
490 prefix: impl Into<String>,
491 token: Arc<gemini_genai_rs::transport::auth::GoogleAccessToken>,
492 ) -> Self {
493 let prefix = prefix.into().trim_matches('/').to_string();
494 Self {
495 bucket: bucket.into(),
496 prefix: if prefix.is_empty() {
497 prefix
498 } else {
499 format!("{prefix}/")
500 },
501 endpoint: std::env::var("STORAGE_EMULATOR_HOST")
502 .ok()
503 .filter(|h| !h.trim().is_empty())
504 .map(|h| h.trim_end_matches('/').to_string())
505 .unwrap_or_else(|| "https://storage.googleapis.com".to_string()),
506 token,
507 client: reqwest::Client::new(),
508 }
509 }
510
511 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
513 self.endpoint = endpoint.into().trim_end_matches('/').to_string();
514 self
515 }
516
517 fn url(&self, segments: &[&str], query: &[(&str, &str)]) -> Result<url::Url, StoreError> {
518 let mut url =
519 url::Url::parse(&self.endpoint).map_err(|e| StoreError::Backend(e.to_string()))?;
520 url.path_segments_mut()
521 .map_err(|()| StoreError::Backend(format!("endpoint {}", self.endpoint)))?
522 .pop_if_empty()
523 .extend(segments);
524 if !query.is_empty() {
525 url.query_pairs_mut().extend_pairs(query);
526 }
527 Ok(url)
528 }
529
530 async fn send(
531 &self,
532 request: reqwest::RequestBuilder,
533 ) -> Result<reqwest::Response, StoreError> {
534 let token = self
535 .token
536 .token()
537 .await
538 .map_err(|e| StoreError::Backend(e.to_string()))?;
539 let response = request
540 .bearer_auth(token)
541 .send()
542 .await
543 .map_err(|e| StoreError::Backend(e.to_string()))?;
544 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
545 self.token.invalidate().await;
546 }
547 Ok(response)
548 }
549
550 async fn failure(what: &str, response: reqwest::Response) -> StoreError {
551 let status = response.status();
552 let body = response.text().await.unwrap_or_default();
553 StoreError::Backend(format!("{what}: HTTP {status}: {}", body.trim()))
554 }
555}
556
557#[cfg(feature = "gcs-store")]
558#[async_trait]
559impl BundleObjects for GcsObjects {
560 async fn read(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
561 let object = format!("{}{key}", self.prefix);
562 let url = self.url(
563 &["storage", "v1", "b", &self.bucket, "o", &object],
564 &[("alt", "media")],
565 )?;
566 let response = self.send(self.client.get(url)).await?;
567 match response.status() {
568 s if s.is_success() => response
569 .bytes()
570 .await
571 .map(|b| Some(b.to_vec()))
572 .map_err(|e| StoreError::Backend(e.to_string())),
573 reqwest::StatusCode::NOT_FOUND => Ok(None),
574 _ => Err(Self::failure(&object, response).await),
575 }
576 }
577
578 async fn write(&self, key: &str, bytes: Vec<u8>) -> Result<(), StoreError> {
579 let object = format!("{}{key}", self.prefix);
580 let url = self.url(
581 &["upload", "storage", "v1", "b", &self.bucket, "o"],
582 &[("uploadType", "media"), ("name", &object)],
583 )?;
584 let request = self
585 .client
586 .post(url)
587 .header("content-type", "application/json")
588 .body(bytes);
589 let response = self.send(request).await?;
590 if response.status().is_success() {
591 Ok(())
592 } else {
593 Err(Self::failure(&object, response).await)
594 }
595 }
596
597 async fn list(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
598 #[derive(Deserialize)]
599 struct Page {
600 #[serde(default)]
601 items: Vec<Item>,
602 #[serde(default, rename = "nextPageToken")]
603 next_page_token: Option<String>,
604 }
605 #[derive(Deserialize)]
606 struct Item {
607 name: String,
608 }
609 let full = format!("{}{prefix}", self.prefix);
610 let mut keys = Vec::new();
611 let mut page_token: Option<String> = None;
612 loop {
613 let mut query = vec![
614 ("prefix", full.as_str()),
615 ("fields", "items(name),nextPageToken"),
616 ];
617 if let Some(token) = &page_token {
618 query.push(("pageToken", token.as_str()));
619 }
620 let url = self.url(&["storage", "v1", "b", &self.bucket, "o"], &query)?;
621 let response = self.send(self.client.get(url)).await?;
622 if !response.status().is_success() {
623 return Err(Self::failure(&full, response).await);
624 }
625 let page: Page = response
626 .json()
627 .await
628 .map_err(|e| StoreError::Backend(e.to_string()))?;
629 keys.extend(page.items.into_iter().filter_map(|i| {
630 i.name
631 .strip_prefix(self.prefix.as_str())
632 .map(str::to_string)
633 }));
634 match page.next_page_token {
635 Some(token) if !token.is_empty() => page_token = Some(token),
636 _ => break,
637 }
638 }
639 keys.sort();
640 Ok(keys)
641 }
642}
643
644fn check_name(what: &str, name: &str) -> Result<(), StoreError> {
649 let ok = !name.is_empty()
650 && name.len() <= 63
651 && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
652 && name
653 .chars()
654 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
655 && !name.contains("..");
656 if ok {
657 Ok(())
658 } else {
659 Err(StoreError::Invalid(vec![format!(
660 "{what} name '{name}': use lowercase letters, digits, '-', '_' and '.' (at most 63)"
661 )]))
662 }
663}
664
665fn canonical(value: &Value) -> String {
668 match value {
669 Value::Object(map) => {
670 let mut keys: Vec<&String> = map.keys().collect();
671 keys.sort();
672 let fields: Vec<String> = keys
673 .into_iter()
674 .map(|k| format!("{}:{}", Value::String(k.clone()), canonical(&map[k])))
675 .collect();
676 format!("{{{}}}", fields.join(","))
677 }
678 Value::Array(items) => {
679 format!(
680 "[{}]",
681 items.iter().map(canonical).collect::<Vec<_>>().join(",")
682 )
683 }
684 other => other.to_string(),
685 }
686}
687
688fn hex(bytes: &[u8]) -> String {
689 bytes.iter().map(|b| format!("{b:02x}")).collect()
690}
691
692fn now_rfc3339() -> String {
694 let now = std::time::SystemTime::now()
695 .duration_since(std::time::UNIX_EPOCH)
696 .unwrap_or_default();
697 let secs = now.as_secs();
698 let (days, rem) = (secs / 86_400, secs % 86_400);
699 let z = days as i64 + 719_468;
701 let era = z.div_euclid(146_097);
702 let doe = z.rem_euclid(146_097);
703 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
704 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
705 let mp = (5 * doy + 2) / 153;
706 let day = doy - (153 * mp + 2) / 5 + 1;
707 let month = if mp < 10 { mp + 3 } else { mp - 9 };
708 let year = yoe + era * 400 + i64::from(month <= 2);
709 format!(
710 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
711 rem / 3_600,
712 rem % 3_600 / 60,
713 rem % 60,
714 now.subsec_millis()
715 )
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use serde_json::json;
722
723 fn spec(instruction: &str) -> SessionSpec {
724 SessionSpec::from_value(json!({
725 "name": "booking",
726 "instruction": instruction,
727 "flow": { "steps": [{ "id": "s", "terminal": true }] }
728 }))
729 .unwrap()
730 }
731
732 fn temp_dir(tag: &str) -> PathBuf {
733 let dir = std::env::temp_dir().join(format!(
734 "bundles-{tag}-{}-{:?}",
735 std::process::id(),
736 std::time::SystemTime::now()
737 ));
738 let _ = std::fs::remove_dir_all(&dir);
739 dir
740 }
741
742 async fn exercise(store: &dyn BundleStore) {
743 let v1 = store
744 .push("booking", &spec("one"), Some("first"))
745 .await
746 .unwrap();
747 assert_eq!(v1.version.len(), 12);
748 let again = store
750 .push("booking", &spec("one"), Some("dup"))
751 .await
752 .unwrap();
753 assert_eq!(again, v1);
754 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
756 let v2 = store.push("booking", &spec("two"), None).await.unwrap();
757 assert_ne!(v2.version, v1.version);
758
759 let versions = store.versions("booking").await.unwrap();
760 assert_eq!(
761 versions
762 .iter()
763 .map(|v| v.version.as_str())
764 .collect::<Vec<_>>(),
765 [v1.version.as_str(), v2.version.as_str()]
766 );
767 assert_eq!(store.names().await.unwrap(), ["booking"]);
768
769 let (latest, loaded) = store.get("booking", &BundleRef::Latest).await.unwrap();
771 assert_eq!(latest.version, v2.version);
772 assert_eq!(loaded.instruction, "two");
773 let (by_prefix, _) = store
774 .get("booking", &BundleRef::Version(v1.version[..6].to_string()))
775 .await
776 .unwrap();
777 assert_eq!(by_prefix.version, v1.version);
778 assert_eq!(
779 store
780 .set_label("booking", "prod", &v1.version[..8])
781 .await
782 .unwrap(),
783 v1.version
784 );
785 let (prod, loaded) = store
786 .get("booking", &BundleRef::Label("prod".into()))
787 .await
788 .unwrap();
789 assert_eq!(prod.version, v1.version);
790 assert_eq!(loaded.instruction, "one");
791 store
793 .set_label("booking", "prod", &v2.version)
794 .await
795 .unwrap();
796 assert_eq!(store.labels("booking").await.unwrap()["prod"], v2.version);
797
798 assert!(matches!(
800 store
801 .get("booking", &BundleRef::Label("staging".into()))
802 .await,
803 Err(StoreError::NotFound(_))
804 ));
805 assert!(matches!(
806 store.set_label("booking", "prod", "ffffffff").await,
807 Err(StoreError::NotFound(_))
808 ));
809 assert!(matches!(
810 store.push("../etc", &spec("x"), None).await,
811 Err(StoreError::Invalid(_))
812 ));
813 let invalid = SessionSpec::from_value(json!({
814 "name": "bad",
815 "flow": { "steps": [{ "id": "s", "allow": ["nope"] }] }
816 }))
817 .unwrap();
818 assert!(matches!(
819 store.push("bad", &invalid, None).await,
820 Err(StoreError::Invalid(_))
821 ));
822 }
823
824 #[tokio::test]
825 async fn a_directory_store_versions_and_labels_bundles() {
826 let dir = temp_dir("dir");
827 let store = open_store(dir.to_str().unwrap()).unwrap();
828 exercise(store.as_ref()).await;
829 let _ = std::fs::remove_dir_all(dir);
830 }
831
832 #[test]
833 fn references_parse() {
834 assert_eq!(
835 BundleRef::parse("booking"),
836 ("booking".into(), BundleRef::Latest)
837 );
838 assert_eq!(
839 BundleRef::parse("booking@3f2a"),
840 ("booking".into(), BundleRef::Version("3f2a".into()))
841 );
842 assert_eq!(
843 BundleRef::parse("booking:prod"),
844 ("booking".into(), BundleRef::Label("prod".into()))
845 );
846 }
847
848 #[test]
849 fn equal_specs_hash_equally_whatever_the_key_order() {
850 let a = json!({ "b": 1, "a": { "y": [1, { "q": 2, "p": 3 }], "x": null } });
851 let b = json!({ "a": { "x": null, "y": [1, { "p": 3, "q": 2 }] }, "b": 1 });
852 assert_eq!(canonical(&a), canonical(&b));
853 assert_eq!(
854 canonical(&a),
855 r#"{"a":{"x":null,"y":[1,{"p":3,"q":2}]},"b":1}"#
856 );
857 }
858
859 #[test]
860 fn timestamps_are_rfc3339() {
861 let now = now_rfc3339();
862 assert_eq!(now.len(), 24, "{now}");
863 assert!(now.starts_with("20") && now.ends_with('Z'), "{now}");
864 }
865
866 #[cfg(feature = "gcs-store")]
869 async fn fake_gcs() -> String {
870 use std::sync::Mutex;
871 use tokio::io::{AsyncReadExt, AsyncWriteExt};
872
873 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
874 let endpoint = format!("http://{}", listener.local_addr().unwrap());
875 let objects: Arc<Mutex<BTreeMap<String, Vec<u8>>>> = Arc::default();
876 tokio::spawn(async move {
877 while let Ok((mut socket, _)) = listener.accept().await {
878 let objects = objects.clone();
879 tokio::spawn(async move {
880 let mut data = Vec::new();
881 let mut buf = [0u8; 8192];
882 let (head, body) = loop {
883 let n = socket.read(&mut buf).await.unwrap_or(0);
884 if n == 0 {
885 return;
886 }
887 data.extend_from_slice(&buf[..n]);
888 let Some(end) = data.windows(4).position(|w| w == b"\r\n\r\n") else {
889 continue;
890 };
891 let head = String::from_utf8_lossy(&data[..end]).to_string();
892 let length = head
893 .lines()
894 .find_map(|l| {
895 l.to_ascii_lowercase()
896 .strip_prefix("content-length:")
897 .map(|v| v.trim().parse::<usize>().unwrap_or(0))
898 })
899 .unwrap_or(0);
900 if data.len() >= end + 4 + length {
901 break (head, data[end + 4..end + 4 + length].to_vec());
902 }
903 };
904 assert!(
905 head.to_ascii_lowercase()
906 .contains("authorization: bearer test-token")
907 );
908 let target = head.split_whitespace().nth(1).unwrap_or("/").to_string();
909 let url = url::Url::parse(&format!("http://x{target}")).unwrap();
910 let query: BTreeMap<String, String> = url.query_pairs().into_owned().collect();
911 let segments: Vec<String> =
912 url.path_segments().unwrap().map(percent_decode).collect();
913 let (status, reply) = match segments.as_slice() {
914 [u, ..] if u == "upload" => {
915 objects.lock().unwrap().insert(query["name"].clone(), body);
916 ("200 OK", b"{}".to_vec())
917 }
918 [_, _, _, _, o, name] if o == "o" => {
919 match objects.lock().unwrap().get(name) {
920 Some(bytes) => ("200 OK", bytes.clone()),
921 None => ("404 Not Found", b"{}".to_vec()),
922 }
923 }
924 _ => {
925 let prefix = query.get("prefix").cloned().unwrap_or_default();
926 let items: Vec<Value> = objects
927 .lock()
928 .unwrap()
929 .keys()
930 .filter(|k| k.starts_with(&prefix))
931 .map(|k| json!({ "name": k }))
932 .collect();
933 ("200 OK", json!({ "items": items }).to_string().into_bytes())
934 }
935 };
936 let mut response = format!(
937 "HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
938 reply.len()
939 )
940 .into_bytes();
941 response.extend(reply);
942 let _ = socket.write_all(&response).await;
943 });
944 }
945 });
946 endpoint
947 }
948
949 #[cfg(feature = "gcs-store")]
950 fn percent_decode(s: &str) -> String {
951 let bytes = s.as_bytes();
952 let mut out = Vec::new();
953 let mut i = 0;
954 while i < bytes.len() {
955 let escaped = (bytes[i] == b'%' && i + 2 < bytes.len())
956 .then(|| u8::from_str_radix(&s[i + 1..i + 3], 16).ok())
957 .flatten();
958 match escaped {
959 Some(b) => {
960 out.push(b);
961 i += 3;
962 }
963 None => {
964 out.push(bytes[i]);
965 i += 1;
966 }
967 }
968 }
969 String::from_utf8_lossy(&out).into_owned()
970 }
971
972 #[cfg(feature = "gcs-store")]
973 #[tokio::test]
974 async fn a_cloud_storage_store_versions_and_labels_bundles() {
975 let endpoint = fake_gcs().await;
976 let token = Arc::new(gemini_genai_rs::transport::auth::GoogleAccessToken::fixed(
977 "test-token",
978 ));
979 let store = ObjectBundleStore::new(
980 GcsObjects::new("bucket", "team/bundles/", token).with_endpoint(endpoint),
981 );
982 exercise(&store).await;
983 }
984}