Skip to main content

cranpose_services/content/
file.rs

1//! Filesystem-backed [`Content`], [`ContentFolder`] and [`ContentSink`].
2//!
3//! Every non-web provider that can name a real path — the desktop picker, the
4//! iOS security-scoped picker, bundled asset installs, writable folders — hands
5//! back these types rather than re-implementing chunked reads.
6
7use super::{
8    Content, ContentEntry, ContentError, ContentFolder, ContentFolderRef, ContentFuture,
9    ContentHandle, ContentMetadata, ContentReader, ContentReaderRef, ContentSink, ContentSinkRef,
10    DEFAULT_CHUNK_LEN,
11};
12use std::cell::RefCell;
13use std::fs::File;
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::rc::Rc;
17use std::time::UNIX_EPOCH;
18
19fn io_error(path: &Path, error: std::io::Error) -> ContentError {
20    match error.kind() {
21        std::io::ErrorKind::NotFound => ContentError::NotFound(path.display().to_string()),
22        std::io::ErrorKind::PermissionDenied => {
23            ContentError::PermissionDenied(path.display().to_string())
24        }
25        _ => ContentError::Io(format!("{}: {error}", path.display())),
26    }
27}
28
29fn metadata_for(path: &Path) -> ContentMetadata {
30    let name = path
31        .file_name()
32        .map(|name| name.to_string_lossy().into_owned())
33        .unwrap_or_else(|| path.display().to_string());
34    let mut metadata = ContentMetadata {
35        name,
36        mime_type: None,
37        len: None,
38        modified_millis: None,
39        identifier: path.display().to_string(),
40    };
41    if let Ok(stat) = std::fs::metadata(path) {
42        if stat.is_file() {
43            metadata.len = Some(stat.len());
44        }
45        metadata.modified_millis = stat
46            .modified()
47            .ok()
48            .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
49            .map(|since| since.as_millis() as u64);
50    }
51    metadata
52}
53
54/// A file on the local filesystem.
55pub struct FileContent {
56    path: PathBuf,
57}
58
59impl FileContent {
60    /// Wraps `path` as readable content.
61    pub fn new(path: impl Into<PathBuf>) -> Self {
62        Self { path: path.into() }
63    }
64
65    /// The wrapped path.
66    pub fn path(&self) -> &Path {
67        &self.path
68    }
69}
70
71impl Content for FileContent {
72    fn metadata(&self) -> ContentMetadata {
73        metadata_for(&self.path)
74    }
75
76    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>> {
77        let path = self.path.clone();
78        Box::pin(async move {
79            let file = File::open(&path).map_err(|error| io_error(&path, error))?;
80            Ok(Rc::new(FileReader {
81                path,
82                file: RefCell::new(Some(file)),
83            }) as ContentReaderRef)
84        })
85    }
86
87    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
88        let path = self.path.clone();
89        Box::pin(async move { std::fs::read(&path).map_err(|error| io_error(&path, error)) })
90    }
91}
92
93/// A shared handle to the file at `path`.
94pub fn file_content(path: impl Into<PathBuf>) -> ContentHandle {
95    Rc::new(FileContent::new(path))
96}
97
98struct FileReader {
99    path: PathBuf,
100    file: RefCell<Option<File>>,
101}
102
103impl ContentReader for FileReader {
104    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>> {
105        Box::pin(async move {
106            let mut slot = self.file.borrow_mut();
107            let Some(file) = slot.as_mut() else {
108                return Ok(None);
109            };
110            let mut buffer = vec![0u8; DEFAULT_CHUNK_LEN];
111            let mut filled = 0;
112            while filled < buffer.len() {
113                match file.read(&mut buffer[filled..]) {
114                    Ok(0) => break,
115                    Ok(read) => filled += read,
116                    Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
117                    Err(error) => return Err(io_error(&self.path, error)),
118                }
119            }
120            if filled == 0 {
121                *slot = None;
122                return Ok(None);
123            }
124            buffer.truncate(filled);
125            Ok(Some(buffer))
126        })
127    }
128}
129
130/// A directory on the local filesystem.
131pub struct FileFolder {
132    path: PathBuf,
133}
134
135impl FileFolder {
136    /// Wraps `path` as an enumerable folder.
137    pub fn new(path: impl Into<PathBuf>) -> Self {
138        Self { path: path.into() }
139    }
140
141    /// The wrapped path.
142    pub fn path(&self) -> &Path {
143        &self.path
144    }
145}
146
147impl ContentFolder for FileFolder {
148    fn metadata(&self) -> ContentMetadata {
149        metadata_for(&self.path)
150    }
151
152    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>> {
153        let path = self.path.clone();
154        Box::pin(async move {
155            let listing = std::fs::read_dir(&path).map_err(|error| io_error(&path, error))?;
156            let mut entries = Vec::new();
157            for child in listing {
158                let child = child.map_err(|error| io_error(&path, error))?;
159                let child_path = child.path();
160                let is_dir = child
161                    .file_type()
162                    .map(|kind| kind.is_dir())
163                    .unwrap_or_else(|_| child_path.is_dir());
164                if is_dir {
165                    entries.push(ContentEntry::Folder(
166                        Rc::new(FileFolder::new(child_path)) as ContentFolderRef
167                    ));
168                } else {
169                    entries.push(ContentEntry::File(
170                        Rc::new(FileContent::new(child_path)) as ContentHandle
171                    ));
172                }
173            }
174            entries.sort_by_key(|entry| entry.metadata().name);
175            Ok(entries)
176        })
177    }
178}
179
180/// A shared handle to the directory at `path`.
181pub fn file_folder(path: impl Into<PathBuf>) -> ContentFolderRef {
182    Rc::new(FileFolder::new(path))
183}
184
185/// A file being written through a temporary sibling, renamed on
186/// [`ContentSink::finish`] so a partial write never replaces a good file.
187pub struct FileSink {
188    destination: PathBuf,
189    staging: PathBuf,
190    file: RefCell<Option<File>>,
191}
192
193impl FileSink {
194    /// Opens a staged writer for `destination`.
195    pub fn create(destination: impl Into<PathBuf>) -> Result<Self, ContentError> {
196        let destination = destination.into();
197        if let Some(parent) = destination.parent() {
198            std::fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
199        }
200        let staging = staging_path(&destination);
201        let file = File::create(&staging).map_err(|error| io_error(&staging, error))?;
202        Ok(Self {
203            destination,
204            staging,
205            file: RefCell::new(Some(file)),
206        })
207    }
208
209    /// A shared handle to this sink.
210    pub fn handle(self) -> ContentSinkRef {
211        Rc::new(self)
212    }
213
214    /// The path this sink commits to.
215    pub fn destination(&self) -> &Path {
216        &self.destination
217    }
218}
219
220fn staging_path(destination: &Path) -> PathBuf {
221    let mut name = destination
222        .file_name()
223        .map(|name| name.to_string_lossy().into_owned())
224        .unwrap_or_else(|| "content".to_string());
225    name.push_str(".partial");
226    destination.with_file_name(name)
227}
228
229impl ContentSink for FileSink {
230    fn write_chunk(&self, bytes: Vec<u8>) -> ContentFuture<'_, Result<(), ContentError>> {
231        Box::pin(async move {
232            let mut slot = self.file.borrow_mut();
233            let file = slot
234                .as_mut()
235                .ok_or_else(|| ContentError::Io("sink is already finished".into()))?;
236            file.write_all(&bytes)
237                .map_err(|error| io_error(&self.staging, error))
238        })
239    }
240
241    fn finish(&self) -> ContentFuture<'_, Result<(), ContentError>> {
242        Box::pin(async move {
243            let Some(mut file) = self.file.borrow_mut().take() else {
244                return Ok(());
245            };
246            file.flush()
247                .map_err(|error| io_error(&self.staging, error))?;
248            file.sync_all()
249                .map_err(|error| io_error(&self.staging, error))?;
250            drop(file);
251            std::fs::rename(&self.staging, &self.destination)
252                .map_err(|error| io_error(&self.destination, error))
253        })
254    }
255}
256
257impl Drop for FileSink {
258    fn drop(&mut self) {
259        if self.file.borrow().is_some() {
260            let _ = std::fs::remove_file(&self.staging);
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::content::{collect_stream, folder_files, write_all, DEFAULT_CHUNK_LEN};
269
270    fn temp_dir(name: &str) -> PathBuf {
271        crate::test_scratch_dir(&format!("content-{name}"))
272    }
273
274    #[test]
275    fn a_file_streams_in_chunks_and_reports_its_length() {
276        let root = temp_dir("chunks");
277        let path = root.join("payload.bin");
278        let payload = vec![3u8; DEFAULT_CHUNK_LEN + 5];
279        std::fs::write(&path, &payload).unwrap();
280
281        let content = file_content(&path);
282        assert_eq!(content.metadata().name, "payload.bin");
283        assert_eq!(content.metadata().len, Some(payload.len() as u64));
284
285        let sizes = pollster::block_on(async {
286            let reader = content.open().await.unwrap();
287            let mut sizes = Vec::new();
288            while let Some(chunk) = reader.read_chunk().await.unwrap() {
289                sizes.push(chunk.len());
290            }
291            sizes
292        });
293        assert_eq!(sizes, vec![DEFAULT_CHUNK_LEN, 5]);
294        assert_eq!(pollster::block_on(content.read_all()).unwrap(), payload);
295        std::fs::remove_dir_all(&root).unwrap();
296    }
297
298    #[test]
299    fn a_missing_file_reports_not_found() {
300        let root = temp_dir("missing");
301        let content = file_content(root.join("absent.bin"));
302        assert!(matches!(
303            pollster::block_on(content.read_all()),
304            Err(ContentError::NotFound(_))
305        ));
306        std::fs::remove_dir_all(&root).unwrap();
307    }
308
309    #[test]
310    fn a_folder_streams_its_whole_tree() {
311        let root = temp_dir("tree");
312        std::fs::create_dir_all(root.join("nested")).unwrap();
313        std::fs::write(root.join("a.txt"), b"a").unwrap();
314        std::fs::write(root.join("nested/b.txt"), b"b").unwrap();
315
316        let stream = folder_files(file_folder(&root));
317        let mut names: Vec<String> = pollster::block_on(collect_stream(&stream))
318            .unwrap()
319            .iter()
320            .map(|file| file.metadata().name)
321            .collect();
322        names.sort();
323        assert_eq!(names, vec!["a.txt", "b.txt"]);
324        std::fs::remove_dir_all(&root).unwrap();
325    }
326
327    #[test]
328    fn a_sink_commits_atomically_and_discards_unfinished_writes() {
329        let root = temp_dir("sink");
330        let destination = root.join("out/report.bin");
331
332        let sink = FileSink::create(&destination).unwrap().handle();
333        pollster::block_on(write_all(&sink, b"committed".to_vec())).unwrap();
334        assert_eq!(std::fs::read(&destination).unwrap(), b"committed");
335
336        let abandoned = root.join("out/abandoned.bin");
337        {
338            let sink = FileSink::create(&abandoned).unwrap();
339            pollster::block_on(sink.write_chunk(b"partial".to_vec())).unwrap();
340        }
341        assert!(!abandoned.exists());
342        assert!(!staging_path(&abandoned).exists());
343        std::fs::remove_dir_all(&root).unwrap();
344    }
345}