aster_forge_cloud_files_core/
backend.rs1use std::num::NonZeroU64;
8
9use async_trait::async_trait;
10use bytes::Bytes;
11
12use crate::{
13 BackendResult, ChangeCursor, ChangePage, CloudFilesCapabilities, CloudFilesCoreError,
14 CloudItem, CloudItemKey, CloudItemPage, CloudScope, ContentRevision, PageCursor, Result,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct ByteRange {
20 offset: u64,
21 length: NonZeroU64,
22}
23
24impl ByteRange {
25 pub fn new(offset: u64, length: u64) -> Result<Self> {
31 let Some(length) = NonZeroU64::new(length) else {
32 return Err(CloudFilesCoreError::invalid_byte_range(
33 "range length must be greater than zero",
34 ));
35 };
36 if offset.checked_add(length.get()).is_none() {
37 return Err(CloudFilesCoreError::invalid_byte_range(
38 "range end exceeds u64",
39 ));
40 }
41 Ok(Self { offset, length })
42 }
43
44 #[must_use]
46 pub const fn offset(self) -> u64 {
47 self.offset
48 }
49
50 #[must_use]
52 pub const fn length(self) -> u64 {
53 self.length.get()
54 }
55
56 #[must_use]
58 pub const fn end_exclusive(self) -> u64 {
59 self.offset + self.length.get()
60 }
61
62 pub(crate) fn covering(self, other: Self) -> Self {
64 let (first, end) = if self.offset <= other.offset {
65 (self, self.end_exclusive().max(other.end_exclusive()))
66 } else {
67 (other, self.end_exclusive().max(other.end_exclusive()))
68 };
69 let extension = end - first.end_exclusive();
70 Self {
71 offset: first.offset,
72 length: first.length.saturating_add(extension),
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum ContentReadRange {
80 Whole,
82 Range(ByteRange),
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ContentReadRequest {
89 key: CloudItemKey,
90 revision: ContentRevision,
91 expected_size: u64,
92 range: ContentReadRange,
93}
94
95impl ContentReadRequest {
96 #[must_use]
98 pub const fn whole(key: CloudItemKey, revision: ContentRevision, expected_size: u64) -> Self {
99 Self {
100 key,
101 revision,
102 expected_size,
103 range: ContentReadRange::Whole,
104 }
105 }
106
107 #[must_use]
109 pub const fn range(
110 key: CloudItemKey,
111 revision: ContentRevision,
112 expected_size: u64,
113 range: ByteRange,
114 ) -> Self {
115 Self {
116 key,
117 revision,
118 expected_size,
119 range: ContentReadRange::Range(range),
120 }
121 }
122
123 #[must_use]
125 pub const fn key(&self) -> &CloudItemKey {
126 &self.key
127 }
128
129 #[must_use]
131 pub const fn revision(&self) -> &ContentRevision {
132 &self.revision
133 }
134
135 #[must_use]
137 pub const fn expected_size(&self) -> u64 {
138 self.expected_size
139 }
140
141 #[must_use]
143 pub const fn read_range(&self) -> ContentReadRange {
144 self.range
145 }
146
147 pub fn validate_response(&self, response: &ContentReadResponse) -> Result<()> {
153 if response.revision() != &self.revision {
154 return Err(CloudFilesCoreError::invalid_content_response(
155 "response revision does not match the requested revision",
156 ));
157 }
158 if response.total_size() != self.expected_size {
159 return Err(CloudFilesCoreError::invalid_content_response(
160 "response size does not match metadata for the requested revision",
161 ));
162 }
163 match self.range {
164 ContentReadRange::Whole => {
165 if response.offset() != 0
166 || response.byte_len() != response.total_size()
167 || !response.is_complete_file()
168 {
169 return Err(CloudFilesCoreError::invalid_content_response(
170 "whole-file response does not contain the complete file",
171 ));
172 }
173 }
174 ContentReadRange::Range(range) => {
175 if response.offset() != range.offset() {
176 return Err(CloudFilesCoreError::invalid_content_response(
177 "range response starts at a different offset",
178 ));
179 }
180 let expected_len = if range.offset() >= response.total_size() {
181 0
182 } else {
183 std::cmp::min(
184 range.length(),
185 response.total_size().saturating_sub(range.offset()),
186 )
187 };
188 if response.byte_len() != expected_len {
189 return Err(CloudFilesCoreError::invalid_content_response(
190 "range response length does not match the requested extent",
191 ));
192 }
193 }
194 }
195 Ok(())
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct ContentReadResponse {
202 revision: ContentRevision,
203 offset: u64,
204 bytes: Bytes,
205 byte_len: u64,
206 total_size: u64,
207}
208
209impl ContentReadResponse {
210 pub fn new(
216 revision: ContentRevision,
217 offset: u64,
218 bytes: Bytes,
219 total_size: u64,
220 ) -> Result<Self> {
221 let byte_len = bytes.len() as u64;
222 let Some(end) = offset.checked_add(byte_len) else {
223 return Err(CloudFilesCoreError::invalid_content_response(
224 "response byte range exceeds u64",
225 ));
226 };
227 if offset > total_size || end > total_size {
228 return Err(CloudFilesCoreError::invalid_content_response(
229 "response bytes exceed the logical file size",
230 ));
231 }
232 Ok(Self {
233 revision,
234 offset,
235 bytes,
236 byte_len,
237 total_size,
238 })
239 }
240
241 pub const fn revision(&self) -> &ContentRevision {
243 &self.revision
244 }
245
246 pub const fn offset(&self) -> u64 {
248 self.offset
249 }
250
251 pub const fn bytes(&self) -> &Bytes {
253 &self.bytes
254 }
255
256 pub const fn byte_len(&self) -> u64 {
258 self.byte_len
259 }
260
261 pub const fn total_size(&self) -> u64 {
263 self.total_size
264 }
265
266 pub fn is_complete_file(&self) -> bool {
268 self.offset == 0 && self.byte_len() == self.total_size
269 }
270
271 pub fn into_parts(self) -> (ContentRevision, u64, Bytes, u64) {
273 (self.revision, self.offset, self.bytes, self.total_size)
274 }
275}
276
277#[async_trait]
279pub trait CloudMetadataBackend: Send + Sync {
280 async fn get_item(&self, key: &CloudItemKey) -> BackendResult<CloudItem>;
282
283 async fn list_children(
285 &self,
286 parent: &CloudItemKey,
287 cursor: Option<&PageCursor>,
288 ) -> BackendResult<CloudItemPage>;
289
290 async fn changes_since(
292 &self,
293 scope: &CloudScope,
294 cursor: Option<&ChangeCursor>,
295 ) -> BackendResult<ChangePage>;
296}
297
298#[async_trait]
300pub trait CloudContentBackend: Send + Sync {
301 async fn read_content(
303 &self,
304 request: &ContentReadRequest,
305 ) -> BackendResult<ContentReadResponse>;
306}
307
308pub trait CloudFilesBackend: CloudMetadataBackend + CloudContentBackend {
310 fn capabilities(&self) -> CloudFilesCapabilities;
312}