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