1mod file_store;
2
3pub use file_store::FileAttachmentStore;
4
5use std::collections::{BTreeSet, HashMap};
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex};
8
9use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentMeta, AttachmentRef};
10use sha2::{Digest, Sha256};
11
12use crate::store::{AttachmentIntent, AttachmentManifest, StoreError};
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum AttachmentProducer {
16 Host,
17 TurnIngress,
18 Tool { tool_name: String },
19}
20
21#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
22#[error("attachment source policy denied {producer:?}: {reason}")]
23pub struct AttachmentSourcePolicyError {
24 pub producer: AttachmentProducer,
25 pub reason: String,
26}
27
28pub trait AttachmentSourcePolicy: Send + Sync {
29 fn authorize(
30 &self,
31 producer: &AttachmentProducer,
32 source: &crate::AttachmentSource,
33 ) -> Result<(), AttachmentSourcePolicyError>;
34}
35
36#[derive(Debug, Default)]
37pub struct OpenAttachmentSourcePolicy;
38
39impl AttachmentSourcePolicy for OpenAttachmentSourcePolicy {
40 fn authorize(
41 &self,
42 _producer: &AttachmentProducer,
43 _source: &crate::AttachmentSource,
44 ) -> Result<(), AttachmentSourcePolicyError> {
45 Ok(())
46 }
47}
48
49#[derive(Debug, thiserror::Error)]
50pub enum AttachmentStoreError {
51 #[error("attachment `{0}` was not found")]
52 NotFound(AttachmentId),
53 #[error("attachment store I/O failed at {path}: {source}")]
54 Io {
55 path: PathBuf,
56 #[source]
57 source: std::io::Error,
58 },
59 #[error("attachment manifest write failed: {0}")]
60 ManifestRecordFailed(String),
61 #[error("attachment store backend failed: {0}")]
62 Backend(String),
63}
64
65#[derive(Clone, Debug)]
66pub struct StoredAttachment {
67 pub bytes: Vec<u8>,
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct StoredBlobRef {
77 pub id: AttachmentId,
78 pub last_modified_epoch_ms: Option<u64>,
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum AttachmentStorePersistence {
83 Ephemeral,
84 Durable,
85}
86
87impl AttachmentStorePersistence {
88 pub fn durability_tier(self) -> crate::DurabilityTier {
93 match self {
94 Self::Ephemeral => crate::DurabilityTier::Inline,
95 Self::Durable => crate::DurabilityTier::Durable,
96 }
97 }
98}
99
100#[async_trait::async_trait]
114pub trait AttachmentStore: Send + Sync {
115 fn persistence(&self) -> AttachmentStorePersistence {
116 AttachmentStorePersistence::Ephemeral
117 }
118
119 async fn put(
120 &self,
121 bytes: Vec<u8>,
122 meta: AttachmentCreateMeta,
123 ) -> Result<AttachmentRef, AttachmentStoreError>;
124
125 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError>;
126
127 async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError>;
132
133 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError>;
137
138 async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
147 Ok(self.list().await?.into_iter().find(|blob| &blob.id == id))
148 }
149}
150
151#[async_trait::async_trait]
161pub trait AttachmentRootSet: Send + Sync {
162 async fn live_attachment_refs(
170 &self,
171 intent_grace_cutoff_epoch_ms: u64,
172 ) -> Result<BTreeSet<AttachmentId>, StoreError>;
173
174 async fn has_live_attachment_ref(
186 &self,
187 id: &AttachmentId,
188 intent_grace_cutoff_epoch_ms: u64,
189 ) -> Result<bool, StoreError> {
190 Ok(self
191 .live_attachment_refs(intent_grace_cutoff_epoch_ms)
192 .await?
193 .contains(id))
194 }
195}
196
197#[async_trait::async_trait]
203impl<T: crate::SessionStoreFactory + ?Sized> AttachmentRootSet for T {
204 async fn live_attachment_refs(
205 &self,
206 intent_grace_cutoff_epoch_ms: u64,
207 ) -> Result<BTreeSet<AttachmentId>, StoreError> {
208 crate::SessionStoreFactory::live_attachment_refs(self, intent_grace_cutoff_epoch_ms).await
209 }
210
211 async fn has_live_attachment_ref(
212 &self,
213 id: &AttachmentId,
214 intent_grace_cutoff_epoch_ms: u64,
215 ) -> Result<bool, StoreError> {
216 crate::SessionStoreFactory::has_live_attachment_ref(self, id, intent_grace_cutoff_epoch_ms)
217 .await
218 }
219}
220
221#[derive(Clone, Debug, Default, PartialEq, Eq)]
227pub struct AttachmentReclamationReport {
228 pub scanned_blob_count: usize,
230 pub reclaimed_count: usize,
232 pub failed_ids: Vec<AttachmentId>,
235 pub deleted_while_referenced: Vec<AttachmentId>,
243}
244
245pub async fn reclaim_unreferenced_attachments<R>(
302 root_set: &R,
303 backend: &dyn AttachmentStore,
304 grace_period_ms: u64,
305) -> Result<AttachmentReclamationReport, AttachmentStoreError>
306where
307 R: AttachmentRootSet + ?Sized,
308{
309 let now = now_epoch_ms();
310 let intent_grace_cutoff = now.saturating_sub(grace_period_ms);
311 let live = root_set
312 .live_attachment_refs(intent_grace_cutoff)
313 .await
314 .map_err(|err| {
315 AttachmentStoreError::Backend(format!(
316 "failed to enumerate live attachment refs: {err}"
317 ))
318 })?;
319 let blobs = backend.list().await?;
320 let mut report = AttachmentReclamationReport::default();
321 for blob in blobs {
322 report.scanned_blob_count += 1;
323 if live.contains(&blob.id) {
324 continue;
325 }
326 if within_grace(blob.last_modified_epoch_ms, now, grace_period_ms) {
327 continue;
329 }
330 match backend.head(&blob.id).await {
336 Ok(Some(fresh)) => {
337 if within_grace(
338 fresh.last_modified_epoch_ms,
339 now_epoch_ms(),
340 grace_period_ms,
341 ) {
342 continue;
343 }
344 }
345 Ok(None) => continue,
347 Err(_) => {
350 report.failed_ids.push(blob.id);
351 continue;
352 }
353 }
354 match root_set
362 .has_live_attachment_ref(&blob.id, intent_grace_cutoff)
363 .await
364 {
365 Ok(true) => continue,
366 Ok(false) => {}
367 Err(_) => {
370 report.failed_ids.push(blob.id);
371 continue;
372 }
373 }
374 match backend.delete(&blob.id).await {
376 Ok(()) => {
377 report.reclaimed_count += 1;
378 if let Ok(true) = root_set
391 .has_live_attachment_ref(&blob.id, intent_grace_cutoff)
392 .await
393 {
394 tracing::error!(
395 attachment_id = %blob.id,
396 "attachment GC deleted a blob that was re-referenced in the \
397 delete window; bytes are unrecoverable but a subsequent put \
398 self-heals"
399 );
400 report.deleted_while_referenced.push(blob.id);
401 }
402 }
403 Err(_) => report.failed_ids.push(blob.id),
404 }
405 }
406 Ok(report)
407}
408
409fn within_grace(last_modified_epoch_ms: Option<u64>, now: u64, grace_period_ms: u64) -> bool {
413 last_modified_epoch_ms.is_some_and(|modified| now.saturating_sub(modified) < grace_period_ms)
414}
415
416struct InMemoryBlob {
417 stored: StoredAttachment,
418 stored_at_epoch_ms: u64,
419}
420
421#[derive(Default)]
422pub struct InMemoryAttachmentStore {
423 attachments: Mutex<HashMap<AttachmentId, InMemoryBlob>>,
424}
425
426impl InMemoryAttachmentStore {
427 pub fn new() -> Self {
428 Self::default()
429 }
430}
431
432#[async_trait::async_trait]
433impl AttachmentStore for InMemoryAttachmentStore {
434 async fn put(
435 &self,
436 bytes: Vec<u8>,
437 meta: AttachmentCreateMeta,
438 ) -> Result<AttachmentRef, AttachmentStoreError> {
439 let meta = stored_meta(&bytes, meta);
440 let reference = meta.as_ref();
441 let now = now_epoch_ms();
442 let mut attachments = self.attachments.lock().expect("attachment store lock");
443 match attachments.entry(reference.id.clone()) {
444 std::collections::hash_map::Entry::Occupied(mut existing) => {
445 existing.get_mut().stored_at_epoch_ms = now;
449 }
450 std::collections::hash_map::Entry::Vacant(slot) => {
451 slot.insert(InMemoryBlob {
452 stored: StoredAttachment { bytes },
453 stored_at_epoch_ms: now,
454 });
455 }
456 }
457 Ok(reference)
458 }
459
460 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
461 self.attachments
462 .lock()
463 .expect("attachment store lock")
464 .get(id)
465 .map(|blob| blob.stored.clone())
466 .ok_or_else(|| AttachmentStoreError::NotFound(id.clone()))
467 }
468
469 async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
470 self.attachments
471 .lock()
472 .expect("attachment store lock")
473 .remove(id);
474 Ok(())
475 }
476
477 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
478 Ok(self
479 .attachments
480 .lock()
481 .expect("attachment store lock")
482 .iter()
483 .map(|(id, blob)| StoredBlobRef {
484 id: id.clone(),
485 last_modified_epoch_ms: Some(blob.stored_at_epoch_ms),
486 })
487 .collect())
488 }
489
490 async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
491 Ok(self
492 .attachments
493 .lock()
494 .expect("attachment store lock")
495 .get(id)
496 .map(|blob| StoredBlobRef {
497 id: id.clone(),
498 last_modified_epoch_ms: Some(blob.stored_at_epoch_ms),
499 }))
500 }
501}
502
503pub fn content_id(bytes: &[u8]) -> AttachmentId {
504 AttachmentId::new(format!("{:x}", Sha256::digest(bytes)))
505}
506
507pub struct SessionAttachmentStore {
526 backend: Arc<dyn AttachmentStore>,
527 manifest: Arc<dyn AttachmentManifest>,
528 session_id: String,
529 owner: Mutex<Option<(crate::AttachmentOwnerKind, String)>>,
530 clock: Arc<dyn crate::Clock>,
531}
532
533pub(crate) struct AttachmentOwnerBinding {
534 store: Arc<SessionAttachmentStore>,
535 kind: crate::AttachmentOwnerKind,
536 owner_id: String,
537 previous: Option<(crate::AttachmentOwnerKind, String)>,
538}
539
540impl Drop for AttachmentOwnerBinding {
541 fn drop(&mut self) {
542 self.store
543 .restore_owner(self.kind, &self.owner_id, self.previous.take());
544 }
545}
546
547impl SessionAttachmentStore {
548 pub fn new(
549 backend: Arc<dyn AttachmentStore>,
550 manifest: Arc<dyn AttachmentManifest>,
551 session_id: impl Into<String>,
552 ) -> Self {
553 Self::new_with_clock(backend, manifest, session_id, Arc::new(crate::SystemClock))
554 }
555
556 pub(crate) fn new_with_clock(
557 backend: Arc<dyn AttachmentStore>,
558 manifest: Arc<dyn AttachmentManifest>,
559 session_id: impl Into<String>,
560 clock: Arc<dyn crate::Clock>,
561 ) -> Self {
562 Self {
563 backend,
564 manifest,
565 session_id: session_id.into(),
566 owner: Mutex::new(None),
567 clock,
568 }
569 }
570
571 pub fn ephemeral(backend: Arc<dyn AttachmentStore>) -> Self {
575 Self::new(backend, Arc::new(NoopAttachmentManifest), String::new())
576 }
577
578 pub fn in_memory() -> Self {
580 Self::ephemeral(Arc::new(InMemoryAttachmentStore::new()))
581 }
582
583 pub fn backend(&self) -> &Arc<dyn AttachmentStore> {
584 &self.backend
585 }
586
587 pub fn manifest(&self) -> &Arc<dyn AttachmentManifest> {
588 &self.manifest
589 }
590
591 pub fn session_id(&self) -> &str {
592 &self.session_id
593 }
594
595 pub fn persistence(&self) -> AttachmentStorePersistence {
596 self.backend.persistence()
597 }
598
599 pub(crate) fn bind_turn_scoped(
601 self: &Arc<Self>,
602 turn_id: impl Into<String>,
603 ) -> AttachmentOwnerBinding {
604 self.bind_owner_scoped(crate::AttachmentOwnerKind::Turn, turn_id.into())
605 }
606
607 pub(crate) fn bind_process_scoped(
609 self: &Arc<Self>,
610 process_id: impl Into<String>,
611 ) -> AttachmentOwnerBinding {
612 self.bind_owner_scoped(crate::AttachmentOwnerKind::Process, process_id.into())
613 }
614
615 fn bind_owner_scoped(
616 self: &Arc<Self>,
617 kind: crate::AttachmentOwnerKind,
618 owner_id: String,
619 ) -> AttachmentOwnerBinding {
620 let previous = self
621 .owner
622 .lock()
623 .expect("attachment owner binding lock")
624 .replace((kind, owner_id.clone()));
625 AttachmentOwnerBinding {
626 store: Arc::clone(self),
627 kind,
628 owner_id,
629 previous,
630 }
631 }
632
633 fn restore_owner(
634 &self,
635 kind: crate::AttachmentOwnerKind,
636 owner_id: &str,
637 previous: Option<(crate::AttachmentOwnerKind, String)>,
638 ) {
639 let mut owner = self.owner.lock().expect("attachment owner binding lock");
640 if owner
641 .as_ref()
642 .is_some_and(|(current_kind, id)| *current_kind == kind && id == owner_id)
643 {
644 *owner = previous;
645 }
646 }
647
648 pub async fn put(
649 &self,
650 bytes: Vec<u8>,
651 meta: AttachmentCreateMeta,
652 ) -> Result<AttachmentRef, AttachmentStoreError> {
653 let attachment_id = content_id(&bytes);
654 let owner = self
655 .owner
656 .lock()
657 .expect("attachment owner binding lock")
658 .clone();
659 let intent = AttachmentIntent {
660 attachment_id: attachment_id.clone(),
661 session_id: self.session_id.clone(),
662 canonical_uri: attachment_uri(&attachment_id),
663 intent_at_epoch_ms: self.clock.timestamp_ms(),
664 owner_kind: owner.as_ref().map(|(kind, _)| *kind),
665 owner_id: owner.map(|(_, id)| id),
666 };
667 self.manifest.record_intent(intent).map_err(|err| {
670 AttachmentStoreError::ManifestRecordFailed(format!(
671 "failed to record attachment intent for `{attachment_id}`: {err}"
672 ))
673 })?;
674 let reference = self.backend.put(bytes, meta).await?;
675 if reference.id != attachment_id {
676 return Err(AttachmentStoreError::Backend(format!(
677 "attachment store returned id `{}` after manifest intent for `{attachment_id}`",
678 reference.id
679 )));
680 }
681 Ok(reference)
682 }
683
684 pub async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
685 let holds_ref = self
689 .manifest
690 .holds_ref(&self.session_id, id)
691 .map_err(|err| {
692 AttachmentStoreError::Backend(format!(
693 "failed to check attachment manifest for `{id}`: {err}"
694 ))
695 })?;
696 if !holds_ref {
697 return Err(AttachmentStoreError::NotFound(id.clone()));
698 }
699 self.backend.get(id).await
700 }
701
702 pub async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
703 self.manifest.forget(&self.session_id, id).map_err(|err| {
706 AttachmentStoreError::ManifestRecordFailed(format!(
707 "failed to forget attachment ref for `{id}`: {err}"
708 ))
709 })?;
710 Ok(())
711 }
712}
713
714pub struct NoopAttachmentManifest;
718
719impl AttachmentManifest for NoopAttachmentManifest {
720 fn record_intent(&self, _intent: AttachmentIntent) -> Result<(), StoreError> {
721 Ok(())
722 }
723
724 fn commit_refs(
725 &self,
726 _session_id: &str,
727 _attachment_ids: &[AttachmentId],
728 ) -> Result<(), StoreError> {
729 Ok(())
730 }
731
732 fn list_uncommitted(
733 &self,
734 _older_than_epoch_ms: u64,
735 ) -> Result<Vec<crate::AttachmentManifestEntry>, StoreError> {
736 Ok(Vec::new())
737 }
738
739 fn forget(&self, _session_id: &str, _attachment_id: &AttachmentId) -> Result<(), StoreError> {
740 Ok(())
741 }
742
743 fn holds_ref(
744 &self,
745 _session_id: &str,
746 _attachment_id: &AttachmentId,
747 ) -> Result<bool, StoreError> {
748 Ok(true)
749 }
750
751 fn list_all_refs(&self) -> Result<Vec<AttachmentId>, StoreError> {
752 Ok(Vec::new())
753 }
754}
755
756fn attachment_uri(attachment_id: &AttachmentId) -> String {
757 format!("lash-attachment://sha256/{attachment_id}")
758}
759
760fn now_epoch_ms() -> u64 {
761 <crate::SystemClock as crate::Clock>::timestamp_ms(&crate::SystemClock)
762}
763
764pub(crate) struct PersistenceManifestAdapter(pub Arc<dyn crate::RuntimePersistence>);
769
770impl AttachmentManifest for PersistenceManifestAdapter {
771 fn record_intent(&self, intent: AttachmentIntent) -> Result<(), crate::StoreError> {
772 AttachmentManifest::record_intent(&*self.0, intent)
773 }
774
775 fn commit_refs(
776 &self,
777 session_id: &str,
778 attachment_ids: &[AttachmentId],
779 ) -> Result<(), crate::StoreError> {
780 AttachmentManifest::commit_refs(&*self.0, session_id, attachment_ids)
781 }
782
783 fn list_uncommitted(
784 &self,
785 older_than_epoch_ms: u64,
786 ) -> Result<Vec<crate::AttachmentManifestEntry>, crate::StoreError> {
787 AttachmentManifest::list_uncommitted(&*self.0, older_than_epoch_ms)
788 }
789
790 fn forget(
791 &self,
792 session_id: &str,
793 attachment_id: &AttachmentId,
794 ) -> Result<(), crate::StoreError> {
795 AttachmentManifest::forget(&*self.0, session_id, attachment_id)
796 }
797
798 fn holds_ref(
799 &self,
800 session_id: &str,
801 attachment_id: &AttachmentId,
802 ) -> Result<bool, crate::StoreError> {
803 AttachmentManifest::holds_ref(&*self.0, session_id, attachment_id)
804 }
805
806 fn list_all_refs(&self) -> Result<Vec<AttachmentId>, crate::StoreError> {
807 AttachmentManifest::list_all_refs(&*self.0)
808 }
809}
810
811fn stored_meta(bytes: &[u8], meta: AttachmentCreateMeta) -> AttachmentMeta {
812 AttachmentMeta::new(
813 content_id(bytes),
814 meta.media_type,
815 bytes.len() as u64,
816 meta.type_metadata,
817 meta.label,
818 )
819}
820
821pub async fn resolve_llm_request_attachments(
822 mut request: crate::llm::types::LlmRequest,
823 store: &SessionAttachmentStore,
824) -> Result<crate::llm::types::LlmRequest, AttachmentStoreError> {
825 for attachment in &request.attachments {
826 let crate::AttachmentSource::Stored { attachment_ref } = attachment else {
827 continue;
828 };
829 if request.resolved_stored.contains_key(&attachment_ref.id) {
830 continue;
831 }
832 let stored = store.get(&attachment_ref.id).await?;
833 request
834 .resolved_stored
835 .insert(attachment_ref.id.clone(), stored.bytes);
836 }
837 Ok(request)
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843 use lash_sansio::{AttachmentTypeMetadata, MediaType};
844
845 #[derive(Default)]
846 struct RecordingManifest {
847 entries: Mutex<HashMap<(String, AttachmentId), crate::AttachmentManifestEntry>>,
848 }
849
850 impl AttachmentManifest for RecordingManifest {
851 fn record_intent(&self, intent: AttachmentIntent) -> Result<(), crate::StoreError> {
852 let key = (intent.session_id.clone(), intent.attachment_id.clone());
853 self.entries
854 .lock()
855 .expect("lock entries")
856 .entry(key)
857 .or_insert(crate::AttachmentManifestEntry {
858 attachment_id: intent.attachment_id,
859 session_id: intent.session_id,
860 canonical_uri: intent.canonical_uri,
861 intent_at_epoch_ms: intent.intent_at_epoch_ms,
862 committed_at_epoch_ms: None,
863 owner_kind: intent.owner_kind,
864 owner_id: intent.owner_id,
865 });
866 Ok(())
867 }
868
869 fn commit_refs(
870 &self,
871 session_id: &str,
872 attachment_ids: &[AttachmentId],
873 ) -> Result<(), crate::StoreError> {
874 let mut entries = self.entries.lock().expect("lock entries");
875 for attachment_id in attachment_ids {
876 if let Some(entry) =
877 entries.get_mut(&(session_id.to_string(), attachment_id.clone()))
878 {
879 entry.committed_at_epoch_ms.get_or_insert(1);
880 }
881 }
882 Ok(())
883 }
884
885 fn list_uncommitted(
886 &self,
887 older_than_epoch_ms: u64,
888 ) -> Result<Vec<crate::AttachmentManifestEntry>, crate::StoreError> {
889 Ok(self
890 .entries
891 .lock()
892 .expect("lock entries")
893 .values()
894 .filter(|entry| {
895 entry.committed_at_epoch_ms.is_none()
896 && entry.intent_at_epoch_ms <= older_than_epoch_ms
897 })
898 .cloned()
899 .collect())
900 }
901
902 fn forget(
903 &self,
904 session_id: &str,
905 attachment_id: &AttachmentId,
906 ) -> Result<(), crate::StoreError> {
907 self.entries
908 .lock()
909 .expect("lock entries")
910 .remove(&(session_id.to_string(), attachment_id.clone()));
911 Ok(())
912 }
913
914 fn holds_ref(
915 &self,
916 session_id: &str,
917 attachment_id: &AttachmentId,
918 ) -> Result<bool, crate::StoreError> {
919 Ok(self
920 .entries
921 .lock()
922 .expect("lock entries")
923 .contains_key(&(session_id.to_string(), attachment_id.clone())))
924 }
925
926 fn list_all_refs(&self) -> Result<Vec<AttachmentId>, crate::StoreError> {
927 Ok(self
928 .entries
929 .lock()
930 .expect("lock entries")
931 .values()
932 .map(|entry| entry.attachment_id.clone())
933 .collect())
934 }
935 }
936
937 struct RecordingRootSet {
940 manifests: Vec<Arc<RecordingManifest>>,
941 }
942
943 #[async_trait::async_trait]
944 impl AttachmentRootSet for RecordingRootSet {
945 async fn live_attachment_refs(
946 &self,
947 intent_grace_cutoff_epoch_ms: u64,
948 ) -> Result<BTreeSet<AttachmentId>, crate::StoreError> {
949 let mut refs = BTreeSet::new();
950 for manifest in &self.manifests {
951 for aged in manifest.list_uncommitted(intent_grace_cutoff_epoch_ms)? {
954 manifest.forget(&aged.session_id, &aged.attachment_id)?;
955 }
956 refs.extend(manifest.list_all_refs()?);
957 }
958 Ok(refs)
959 }
960 }
961
962 fn meta() -> AttachmentCreateMeta {
963 AttachmentCreateMeta::new(
964 MediaType::parse("image/png").unwrap(),
965 Some(AttachmentTypeMetadata::image(Some(1), Some(1))),
966 Some("pixel".to_string()),
967 )
968 }
969
970 #[tokio::test]
971 async fn memory_store_dedupes_by_bytes() {
972 let store = InMemoryAttachmentStore::new();
973 let a = store.put(vec![1, 2, 3], meta()).await.expect("put a");
974 let b = store.put(vec![1, 2, 3], meta()).await.expect("put b");
975 assert_eq!(a.id, b.id);
976 assert_eq!(a.byte_len, 3);
977 assert_eq!(store.get(&a.id).await.expect("get").bytes, vec![1, 2, 3]);
978 }
979
980 #[tokio::test]
981 async fn memory_store_assigns_identity_and_byte_len_from_bytes() {
982 let store = InMemoryAttachmentStore::new();
983 let reference = store.put(vec![4, 5, 6, 7], meta()).await.expect("put");
984
985 assert_eq!(reference.id, content_id(&[4, 5, 6, 7]));
986 assert_eq!(reference.byte_len, 4);
987 }
988
989 #[tokio::test]
990 async fn memory_store_lists_stored_blobs() {
991 let store = InMemoryAttachmentStore::new();
992 let a = store.put(vec![1], meta()).await.expect("put a");
993 let b = store.put(vec![2], meta()).await.expect("put b");
994 let listed: BTreeSet<AttachmentId> = store
995 .list()
996 .await
997 .expect("list")
998 .into_iter()
999 .map(|blob| blob.id)
1000 .collect();
1001 assert!(listed.contains(&a.id));
1002 assert!(listed.contains(&b.id));
1003 assert_eq!(listed.len(), 2);
1004 }
1005
1006 #[tokio::test]
1007 async fn facade_get_is_gated_by_manifest_ownership() {
1008 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1009 let manifest: Arc<dyn AttachmentManifest> = Arc::new(RecordingManifest::default());
1010 let session_a = SessionAttachmentStore::new(backend.clone(), manifest.clone(), "session-a");
1011 let session_b = SessionAttachmentStore::new(backend.clone(), manifest.clone(), "session-b");
1012
1013 let reference = session_a.put(vec![7, 7, 7], meta()).await.expect("put a");
1014 assert_eq!(
1016 session_a.get(&reference.id).await.expect("a reads").bytes,
1017 vec![7, 7, 7]
1018 );
1019 assert!(matches!(
1021 session_b.get(&reference.id).await,
1022 Err(AttachmentStoreError::NotFound(_))
1023 ));
1024 }
1025
1026 #[tokio::test]
1027 async fn facade_delete_drops_ref_but_keeps_backend_bytes() {
1028 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1029 let manifest: Arc<dyn AttachmentManifest> = Arc::new(RecordingManifest::default());
1030 let session = SessionAttachmentStore::new(backend.clone(), manifest, "session-1");
1031
1032 let reference = session.put(vec![9, 9], meta()).await.expect("put");
1033 session.delete(&reference.id).await.expect("delete ref");
1034
1035 assert!(matches!(
1037 session.get(&reference.id).await,
1038 Err(AttachmentStoreError::NotFound(_))
1039 ));
1040 assert_eq!(
1042 backend
1043 .get(&reference.id)
1044 .await
1045 .expect("bytes remain")
1046 .bytes,
1047 vec![9, 9]
1048 );
1049 }
1050
1051 #[tokio::test]
1052 async fn shared_bytes_survive_until_all_refs_released_then_gc_collects() {
1053 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1054 let manifest_a = Arc::new(RecordingManifest::default());
1055 let manifest_b = Arc::new(RecordingManifest::default());
1056 let session_a = SessionAttachmentStore::new(
1057 backend.clone(),
1058 manifest_a.clone() as Arc<dyn AttachmentManifest>,
1059 "session-a",
1060 );
1061 let session_b = SessionAttachmentStore::new(
1062 backend.clone(),
1063 manifest_b.clone() as Arc<dyn AttachmentManifest>,
1064 "session-b",
1065 );
1066
1067 let ref_a = session_a.put(vec![5, 5, 5], meta()).await.expect("put a");
1071 let ref_b = session_b.put(vec![5, 5, 5], meta()).await.expect("put b");
1072 assert_eq!(ref_a.id, ref_b.id);
1073 manifest_a
1074 .commit_refs("session-a", std::slice::from_ref(&ref_a.id))
1075 .expect("commit a");
1076 manifest_b
1077 .commit_refs("session-b", std::slice::from_ref(&ref_b.id))
1078 .expect("commit b");
1079 assert_eq!(backend.list().await.expect("list").len(), 1);
1080
1081 let root_set = RecordingRootSet {
1082 manifests: vec![manifest_a.clone(), manifest_b.clone()],
1083 };
1084
1085 session_a.delete(&ref_a.id).await.expect("a releases");
1087 let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1088 .await
1089 .expect("sweep with b holding a ref");
1090 assert_eq!(report.reclaimed_count, 0, "b still references the blob");
1091 assert_eq!(
1092 backend.get(&ref_b.id).await.expect("blob alive").bytes,
1093 vec![5, 5, 5]
1094 );
1095
1096 session_b.delete(&ref_b.id).await.expect("b releases");
1098 let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1099 .await
1100 .expect("sweep with no refs");
1101 assert_eq!(report.reclaimed_count, 1);
1102 assert!(matches!(
1103 backend.get(&ref_b.id).await,
1104 Err(AttachmentStoreError::NotFound(_))
1105 ));
1106 }
1107
1108 #[tokio::test]
1109 async fn gc_spares_fresh_in_flight_intents_as_refs() {
1110 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1111 let manifest = Arc::new(RecordingManifest::default());
1112 let session = SessionAttachmentStore::new(
1113 backend.clone(),
1114 manifest.clone() as Arc<dyn AttachmentManifest>,
1115 "session-1",
1116 );
1117
1118 let reference = session.put(vec![3, 1, 4], meta()).await.expect("put");
1121 let root_set = RecordingRootSet {
1122 manifests: vec![manifest.clone()],
1123 };
1124 const GRACE_MS: u64 = 60 * 60 * 1000;
1125 let report = reclaim_unreferenced_attachments(&root_set, &*backend, GRACE_MS)
1126 .await
1127 .expect("sweep");
1128 assert_eq!(report.reclaimed_count, 0, "a fresh intent is a live ref");
1129 assert_eq!(
1130 backend.get(&reference.id).await.expect("kept").bytes,
1131 vec![3, 1, 4]
1132 );
1133 }
1134
1135 #[tokio::test]
1138 async fn gc_collects_aged_uncommitted_intent_orphan() {
1139 let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1140 let manifest = Arc::new(RecordingManifest::default());
1141 let session = SessionAttachmentStore::new(
1142 backend.clone(),
1143 manifest.clone() as Arc<dyn AttachmentManifest>,
1144 "session-1",
1145 );
1146
1147 let orphan = session
1149 .put(vec![9, 9, 9], meta())
1150 .await
1151 .expect("put orphan");
1152 let root_set = RecordingRootSet {
1153 manifests: vec![manifest.clone()],
1154 };
1155 let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1156 .await
1157 .expect("sweep");
1158 assert_eq!(
1159 report.reclaimed_count, 1,
1160 "an aged, never-committed intent is a collectable orphan"
1161 );
1162 assert!(matches!(
1163 backend.get(&orphan.id).await,
1164 Err(AttachmentStoreError::NotFound(_))
1165 ));
1166 assert!(manifest.list_all_refs().expect("refs").is_empty());
1168 }
1169
1170 #[tokio::test]
1176 async fn gc_delete_recheck_spares_blob_refreshed_after_snapshot() {
1177 struct StaleSnapshotStore {
1178 id: AttachmentId,
1179 list_mtime: u64,
1180 head_mtime: u64,
1181 deleted: Mutex<bool>,
1182 }
1183
1184 #[async_trait::async_trait]
1185 impl AttachmentStore for StaleSnapshotStore {
1186 async fn put(
1187 &self,
1188 _bytes: Vec<u8>,
1189 _meta: AttachmentCreateMeta,
1190 ) -> Result<AttachmentRef, AttachmentStoreError> {
1191 unreachable!("test does not put through this store")
1192 }
1193 async fn get(
1194 &self,
1195 id: &AttachmentId,
1196 ) -> Result<StoredAttachment, AttachmentStoreError> {
1197 Err(AttachmentStoreError::NotFound(id.clone()))
1198 }
1199 async fn delete(&self, _id: &AttachmentId) -> Result<(), AttachmentStoreError> {
1200 *self.deleted.lock().expect("lock") = true;
1201 Ok(())
1202 }
1203 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
1204 Ok(vec![StoredBlobRef {
1206 id: self.id.clone(),
1207 last_modified_epoch_ms: Some(self.list_mtime),
1208 }])
1209 }
1210 async fn head(
1211 &self,
1212 id: &AttachmentId,
1213 ) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
1214 Ok((id == &self.id).then(|| StoredBlobRef {
1216 id: id.clone(),
1217 last_modified_epoch_ms: Some(self.head_mtime),
1218 }))
1219 }
1220 }
1221
1222 let now = now_epoch_ms();
1223 const GRACE_MS: u64 = 60 * 60 * 1000;
1224 let backend = StaleSnapshotStore {
1225 id: AttachmentId::new("recheck"),
1226 list_mtime: now.saturating_sub(GRACE_MS * 2),
1228 head_mtime: now,
1230 deleted: Mutex::new(false),
1231 };
1232 let root_set = RecordingRootSet { manifests: vec![] };
1234 let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1235 .await
1236 .expect("sweep");
1237 assert_eq!(
1238 report.reclaimed_count, 0,
1239 "the delete-time re-check must spare a blob refreshed after the snapshot"
1240 );
1241 assert!(
1242 !*backend.deleted.lock().expect("lock"),
1243 "the freshly-refreshed blob must not be deleted"
1244 );
1245 }
1246
1247 struct StaleHeadStore {
1255 id: AttachmentId,
1256 mtime: u64,
1257 deleted: Mutex<bool>,
1258 }
1259
1260 #[async_trait::async_trait]
1261 impl AttachmentStore for StaleHeadStore {
1262 async fn put(
1263 &self,
1264 _bytes: Vec<u8>,
1265 _meta: AttachmentCreateMeta,
1266 ) -> Result<AttachmentRef, AttachmentStoreError> {
1267 unreachable!("test does not put through this store")
1268 }
1269 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
1270 Err(AttachmentStoreError::NotFound(id.clone()))
1271 }
1272 async fn delete(&self, _id: &AttachmentId) -> Result<(), AttachmentStoreError> {
1273 *self.deleted.lock().expect("lock") = true;
1274 Ok(())
1275 }
1276 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
1277 Ok(vec![StoredBlobRef {
1278 id: self.id.clone(),
1279 last_modified_epoch_ms: Some(self.mtime),
1280 }])
1281 }
1282 async fn head(
1283 &self,
1284 id: &AttachmentId,
1285 ) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
1286 Ok((id == &self.id).then(|| StoredBlobRef {
1289 id: id.clone(),
1290 last_modified_epoch_ms: Some(self.mtime),
1291 }))
1292 }
1293 }
1294
1295 struct ScriptedRootSet {
1299 answers: Mutex<std::collections::VecDeque<bool>>,
1300 }
1301
1302 #[async_trait::async_trait]
1303 impl AttachmentRootSet for ScriptedRootSet {
1304 async fn live_attachment_refs(
1305 &self,
1306 _intent_grace_cutoff_epoch_ms: u64,
1307 ) -> Result<BTreeSet<AttachmentId>, crate::StoreError> {
1308 Ok(BTreeSet::new())
1309 }
1310
1311 async fn has_live_attachment_ref(
1312 &self,
1313 _id: &AttachmentId,
1314 _intent_grace_cutoff_epoch_ms: u64,
1315 ) -> Result<bool, crate::StoreError> {
1316 Ok(self
1317 .answers
1318 .lock()
1319 .expect("lock answers")
1320 .pop_front()
1321 .unwrap_or(false))
1322 }
1323 }
1324
1325 #[tokio::test]
1326 async fn gc_pre_delete_root_recheck_spares_reappeared_ref() {
1327 let now = now_epoch_ms();
1328 const GRACE_MS: u64 = 60 * 60 * 1000;
1329 let backend = StaleHeadStore {
1330 id: AttachmentId::new("reappeared"),
1331 mtime: now.saturating_sub(GRACE_MS * 2),
1334 deleted: Mutex::new(false),
1335 };
1336 let root_set = ScriptedRootSet {
1338 answers: Mutex::new([true].into_iter().collect()),
1339 };
1340 let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1341 .await
1342 .expect("sweep");
1343 assert_eq!(
1344 report.reclaimed_count, 0,
1345 "pre-delete root re-check must spare the blob"
1346 );
1347 assert!(report.deleted_while_referenced.is_empty());
1348 assert!(
1349 !*backend.deleted.lock().expect("lock"),
1350 "a blob re-referenced before delete must not be deleted"
1351 );
1352 }
1353
1354 #[tokio::test]
1355 async fn gc_post_delete_root_recheck_alarms_on_window_ref() {
1356 let now = now_epoch_ms();
1357 const GRACE_MS: u64 = 60 * 60 * 1000;
1358 let id = AttachmentId::new("window-ref");
1359 let backend = StaleHeadStore {
1360 id: id.clone(),
1361 mtime: now.saturating_sub(GRACE_MS * 2),
1362 deleted: Mutex::new(false),
1363 };
1364 let root_set = ScriptedRootSet {
1367 answers: Mutex::new([false, true].into_iter().collect()),
1368 };
1369 let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1370 .await
1371 .expect("sweep");
1372 assert_eq!(report.reclaimed_count, 1, "the blob is deleted");
1373 assert!(*backend.deleted.lock().expect("lock"), "delete happened");
1374 assert_eq!(
1375 report.deleted_while_referenced,
1376 vec![id],
1377 "a ref appearing in the delete window is recorded for the operator alarm"
1378 );
1379 }
1380
1381 #[tokio::test]
1382 async fn session_facade_records_bound_owner_on_put() {
1383 let manifest = Arc::new(RecordingManifest::default());
1384 let store = Arc::new(SessionAttachmentStore::new(
1385 Arc::new(InMemoryAttachmentStore::new()),
1386 manifest.clone(),
1387 "session-1",
1388 ));
1389 let binding = store.bind_turn_scoped("turn-1");
1390
1391 let reference = store.put(vec![8, 9, 10], meta()).await.expect("put");
1392 {
1393 let entries = manifest.entries.lock().expect("lock entries");
1394 let entry = entries
1395 .get(&("session-1".to_string(), reference.id))
1396 .expect("manifest entry");
1397 assert_eq!(entry.owner_kind, Some(crate::AttachmentOwnerKind::Turn));
1398 assert_eq!(entry.owner_id.as_deref(), Some("turn-1"));
1399 }
1400
1401 drop(binding);
1402 let host_reference = store.put(vec![11, 12], meta()).await.expect("host put");
1403 let entries = manifest.entries.lock().expect("lock entries");
1404 let host_entry = entries
1405 .get(&("session-1".to_string(), host_reference.id))
1406 .expect("host manifest entry");
1407 assert_eq!(host_entry.owner_kind, None);
1408 assert_eq!(host_entry.owner_id, None);
1409 }
1410
1411 #[tokio::test]
1412 async fn nested_owner_binding_restores_the_previous_owner() {
1413 let manifest = Arc::new(RecordingManifest::default());
1414 let store = Arc::new(SessionAttachmentStore::new(
1415 Arc::new(InMemoryAttachmentStore::new()),
1416 manifest.clone(),
1417 "session-1",
1418 ));
1419 let process_binding = store.bind_process_scoped("process-1");
1420 let turn_binding = store.bind_turn_scoped("turn-1");
1421
1422 let turn_ref = store.put(vec![1], meta()).await.expect("turn put");
1423 drop(turn_binding);
1424 let process_ref = store.put(vec![2], meta()).await.expect("process put");
1425 drop(process_binding);
1426 let host_ref = store.put(vec![3], meta()).await.expect("host put");
1427
1428 let entries = manifest.entries.lock().expect("lock entries");
1429 let turn = entries
1430 .get(&("session-1".to_string(), turn_ref.id))
1431 .expect("turn entry");
1432 assert_eq!(turn.owner_kind, Some(crate::AttachmentOwnerKind::Turn));
1433 assert_eq!(turn.owner_id.as_deref(), Some("turn-1"));
1434 let process = entries
1435 .get(&("session-1".to_string(), process_ref.id))
1436 .expect("process entry");
1437 assert_eq!(
1438 process.owner_kind,
1439 Some(crate::AttachmentOwnerKind::Process)
1440 );
1441 assert_eq!(process.owner_id.as_deref(), Some("process-1"));
1442 let host = entries
1443 .get(&("session-1".to_string(), host_ref.id))
1444 .expect("host entry");
1445 assert_eq!(host.owner_kind, None);
1446 assert_eq!(host.owner_id, None);
1447 }
1448
1449 #[tokio::test]
1450 async fn ephemeral_facade_passes_reads_through_without_a_guard() {
1451 let store = SessionAttachmentStore::in_memory();
1452 let reference = store.put(vec![1, 2, 3], meta()).await.expect("put");
1453 assert_eq!(
1454 store.get(&reference.id).await.expect("get").bytes,
1455 vec![1, 2, 3]
1456 );
1457 }
1458
1459 #[test]
1460 fn persistence_manifest_adapter_forwards_holds_ref() {
1461 let runtime: Arc<dyn crate::RuntimePersistence> =
1462 Arc::new(crate::InMemorySessionStore::new());
1463 let adapter = PersistenceManifestAdapter(runtime);
1464 let attachment_id = AttachmentId::new("adapter-forwarding");
1465 let intent = AttachmentIntent {
1466 attachment_id: attachment_id.clone(),
1467 session_id: "adapter-session".to_string(),
1468 canonical_uri: attachment_uri(&attachment_id),
1469 intent_at_epoch_ms: 10,
1470 owner_kind: None,
1471 owner_id: None,
1472 };
1473 adapter.record_intent(intent).expect("record intent");
1474 assert!(
1475 adapter
1476 .holds_ref("adapter-session", &attachment_id)
1477 .expect("holds ref")
1478 );
1479 assert!(
1480 !adapter
1481 .holds_ref("other-session", &attachment_id)
1482 .expect("no ref for other session")
1483 );
1484 assert_eq!(
1485 adapter.list_all_refs().expect("list all refs"),
1486 vec![attachment_id]
1487 );
1488 }
1489}