Skip to main content

objects/transfer/
availability.rs

1// SPDX-License-Identifier: Apache-2.0
2use crate::{
3    error::Result,
4    store::ObjectStore,
5    transfer::graph::{ObjectId, ObjectInfo, ObjectType},
6};
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct ObjectAvailabilityPlan {
10    pub have_objects: Vec<ObjectId>,
11    pub want_objects: Vec<ObjectId>,
12    pub partial_fetch_allowed: bool,
13}
14
15pub fn has_object(store: &impl ObjectStore, info: &ObjectInfo) -> Result<bool> {
16    match (&info.id, info.obj_type) {
17        (ObjectId::Hash(hash), ObjectType::Blob) => Ok(store.has_blob(hash)?),
18        (ObjectId::Hash(hash), ObjectType::Tree) => Ok(store.has_tree(hash)?),
19        (ObjectId::StateId(state_id), ObjectType::State) => Ok(store.has_state(state_id)?),
20        (ObjectId::StateAttachment { state, id, kind: _ }, ObjectType::StateAttachment) => {
21            Ok(store.get_state_attachment(state, id)?.is_some())
22        }
23        // Redactions are keyed by the redacted blob's hash. Two senders
24        // can declare different redactions on the same blob (different
25        // reason / signature / timestamp), so we conservatively report
26        // "do not have" and always re-fetch — `accept_wire_redactions`
27        // deduplicates via the content-addressed `put_redaction`
28        // idempotency rule. Cheap to refetch; correct under merge.
29        (ObjectId::Hash(_), ObjectType::Redaction) => Ok(false),
30        // Purge carries separately authorized destructive evidence and must
31        // always reach the repository verification boundary.
32        (ObjectId::Hash(_), ObjectType::Purge) => Ok(false),
33        // StateVisibility is a per-state sidecar with append/merge
34        // semantics. Like Redaction, conservatively refetch and let the
35        // repository boundary validate + dedupe.
36        (ObjectId::StateId(_), ObjectType::StateVisibility) => Ok(false),
37        // Hosted registries are materialized snapshots with a liveness overlay;
38        // let the receiver validate and deduplicate the complete payload.
39        (ObjectId::Hash(_), ObjectType::KeyBinding) => Ok(false),
40        _ => Ok(false),
41    }
42}
43
44pub fn plan_object_availability(
45    store: &impl ObjectStore,
46    objects: &[ObjectInfo],
47) -> Result<ObjectAvailabilityPlan> {
48    let mut plan = ObjectAvailabilityPlan::default();
49
50    for info in objects {
51        if has_object(store, info)? {
52            plan.have_objects.push(info.id.clone());
53        } else {
54            plan.want_objects.push(info.id.clone());
55        }
56    }
57
58    Ok(plan)
59}
60
61impl ObjectAvailabilityPlan {
62    pub fn with_partial_fetch_allowed(mut self, allowed: bool) -> Self {
63        self.partial_fetch_allowed = allowed;
64        self
65    }
66
67    pub fn is_complete(&self) -> bool {
68        self.want_objects.is_empty()
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use crate::{
75        object::{Blob, ContentHash, StateId, Tree},
76        store::{ObjectStore, Result as StoreResult, SidecarStore},
77    };
78
79    use super::*;
80
81    #[derive(Default)]
82    struct DummyStore {
83        blob: Option<ContentHash>,
84        state: Option<StateId>,
85    }
86
87    impl SidecarStore for DummyStore {}
88
89    impl ObjectStore for DummyStore {
90        fn get_blob(&self, _hash: &ContentHash) -> StoreResult<Option<Blob>> {
91            Ok(None)
92        }
93
94        fn put_blob(&self, _blob: &Blob) -> StoreResult<ContentHash> {
95            unreachable!("not used in test")
96        }
97
98        fn has_blob(&self, hash: &ContentHash) -> StoreResult<bool> {
99            Ok(self.blob == Some(*hash))
100        }
101
102        fn get_tree(&self, _hash: &ContentHash) -> StoreResult<Option<Tree>> {
103            Ok(None)
104        }
105
106        fn put_tree(&self, _tree: &Tree) -> StoreResult<ContentHash> {
107            unreachable!("not used in test")
108        }
109
110        fn has_tree(&self, _hash: &ContentHash) -> StoreResult<bool> {
111            Ok(false)
112        }
113
114        fn get_state(&self, _id: &StateId) -> StoreResult<Option<crate::object::State>> {
115            Ok(None)
116        }
117
118        fn put_state(&self, _state: &crate::object::State) -> StoreResult<()> {
119            unreachable!("not used in test")
120        }
121
122        fn has_state(&self, id: &StateId) -> StoreResult<bool> {
123            Ok(self.state == Some(*id))
124        }
125
126        fn list_states(&self) -> StoreResult<Vec<StateId>> {
127            Ok(vec![])
128        }
129
130        fn get_action(
131            &self,
132            _id: &crate::object::ActionId,
133        ) -> StoreResult<Option<crate::object::Action>> {
134            Ok(None)
135        }
136
137        fn put_action(
138            &self,
139            _action: &mut crate::object::Action,
140        ) -> StoreResult<crate::object::ActionId> {
141            unreachable!("not used in test")
142        }
143
144        fn list_actions(&self) -> StoreResult<Vec<crate::object::ActionId>> {
145            Ok(vec![])
146        }
147
148        fn list_blobs(&self) -> StoreResult<Vec<ContentHash>> {
149            Ok(vec![])
150        }
151
152        fn list_trees(&self) -> StoreResult<Vec<ContentHash>> {
153            Ok(vec![])
154        }
155    }
156
157    #[test]
158    fn test_plan_tracks_available_and_wanted_objects() {
159        let blob = Blob::new(b"hello".to_vec());
160        let blob_hash = blob.hash();
161        let store = DummyStore {
162            blob: Some(blob_hash),
163            state: None,
164        };
165        let missing_hash = ContentHash::from_bytes([7; 32]);
166        let objects = vec![
167            ObjectInfo {
168                id: ObjectId::Hash(blob_hash),
169                obj_type: ObjectType::Blob,
170                size: blob.size() as u64,
171                delta_base: None,
172            },
173            ObjectInfo {
174                id: ObjectId::Hash(missing_hash),
175                obj_type: ObjectType::Tree,
176                size: 0,
177                delta_base: None,
178            },
179        ];
180
181        let plan = plan_object_availability(&store, &objects).unwrap();
182
183        assert_eq!(plan.have_objects.len(), 1);
184        assert_eq!(plan.want_objects.len(), 1);
185        assert!(!plan.is_complete());
186    }
187
188    #[test]
189    fn missing_state_objects_are_requested() {
190        let store = DummyStore::default();
191        let state = StateId::from_bytes([9; 32]);
192        let objects = vec![ObjectInfo {
193            id: ObjectId::StateId(state),
194            obj_type: ObjectType::State,
195            size: 0,
196            delta_base: None,
197        }];
198
199        let plan = plan_object_availability(&store, &objects).unwrap();
200
201        assert!(plan.have_objects.is_empty());
202        assert_eq!(plan.want_objects, vec![ObjectId::StateId(state)]);
203    }
204
205    #[test]
206    fn immutable_state_objects_are_not_requested_when_present() {
207        let state = StateId::from_bytes([9; 32]);
208        let store = DummyStore {
209            state: Some(state),
210            ..DummyStore::default()
211        };
212        let objects = vec![ObjectInfo {
213            id: ObjectId::StateId(state),
214            obj_type: ObjectType::State,
215            size: 0,
216            delta_base: None,
217        }];
218
219        let plan = plan_object_availability(&store, &objects).unwrap();
220
221        assert_eq!(plan.have_objects, vec![ObjectId::StateId(state)]);
222        assert!(plan.want_objects.is_empty());
223    }
224
225    #[test]
226    fn test_partial_fetch_flag_helpers() {
227        let plan = ObjectAvailabilityPlan::default().with_partial_fetch_allowed(true);
228
229        assert!(plan.partial_fetch_allowed);
230        assert!(plan.is_complete());
231    }
232}