Skip to main content

base_http_content/
file_store.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use crate::UploadStore;
5use base_scheduler::{Blocking, Cancellation};
6use base_web_content::{FieldPath, UploadHead};
7use std::{
8    fmt,
9    fs::{self, File, OpenOptions},
10    io::{self, Write},
11    path::{Path, PathBuf},
12};
13
14/// The content an upload gathers before it is written, so that small pieces do not each need a blocking job.
15const WRITE_SIZE: usize = 64 * 1024;
16
17/// Stores each upload in a new file, at a path the application chooses, as `protocol-multipart`'s `Readable#save` does.
18///
19/// `destination` is given the declared field's path, such as `avatar` or `photos[2]`, and returns the file's path; it is never given the filename or media type the client declared, which must not choose where an upload is stored. Each file is created exclusively, so creating it fails if anything is at the path, including a symbolic link, and on Unix with permissions 0600 unless [`Self::with_permissions`] chooses others; the process's umask may remove more. A path that already exists fails the upload, and the read, with [`io::ErrorKind::AlreadyExists`], so `destination` should return a path that is new, such as one with a random or unique name in a directory only the server can write.
20///
21/// When an upload is aborted, which [`read_content`](crate::read_content) does whenever writing or finishing it fails, its file is removed; a file that is already gone counts as removed. A failed write or finish leaves the file in place until then, so a caller driving the store itself must abort the upload too. The store only removes files it created, by the paths it created them at, so the directory should not be one that others can write. Files are closed when they finish, but not synchronized to storage. File operations run through the scheduler's [`Blocking`] capability, so that they do not block an async worker, and content is gathered into writes of up to 64 KiB; only when the scheduler refuses to admit blocking work, such as after cancellation, is a file closed on the current thread.
22///
23/// The handle of a stored upload is its path.
24pub struct FileStore<'s, S, D> {
25    scheduler: &'s S,
26    destination: D,
27    #[cfg(unix)]
28    permissions: u32,
29}
30
31impl<'s, S, D> FileStore<'s, S, D>
32where
33    S: Blocking + Sync,
34    D: FnMut(&FieldPath) -> PathBuf + Send,
35{
36    /// A store creating files on `scheduler`'s blocking threads, at the paths `destination` gives.
37    pub fn new(scheduler: &'s S, destination: D) -> Self {
38        Self {
39            scheduler,
40            destination,
41            #[cfg(unix)]
42            permissions: 0o600,
43        }
44    }
45
46    /// Create files with these permission bits, as for `open(2)`'s `mode`, rather than 0600. Only the permission bits, `0o777`, are used: the setuid, setgid, and sticky bits are ignored. It is only available on Unix, where files have permission bits.
47    #[cfg(unix)]
48    pub fn with_permissions(mut self, permissions: u32) -> Self {
49        self.permissions = permissions & 0o777;
50        self
51    }
52}
53
54impl<S, D> fmt::Debug for FileStore<'_, S, D> {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        formatter.debug_struct("FileStore").finish_non_exhaustive()
57    }
58}
59
60/// An upload being stored in a file by [`FileStore`].
61pub struct FileUpload {
62    path: PathBuf,
63    /// The open file, until the upload finishes or a write fails.
64    file: Option<File>,
65    /// Content not yet written.
66    pending: Vec<u8>,
67}
68
69impl FileUpload {
70    /// The path the upload is stored at.
71    pub fn path(&self) -> &Path {
72        &self.path
73    }
74}
75
76/// Shows the path, which the application chose, and the size of the content not yet written.
77impl fmt::Debug for FileUpload {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        formatter
80            .debug_struct("FileUpload")
81            .field("path", &self.path)
82            .field("pending", &self.pending.len())
83            .finish_non_exhaustive()
84    }
85}
86
87impl<S, D> UploadStore for FileStore<'_, S, D>
88where
89    S: Blocking + Sync,
90    D: FnMut(&FieldPath) -> PathBuf + Send,
91{
92    type Handle = PathBuf;
93    type Upload = FileUpload;
94    type Error = io::Error;
95
96    async fn create(
97        &mut self,
98        head: &UploadHead,
99        cancellation: &Cancellation,
100    ) -> io::Result<FileUpload> {
101        let path = (self.destination)(head.path());
102        let mut options = OpenOptions::new();
103        // SECURITY: `create_new` is `O_CREAT | O_EXCL`, which fails if anything exists at the path, including a symbolic link, so an upload never writes through a link or into a file the server did not just create.
104        options.write(true).create_new(true);
105        #[cfg(unix)]
106        std::os::unix::fs::OpenOptionsExt::mode(&mut options, self.permissions);
107        let opened = path.clone();
108        let file = blocking(self.scheduler, move || options.open(opened), cancellation).await?;
109        Ok(FileUpload {
110            path,
111            file: Some(file),
112            pending: Vec::new(),
113        })
114    }
115
116    async fn write(
117        &mut self,
118        upload: &mut FileUpload,
119        content: &[u8],
120        cancellation: &Cancellation,
121    ) -> io::Result<()> {
122        upload.pending.extend_from_slice(content);
123        if upload.pending.len() < WRITE_SIZE {
124            return Ok(());
125        }
126        flush(self.scheduler, upload, cancellation).await
127    }
128
129    async fn finish(
130        &mut self,
131        upload: &mut FileUpload,
132        cancellation: &Cancellation,
133    ) -> io::Result<PathBuf> {
134        if !upload.pending.is_empty() {
135            flush(self.scheduler, upload, cancellation).await?;
136        }
137        // Close the file on a blocking thread, since closing can wait for the file system.
138        let file = upload.file.take();
139        let close = move || {
140            drop(file);
141            Ok(())
142        };
143        blocking(self.scheduler, close, cancellation).await?;
144        Ok(upload.path.clone())
145    }
146
147    async fn abort(&mut self, upload: FileUpload) -> io::Result<()> {
148        let FileUpload { path, file, .. } = upload;
149        let abort = move || {
150            drop(file);
151            remove(&path)
152        };
153        blocking(self.scheduler, abort, &Cancellation::never()).await
154    }
155
156    async fn remove(&mut self, handle: PathBuf) -> io::Result<()> {
157        blocking(
158            self.scheduler,
159            move || remove(&handle),
160            &Cancellation::never(),
161        )
162        .await
163    }
164}
165
166/// Run file operations on one of `scheduler`'s blocking threads, admitted under `cancellation`.
167async fn blocking<S: Blocking + Sync, T: Send + 'static>(
168    scheduler: &S,
169    operation: impl FnOnce() -> io::Result<T> + Send + 'static,
170    cancellation: &Cancellation,
171) -> io::Result<T> {
172    let handle = scheduler
173        .spawn_blocking(operation, cancellation)
174        .await
175        .map_err(io::Error::other)?;
176    // The task error's panic payload cannot be shared between threads, so only its description is kept.
177    handle
178        .await
179        .map_err(|error| io::Error::other(error.to_string()))?
180}
181
182/// Write what the upload has gathered, leaving its file closed when this fails.
183async fn flush<S: Blocking + Sync>(
184    scheduler: &S,
185    upload: &mut FileUpload,
186    cancellation: &Cancellation,
187) -> io::Result<()> {
188    // An upload whose write failed has lost its file, and one that finished has closed it.
189    let mut file = upload.file.take().ok_or_else(closed)?;
190    let pending = std::mem::take(&mut upload.pending);
191    let path = upload.path.clone();
192    let (file, mut pending) = blocking(
193        scheduler,
194        move || {
195            write_all(&mut file, &pending, &path)?;
196            Ok((file, pending))
197        },
198        cancellation,
199    )
200    .await?;
201    pending.clear();
202    upload.file = Some(file);
203    upload.pending = pending;
204    Ok(())
205}
206
207/// The error for writing to an upload that failed or finished.
208fn closed() -> io::Error {
209    io::Error::other("the upload's file is closed, after it failed or finished")
210}
211
212/// Write all of `content` to the file at `path`.
213fn write_all(file: &mut File, content: &[u8], path: &Path) -> io::Result<()> {
214    #[cfg(test)]
215    {
216        if faults::check(path, faults::Operation::Panic).is_err() {
217            panic!("injected panic");
218        }
219        faults::check(path, faults::Operation::Write)?;
220    }
221    #[cfg(not(test))]
222    let _ = path;
223    file.write_all(content)
224}
225
226/// Remove the file at `path`, which counts as removed if it is already gone.
227// SECURITY: Removal is by path, so something that replaced the file at that path after it was created would be removed instead. Replacing it needs write access to the application's upload directory, which the store's documentation requires only the server to have; with that access, the replacement could be removed or overwritten directly anyway.
228fn remove(path: &Path) -> io::Result<()> {
229    #[cfg(test)]
230    faults::check(path, faults::Operation::Remove)?;
231    match fs::remove_file(path) {
232        Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
233        _ => Ok(()),
234    }
235}
236
237/// Failures injected into file operations by path, for tests of failures the file system cannot be made to produce reliably.
238#[cfg(test)]
239pub(crate) mod faults {
240    use std::{
241        io,
242        path::{Path, PathBuf},
243        sync::Mutex,
244    };
245
246    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
247    pub(crate) enum Operation {
248        Write,
249        Remove,
250        /// Panic while writing, as a blocking job that fails.
251        Panic,
252    }
253
254    static FAULTS: Mutex<Vec<(PathBuf, Operation)>> = Mutex::new(Vec::new());
255
256    /// Fail the next `operation` on `path`.
257    pub(crate) fn inject(path: &Path, operation: Operation) {
258        FAULTS.lock().unwrap().push((path.to_owned(), operation));
259    }
260
261    /// The injected failure of `operation` on `path`, if any, which is then used up.
262    pub(crate) fn check(path: &Path, operation: Operation) -> io::Result<()> {
263        let mut faults = FAULTS.lock().unwrap();
264        let Some(position) = faults
265            .iter()
266            .position(|(faulty, injected)| faulty == path && *injected == operation)
267        else {
268            return Ok(());
269        };
270        faults.remove(position);
271        Err(io::Error::other("injected failure"))
272    }
273}