Skip to main content

nextcloud_uploads/
session.rs

1//! State and chunk boundary calculations for Nextcloud chunked uploads.
2
3use std::collections::BTreeSet;
4use std::sync::{Arc, Mutex, MutexGuard};
5
6/// Minimum chunk size recommended by Nextcloud (5 MiB).
7pub const MIN_CHUNK_SIZE: u64 = 5 * 1024 * 1024;
8
9/// Maximum chunk size recommended by Nextcloud (5 GiB).
10pub const MAX_CHUNK_SIZE: u64 = 5 * 1024 * 1024 * 1024;
11
12/// Default chunk size used for uploads (10 MiB).
13pub const DEFAULT_CHUNK_SIZE: u64 = 10 * 1024 * 1024;
14
15/// Describes the byte boundaries of an individual upload chunk.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub struct ChunkBoundary {
18    /// 1-based index of this chunk.
19    pub index: u32,
20    /// Inclusive byte offset where this chunk starts within the upload stream.
21    pub start: u64,
22    /// Exclusive byte offset where this chunk ends within the upload stream.
23    pub end: u64,
24}
25
26impl ChunkBoundary {
27    /// Constructs a new chunk boundary.
28    pub const fn new(index: u32, start: u64, end: u64) -> Self {
29        Self { index, start, end }
30    }
31
32    /// Size of this chunk in bytes.
33    pub const fn len(&self) -> u64 {
34        self.end.saturating_sub(self.start)
35    }
36
37    /// Whether this chunk has zero length.
38    pub const fn is_empty(&self) -> bool {
39        self.start >= self.end
40    }
41}
42
43/// Active chunked upload session state.
44///
45/// Keeps track of user ID, upload ID, destination path, total file size,
46/// and which chunks have been uploaded successfully.
47#[derive(Clone, Debug)]
48pub struct UploadSession {
49    user_id: String,
50    upload_id: String,
51    destination_path: String,
52    total_size: u64,
53    uploaded_chunks: Arc<Mutex<BTreeSet<u32>>>,
54}
55
56impl UploadSession {
57    /// Minimum chunk size recommended by Nextcloud (5 MiB).
58    pub const MIN_CHUNK_SIZE: u64 = MIN_CHUNK_SIZE;
59    /// Maximum chunk size recommended by Nextcloud (5 GiB).
60    pub const MAX_CHUNK_SIZE: u64 = MAX_CHUNK_SIZE;
61    /// Default chunk size used for uploads (10 MiB).
62    pub const DEFAULT_CHUNK_SIZE: u64 = DEFAULT_CHUNK_SIZE;
63
64    /// Creates a new upload session without any recorded chunks.
65    pub fn new(
66        user_id: impl Into<String>,
67        upload_id: impl Into<String>,
68        destination_path: impl Into<String>,
69        total_size: u64,
70    ) -> Self {
71        Self {
72            user_id: user_id.into(),
73            upload_id: upload_id.into(),
74            destination_path: destination_path.into(),
75            total_size,
76            uploaded_chunks: Arc::new(Mutex::new(BTreeSet::new())),
77        }
78    }
79
80    /// User ID of the upload owner.
81    pub fn user_id(&self) -> &str {
82        &self.user_id
83    }
84
85    /// Unique identifier for this upload session.
86    pub fn upload_id(&self) -> &str {
87        &self.upload_id
88    }
89
90    /// Destination WebDAV path or URI where the assembled file will be placed.
91    pub fn destination_path(&self) -> &str {
92        &self.destination_path
93    }
94
95    /// Total size in bytes of the complete file being uploaded.
96    pub const fn total_size(&self) -> u64 {
97        self.total_size
98    }
99
100    /// Records that a chunk index has been uploaded successfully.
101    pub fn mark_chunk_uploaded(&self, chunk_index: u32) {
102        self.lock_chunks().insert(chunk_index);
103    }
104
105    /// Checks whether a specific chunk index has been uploaded.
106    pub fn is_chunk_uploaded(&self, chunk_index: u32) -> bool {
107        self.lock_chunks().contains(&chunk_index)
108    }
109
110    /// Returns a set of all chunk indices uploaded so far.
111    pub fn uploaded_chunks(&self) -> BTreeSet<u32> {
112        self.lock_chunks().clone()
113    }
114
115    /// Number of chunks marked as successfully uploaded.
116    pub fn uploaded_chunk_count(&self) -> usize {
117        self.lock_chunks().len()
118    }
119
120    /// Calculates the expected total chunk count for the given chunk size.
121    ///
122    /// Returns `None` if `chunk_size` is zero or if total chunk count overflows `u32`.
123    pub fn expected_chunk_count(&self, chunk_size: u64) -> Option<u32> {
124        if chunk_size == 0 {
125            return None;
126        }
127        if self.total_size == 0 {
128            return Some(1);
129        }
130        let count = self.total_size.div_ceil(chunk_size);
131        u32::try_from(count).ok()
132    }
133
134    /// Whether all expected chunks for the given chunk size have been uploaded.
135    pub fn is_complete(&self, chunk_size: u64) -> bool {
136        let Some(expected) = self.expected_chunk_count(chunk_size) else {
137            return false;
138        };
139        let uploaded = self.lock_chunks();
140        if uploaded.len() != expected as usize {
141            return false;
142        }
143        (1..=expected).all(|i| uploaded.contains(&i))
144    }
145
146    /// Calculates the byte boundary for a single 1-based chunk index.
147    ///
148    /// Returns `None` if `chunk_size` is zero, `chunk_index` is zero, or exceeds total chunk count.
149    pub fn chunk_boundary(&self, chunk_index: u32, chunk_size: u64) -> Option<ChunkBoundary> {
150        if chunk_size == 0 || chunk_index == 0 {
151            return None;
152        }
153        let total_chunks = self.expected_chunk_count(chunk_size)?;
154        if chunk_index > total_chunks {
155            return None;
156        }
157        let offset = u64::from(chunk_index - 1) * chunk_size;
158        let end = (offset + chunk_size).min(self.total_size);
159        Some(ChunkBoundary::new(chunk_index, offset, end))
160    }
161
162    /// Calculates all chunk boundaries for this upload given a chunk size.
163    ///
164    /// Returns `None` if `chunk_size` is zero or if total chunk count overflows `u32`.
165    pub fn chunk_boundaries(&self, chunk_size: u64) -> Option<Vec<ChunkBoundary>> {
166        let total_chunks = self.expected_chunk_count(chunk_size)?;
167        let mut boundaries = Vec::with_capacity(total_chunks as usize);
168        for index in 1..=total_chunks {
169            if let Some(boundary) = self.chunk_boundary(index, chunk_size) {
170                boundaries.push(boundary);
171            }
172        }
173        Some(boundaries)
174    }
175
176    fn lock_chunks(&self) -> MutexGuard<'_, BTreeSet<u32>> {
177        match self.uploaded_chunks.lock() {
178            Ok(guard) => guard,
179            Err(poisoned) => poisoned.into_inner(),
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn boundary_calculations_exact_multiple() {
190        let session = UploadSession::new("alice", "up-1", "/path", 30 * 1024 * 1024);
191        let chunk_size = 10 * 1024 * 1024;
192        assert_eq!(session.expected_chunk_count(chunk_size), Some(3));
193
194        let boundaries = session.chunk_boundaries(chunk_size).unwrap();
195        assert_eq!(boundaries.len(), 3);
196
197        assert_eq!(boundaries[0].index, 1);
198        assert_eq!(boundaries[0].start, 0);
199        assert_eq!(boundaries[0].end, 10 * 1024 * 1024);
200        assert_eq!(boundaries[0].len(), 10 * 1024 * 1024);
201        assert!(!boundaries[0].is_empty());
202
203        assert_eq!(boundaries[1].index, 2);
204        assert_eq!(boundaries[1].start, 10 * 1024 * 1024);
205        assert_eq!(boundaries[1].end, 20 * 1024 * 1024);
206
207        assert_eq!(boundaries[2].index, 3);
208        assert_eq!(boundaries[2].start, 20 * 1024 * 1024);
209        assert_eq!(boundaries[2].end, 30 * 1024 * 1024);
210    }
211
212    #[test]
213    fn boundary_calculations_with_remainder() {
214        let session = UploadSession::new("alice", "up-1", "/path", 25 * 1024 * 1024);
215        let chunk_size = 10 * 1024 * 1024;
216        assert_eq!(session.expected_chunk_count(chunk_size), Some(3));
217
218        let boundaries = session.chunk_boundaries(chunk_size).unwrap();
219        assert_eq!(boundaries.len(), 3);
220
221        assert_eq!(boundaries[2].index, 3);
222        assert_eq!(boundaries[2].start, 20 * 1024 * 1024);
223        assert_eq!(boundaries[2].end, 25 * 1024 * 1024);
224        assert_eq!(boundaries[2].len(), 5 * 1024 * 1024);
225    }
226
227    #[test]
228    fn zero_size_and_invalid_chunk_size() {
229        let session = UploadSession::new("alice", "up-1", "/path", 0);
230        assert_eq!(session.expected_chunk_count(1024), Some(1));
231        assert_eq!(
232            session.chunk_boundary(1, 1024),
233            Some(ChunkBoundary::new(1, 0, 0))
234        );
235        assert!(session.chunk_boundary(1, 1024).unwrap().is_empty());
236
237        assert_eq!(session.expected_chunk_count(0), None);
238        assert_eq!(session.chunk_boundary(1, 0), None);
239        assert_eq!(session.chunk_boundaries(0), None);
240
241        assert_eq!(session.chunk_boundary(0, 1024), None);
242        assert_eq!(session.chunk_boundary(2, 1024), None);
243    }
244
245    #[test]
246    fn chunk_tracking_and_completion() {
247        let session = UploadSession::new("alice", "up-1", "/path", 20 * 1024 * 1024);
248        let chunk_size = 10 * 1024 * 1024;
249
250        assert_eq!(session.uploaded_chunk_count(), 0);
251        assert!(!session.is_chunk_uploaded(1));
252        assert!(!session.is_complete(chunk_size));
253
254        session.mark_chunk_uploaded(1);
255        assert_eq!(session.uploaded_chunk_count(), 1);
256        assert!(session.is_chunk_uploaded(1));
257        assert!(!session.is_complete(chunk_size));
258
259        session.mark_chunk_uploaded(2);
260        assert_eq!(session.uploaded_chunk_count(), 2);
261        assert!(session.is_chunk_uploaded(2));
262        assert!(session.is_complete(chunk_size));
263
264        let uploaded = session.uploaded_chunks();
265        assert_eq!(uploaded.into_iter().collect::<Vec<_>>(), vec![1, 2]);
266    }
267
268    #[test]
269    fn cloned_session_shares_upload_progress() {
270        let session = UploadSession::new("alice", "up-1", "/path", 30 * 1024 * 1024);
271        let clone = session.clone();
272
273        session.mark_chunk_uploaded(1);
274        assert!(clone.is_chunk_uploaded(1));
275        assert_eq!(clone.uploaded_chunk_count(), 1);
276
277        clone.mark_chunk_uploaded(2);
278        assert!(session.is_chunk_uploaded(2));
279        assert_eq!(session.uploaded_chunk_count(), 2);
280    }
281}