aster_forge_cloud_files_macos_bridge/
enumeration.rs

1//! Owned File Provider enumeration requests and backend page validation.
2
3use std::collections::HashSet;
4
5use aster_forge_cloud_files_core::{CloudItemKey, CloudItemPage, PageCursor};
6
7use crate::{
8    MacosBridgeError, MacosFileProviderIdentifier, MacosFileProviderItem,
9    MacosFileProviderSystemContainer, Result,
10};
11
12/// Maximum number of items accepted from one backend page.
13pub const MAX_ENUMERATION_PAGE_ITEMS: usize = 4_096;
14/// Maximum number of items retained for cross-page duplicate detection by one enumerator.
15pub const MAX_ENUMERATION_ITEMS: usize = 100_000;
16/// Maximum cumulative UTF-8 bytes retained for identifiers, filenames, and cursors.
17pub const MAX_ENUMERATION_STATE_BYTES: usize = 16 * 1024 * 1024;
18
19/// One File Provider enumeration request with a backend page cursor kept opaque.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MacosEnumerationRequest {
22    container: MacosFileProviderIdentifier,
23    page: Option<PageCursor>,
24}
25
26impl MacosEnumerationRequest {
27    /// Creates a request for a product directory or the root system container.
28    #[must_use]
29    pub const fn new(container: MacosFileProviderIdentifier, page: Option<PageCursor>) -> Self {
30        Self { container, page }
31    }
32
33    /// Returns the native-facing container identifier.
34    #[must_use]
35    pub const fn container(&self) -> &MacosFileProviderIdentifier {
36        &self.container
37    }
38
39    /// Returns the opaque backend page cursor.
40    #[must_use]
41    pub const fn page(&self) -> Option<&PageCursor> {
42        self.page.as_ref()
43    }
44
45    /// Resolves the backend parent key, using `root_key` only for Apple's root container.
46    /// # Errors
47    ///
48    /// Returns an error when validation fails or an underlying backend, store, or platform
49    /// operation fails.
50    pub fn backend_parent<'a>(&'a self, root_key: &'a CloudItemKey) -> Result<&'a CloudItemKey> {
51        match &self.container {
52            MacosFileProviderIdentifier::Item { key, .. } => Ok(key),
53            MacosFileProviderIdentifier::System(MacosFileProviderSystemContainer::Root) => {
54                Ok(root_key)
55            }
56            MacosFileProviderIdentifier::System(
57                MacosFileProviderSystemContainer::WorkingSet
58                | MacosFileProviderSystemContainer::Trash,
59            ) => Err(MacosBridgeError::UnsupportedSystemContainer),
60        }
61    }
62}
63
64/// Owned, validated page returned to the Swift enumerator.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct MacosEnumerationPage {
67    items: Vec<MacosFileProviderItem>,
68    next_page: Option<PageCursor>,
69}
70
71/// Enumerator-scoped state that rejects duplicate items and repeated continuation cursors across
72/// multiple backend pages.
73#[derive(Debug, Clone)]
74pub struct MacosEnumerationState {
75    container: MacosFileProviderIdentifier,
76    next_page: Option<PageCursor>,
77    seen_identifiers: HashSet<String>,
78    seen_filenames: HashSet<String>,
79    seen_cursors: HashSet<PageCursor>,
80    retained_bytes: usize,
81    finished: bool,
82}
83
84impl MacosEnumerationState {
85    /// Creates a fresh enumerator for one immutable container identity.
86    #[must_use]
87    pub fn new(container: MacosFileProviderIdentifier) -> Self {
88        Self {
89            container,
90            next_page: None,
91            seen_identifiers: HashSet::new(),
92            seen_filenames: HashSet::new(),
93            seen_cursors: HashSet::new(),
94            retained_bytes: 0,
95            finished: false,
96        }
97    }
98
99    /// Returns the next backend request. Calling after the terminal page is a contract error.
100    /// # Errors
101    ///
102    /// Returns an error when validation fails or an underlying backend, store, or platform
103    /// operation fails.
104    pub fn request(&self) -> Result<MacosEnumerationRequest> {
105        if self.finished {
106            return Err(MacosBridgeError::InvalidBackendResponse {
107                reason: "enumeration requested another page after terminal completion",
108            });
109        }
110        Ok(MacosEnumerationRequest::new(
111            self.container.clone(),
112            self.next_page.clone(),
113        ))
114    }
115
116    /// Accepts one page and advances the handle-scoped continuation state.
117    /// # Errors
118    ///
119    /// Returns an error when validation fails or an underlying backend, store, or platform
120    /// operation fails.
121    pub fn accept_page(&mut self, page: MacosEnumerationPage) -> Result<MacosEnumerationPage> {
122        if self.finished {
123            return Err(MacosBridgeError::InvalidBackendResponse {
124                reason: "enumeration returned a page after terminal completion",
125            });
126        }
127        let new_item_count = self
128            .seen_identifiers
129            .len()
130            .checked_add(page.items().len())
131            .ok_or(MacosBridgeError::InvalidBackendResponse {
132                reason: "enumeration item count overflowed",
133            })?;
134        if new_item_count > MAX_ENUMERATION_ITEMS {
135            return Err(MacosBridgeError::InvalidBackendResponse {
136                reason: "enumeration exceeded the retained item limit",
137            });
138        }
139        let mut added_bytes = 0usize;
140        for item in page.items() {
141            if self.seen_identifiers.contains(item.identifier().as_str()) {
142                return Err(MacosBridgeError::InvalidBackendResponse {
143                    reason: "enumeration repeated a persistent identifier across pages",
144                });
145            }
146            if self.seen_filenames.contains(item.filename()) {
147                return Err(MacosBridgeError::InvalidBackendResponse {
148                    reason: "enumeration repeated a filename across pages",
149                });
150            }
151            added_bytes = added_bytes
152                .checked_add(item.identifier().as_str().len())
153                .and_then(|value| value.checked_add(item.filename().len()))
154                .ok_or(MacosBridgeError::InvalidBackendResponse {
155                    reason: "enumeration retained byte count overflowed",
156                })?;
157        }
158        let next_page = match page.next_page() {
159            Some(cursor) => {
160                if self.seen_cursors.contains(cursor) {
161                    return Err(MacosBridgeError::InvalidBackendResponse {
162                        reason: "enumeration repeated a continuation cursor",
163                    });
164                }
165                added_bytes = added_bytes.checked_add(cursor.as_bytes().len()).ok_or(
166                    MacosBridgeError::InvalidBackendResponse {
167                        reason: "enumeration retained byte count overflowed",
168                    },
169                )?;
170                Some(cursor.clone())
171            }
172            None => None,
173        };
174        let retained_bytes = self.retained_bytes.checked_add(added_bytes).ok_or(
175            MacosBridgeError::InvalidBackendResponse {
176                reason: "enumeration retained byte count overflowed",
177            },
178        )?;
179        if retained_bytes > MAX_ENUMERATION_STATE_BYTES {
180            return Err(MacosBridgeError::InvalidBackendResponse {
181                reason: "enumeration exceeded the retained byte limit",
182            });
183        }
184        self.seen_identifiers.extend(
185            page.items()
186                .iter()
187                .map(|item| item.identifier().as_str().to_owned()),
188        );
189        self.seen_filenames
190            .extend(page.items().iter().map(|item| item.filename().to_owned()));
191        if let Some(cursor) = &next_page {
192            self.seen_cursors.insert(cursor.clone());
193        } else {
194            self.finished = true;
195        }
196        self.next_page = next_page;
197        self.retained_bytes = retained_bytes;
198        Ok(page)
199    }
200
201    /// Returns whether a terminal page has been accepted.
202    #[must_use]
203    pub const fn is_finished(&self) -> bool {
204        self.finished
205    }
206}
207
208impl MacosEnumerationPage {
209    /// Validates a backend page against the exact domain root and requested parent before exposing
210    /// it to File Provider.
211    /// # Errors
212    ///
213    /// Returns an error when validation fails or an underlying backend, store, or platform
214    /// operation fails.
215    pub fn from_backend(
216        root_key: &CloudItemKey,
217        parent: &CloudItemKey,
218        page: CloudItemPage,
219    ) -> Result<Self> {
220        let (items, next_page) = page.into_parts();
221        if items.len() > MAX_ENUMERATION_PAGE_ITEMS {
222            return Err(MacosBridgeError::InvalidBackendResponse {
223                reason: "enumeration page exceeded the item limit",
224            });
225        }
226        let mut identifiers = HashSet::new();
227        let mut filenames = HashSet::new();
228        let mut converted = Vec::with_capacity(items.len());
229        for item in items {
230            if item.key().scope() != parent.scope() || item.parent_id() != Some(parent.item_id()) {
231                return Err(MacosBridgeError::InvalidBackendResponse {
232                    reason: "enumerated item escaped the requested parent scope",
233                });
234            }
235            let converted_item = MacosFileProviderItem::from_cloud_item(&item, root_key)?;
236            if !identifiers.insert(converted_item.identifier().as_str().to_owned()) {
237                return Err(MacosBridgeError::InvalidBackendResponse {
238                    reason: "enumeration returned duplicate persistent identifiers",
239                });
240            }
241            if !filenames.insert(converted_item.filename().to_owned()) {
242                return Err(MacosBridgeError::InvalidBackendResponse {
243                    reason: "enumeration returned duplicate filenames",
244                });
245            }
246            converted.push(converted_item);
247        }
248        Ok(Self {
249            items: converted,
250            next_page,
251        })
252    }
253
254    /// Returns items in backend enumeration order.
255    #[must_use]
256    pub fn items(&self) -> &[MacosFileProviderItem] {
257        &self.items
258    }
259
260    /// Returns the next opaque page cursor.
261    #[must_use]
262    pub const fn next_page(&self) -> Option<&PageCursor> {
263        self.next_page.as_ref()
264    }
265
266    /// Consumes the page into owned items and continuation cursor.
267    #[must_use]
268    pub fn into_parts(self) -> (Vec<MacosFileProviderItem>, Option<PageCursor>) {
269        (self.items, self.next_page)
270    }
271}