Skip to main content

coven_foundation/
local_file.rs

1//! Private local-file machinery used by storage and store-directory capabilities.
2
3use std::path::{Path, PathBuf};
4use std::pin::Pin;
5
6use async_trait::async_trait;
7use bytes::Bytes;
8use futures_util::{Stream, StreamExt};
9use tokio::io::AsyncReadExt;
10
11use crate::atomic_file::{FileError, FileSync};
12
13/// The filename prefix an atomic blob write gives its in-progress temp sibling
14/// (`.tmp.<uuid>`) before the owning durability policy and rename make it the
15/// committed destination.
16pub const TEMP_BLOB_PREFIX: &str = crate::atomic_file::TEMP_FILE_PREFIX;
17
18/// Whether `path`'s file name marks it as an atomic-write temp sibling.
19pub(crate) fn is_temp_blob_path(path: &Path) -> bool {
20    path.file_name()
21        .and_then(|name| name.to_str())
22        .is_some_and(|name| name.starts_with(TEMP_BLOB_PREFIX))
23}
24
25#[derive(Debug)]
26pub enum StreamWriteError<E> {
27    Source(E),
28    Local(FileError),
29}
30
31impl<E: std::fmt::Display> std::fmt::Display for StreamWriteError<E> {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::Source(error) => error.fmt(f),
35            Self::Local(error) => write!(f, "local destination: {error}"),
36        }
37    }
38}
39
40impl<E: std::fmt::Debug + std::fmt::Display> std::error::Error for StreamWriteError<E> {}
41
42#[derive(Debug)]
43pub enum ByteStreamWriteError<E> {
44    Source(E),
45    SourceCleanup { source: E, cleanup: FileError },
46    Local(FileError),
47}
48
49#[async_trait]
50pub trait PlaintextChunkReader: Send {
51    type Error: Send;
52    async fn next_chunk(&mut self, max: usize) -> Result<Vec<u8>, Self::Error>;
53}
54
55enum AtomicChunkWriteError<E> {
56    Source(E),
57    Local(FileError),
58}
59
60struct AtomicTempFile {
61    path: PathBuf,
62    file: Option<tokio::fs::File>,
63    armed: bool,
64}
65
66/// A provider download path that becomes visible at its destination only after
67/// the caller has verified the completed file.
68pub struct AtomicStagedFile {
69    destination: PathBuf,
70    staged: Option<AtomicTempFile>,
71    file_sync: FileSync,
72}
73
74/// A staged file that has been installed at its destination while the caller's
75/// durable transaction is still deciding whether that installation commits.
76pub struct PublishedAtomicFile {
77    destination: PathBuf,
78    file_sync: FileSync,
79}
80
81#[derive(Debug, thiserror::Error)]
82pub enum CommitNewFileError {
83    #[error("destination already exists: {0}")]
84    DestinationExists(PathBuf),
85    #[error("commit new file: {0}")]
86    Filesystem(#[from] FileError),
87    #[error("{operation}; rollback failed: {rollback}")]
88    RollbackFailed {
89        operation: Box<CommitNewFileError>,
90        rollback: Box<FileError>,
91    },
92}
93
94impl CommitNewFileError {
95    fn rollback(operation: CommitNewFileError, rollback: FileError) -> Self {
96        Self::RollbackFailed {
97            operation: Box::new(operation),
98            rollback: Box::new(rollback),
99        }
100    }
101}
102
103impl AtomicStagedFile {
104    pub(crate) fn is_staging_path(path: &Path) -> bool {
105        is_temp_blob_path(path)
106    }
107
108    pub async fn create(destination: &Path) -> Result<Self, FileError> {
109        Self::create_with_file_sync(destination, FileSync::Enabled).await
110    }
111
112    pub(crate) async fn create_with_file_sync(
113        destination: &Path,
114        file_sync: FileSync,
115    ) -> Result<Self, FileError> {
116        let parent = destination.parent().ok_or_else(|| FileError::NoParent {
117            path: destination.to_path_buf(),
118        })?;
119        tokio::fs::create_dir_all(parent)
120            .await
121            .map_err(|source| FileError::at("create parent directory", parent, source))?;
122        let staged = AtomicTempFile::create_in(parent)?;
123        Ok(Self {
124            destination: destination.to_path_buf(),
125            staged: Some(staged),
126            file_sync,
127        })
128    }
129
130    pub fn path(&self) -> &Path {
131        &self
132            .staged
133            .as_ref()
134            .expect("atomic stage is unpublished")
135            .path
136    }
137
138    pub fn destination(&self) -> &Path {
139        &self.destination
140    }
141
142    /// Create another unpublished stage governed by the same durability policy.
143    pub async fn stage_peer(&self, destination: &Path) -> Result<Self, FileError> {
144        Self::create_with_file_sync(destination, self.file_sync.clone()).await
145    }
146
147    pub async fn read_bytes(&self) -> Result<Vec<u8>, FileError> {
148        tokio::fs::read(self.path())
149            .await
150            .map_err(|source| FileError::at("read staged blob", self.path(), source))
151    }
152
153    /// Hand the reserved path to a writer that performs its own atomic
154    /// replacement. The retained descriptor is closed first so publication
155    /// always names the replacement inode.
156    pub fn path_for_atomic_replacement(&mut self) -> &Path {
157        self.staged
158            .as_mut()
159            .expect("atomic stage is unpublished")
160            .close();
161        self.path()
162    }
163
164    pub async fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), FileError> {
165        use tokio::io::{AsyncSeekExt, AsyncWriteExt};
166
167        let staged = self.staged.as_mut().expect("atomic stage is unpublished");
168        let path = staged.path.clone();
169        let file = staged.file_mut();
170        file.set_len(0)
171            .await
172            .map_err(|source| FileError::at("truncate staged blob", &path, source))?;
173        file.seek(std::io::SeekFrom::Start(0))
174            .await
175            .map_err(|source| FileError::at("seek staged blob", &path, source))?;
176        file.write_all(bytes)
177            .await
178            .map_err(|source| FileError::at("write staged blob", &path, source))?;
179        // Finish Tokio's queued writes before this stage can be inspected or
180        // published. The owning durability policy separately decides whether
181        // the completed file also needs a physical barrier.
182        self.file_sync
183            .finish_async_write(file)
184            .await
185            .map_err(|source| FileError::at("finish staged blob write", path, source))
186    }
187
188    /// Fill this unpublished stage from a plaintext stream and apply its file
189    /// durability barrier. The caller verifies higher-level content facts
190    /// before publishing the stage.
191    pub async fn write_plaintext<R: PlaintextChunkReader>(
192        &mut self,
193        source: &mut R,
194    ) -> Result<u64, StreamWriteError<R::Error>> {
195        use tokio::io::{AsyncSeekExt, AsyncWriteExt};
196
197        let staged = self.staged.as_mut().expect("atomic stage is unpublished");
198        let path = staged.path.clone();
199        let file = staged.file_mut();
200        file.set_len(0).await.map_err(|source| {
201            StreamWriteError::Local(FileError::at("truncate staged blob", &path, source))
202        })?;
203        file.seek(std::io::SeekFrom::Start(0))
204            .await
205            .map_err(|source| {
206                StreamWriteError::Local(FileError::at("seek staged blob", &path, source))
207            })?;
208        let mut written = 0u64;
209        loop {
210            let chunk = source
211                .next_chunk(1 << 20)
212                .await
213                .map_err(StreamWriteError::Source)?;
214            if chunk.is_empty() {
215                break;
216            }
217            file.write_all(&chunk).await.map_err(|source| {
218                StreamWriteError::Local(FileError::at("write staged blob", &path, source))
219            })?;
220            written += chunk.len() as u64;
221        }
222        // Finish Tokio's queued writes before the caller verifies this stage.
223        // The owning durability policy separately decides whether the
224        // completed file also needs a physical barrier.
225        self.file_sync
226            .finish_async_write(file)
227            .await
228            .map_err(|source| {
229                StreamWriteError::Local(FileError::at("finish staged blob write", path, source))
230            })?;
231        Ok(written)
232    }
233
234    pub async fn write_byte_stream<E: Send>(
235        mut self,
236        mut stream: Pin<Box<dyn Stream<Item = Result<Bytes, E>> + Send>>,
237    ) -> Result<(Self, u64), ByteStreamWriteError<E>> {
238        use tokio::io::{AsyncSeekExt, AsyncWriteExt};
239
240        let write = async {
241            let staged = self.staged.as_mut().expect("atomic stage is unpublished");
242            let path = staged.path.clone();
243            let file = staged.file_mut();
244            file.set_len(0).await.map_err(|source| {
245                AtomicChunkWriteError::Local(FileError::at("truncate staged blob", &path, source))
246            })?;
247            file.seek(std::io::SeekFrom::Start(0))
248                .await
249                .map_err(|source| {
250                    AtomicChunkWriteError::Local(FileError::at("seek staged blob", &path, source))
251                })?;
252            let mut written = 0u64;
253            while let Some(chunk) = stream
254                .next()
255                .await
256                .transpose()
257                .map_err(AtomicChunkWriteError::Source)?
258            {
259                file.write_all(&chunk).await.map_err(|source| {
260                    AtomicChunkWriteError::Local(FileError::at("write staged blob", &path, source))
261                })?;
262                written += chunk.len() as u64;
263            }
264            // Finish Tokio's queued writes before this stage can be inspected
265            // or published. The owning durability policy separately decides
266            // whether the completed file also needs a physical barrier.
267            self.file_sync
268                .finish_async_write(file)
269                .await
270                .map_err(|source| {
271                    AtomicChunkWriteError::Local(FileError::at(
272                        "finish staged blob write",
273                        path,
274                        source,
275                    ))
276                })?;
277            Ok(written)
278        }
279        .await;
280        match write {
281            Ok(written) => Ok((self, written)),
282            Err(AtomicChunkWriteError::Source(source)) => match self.take_stage().cleanup().await {
283                Ok(()) => Err(ByteStreamWriteError::Source(source)),
284                Err(cleanup) => Err(ByteStreamWriteError::SourceCleanup { source, cleanup }),
285            },
286            Err(AtomicChunkWriteError::Local(operation)) => {
287                let error = self
288                    .take_stage()
289                    .fail::<()>(operation)
290                    .await
291                    .expect_err("failed staged write returns an error");
292                Err(ByteStreamWriteError::Local(error))
293            }
294        }
295    }
296
297    /// Fill this unpublished stage from one opened source file while computing
298    /// the exact identity of the copied bytes from that same descriptor.
299    pub async fn copy_from(self, source: &Path) -> Result<(Self, u64, [u8; 32]), FileError> {
300        let input = tokio::fs::File::open(source)
301            .await
302            .map_err(|error| FileError::at("open copy source", source, error))?;
303        self.write_open_file_with_facts(input, source).await
304    }
305
306    async fn write_open_file_with_facts(
307        mut self,
308        mut input: tokio::fs::File,
309        source: &Path,
310    ) -> Result<(Self, u64, [u8; 32]), FileError> {
311        use sha2::{Digest, Sha256};
312        use tokio::io::AsyncWriteExt;
313
314        let copy = async {
315            let staged = self.staged.as_mut().expect("atomic stage is unpublished");
316            let mut buffer = vec![0u8; 1 << 20];
317            let mut size = 0_u64;
318            let mut hasher = Sha256::new();
319            loop {
320                let read = input
321                    .read(&mut buffer)
322                    .await
323                    .map_err(|error| FileError::at("read copy source", source, error))?;
324                if read == 0 {
325                    break;
326                }
327                size = size
328                    .checked_add(read as u64)
329                    .ok_or_else(|| FileError::SizeOverflow {
330                        subject: "copy source",
331                        path: source.to_path_buf(),
332                    })?;
333                hasher.update(&buffer[..read]);
334                staged
335                    .file_mut()
336                    .write_all(&buffer[..read])
337                    .await
338                    .map_err(|error| FileError::at("write copy stage", &staged.path, error))?;
339            }
340            // Finish Tokio's queued writes before returning the hash and size.
341            // The owning durability policy separately decides whether the
342            // completed file also needs a physical barrier.
343            self.file_sync
344                .finish_async_write(staged.file_mut())
345                .await
346                .map_err(|error| FileError::at("finish copy stage", &staged.path, error))?;
347            Ok::<_, FileError>((size, hasher.finalize().into()))
348        }
349        .await;
350        match copy {
351            Ok((size, hash)) => Ok((self, size, hash)),
352            Err(operation) => self.take_stage().fail(operation).await,
353        }
354    }
355
356    pub async fn commit(self) -> Result<(), FileError> {
357        let file_sync = self.file_sync.clone();
358        self.commit_with_sync(|path| {
359            let path = path.to_path_buf();
360            async move { file_sync.sync_parent(&path).await }
361        })
362        .await
363    }
364
365    async fn commit_with_sync<F, Fut>(mut self, sync_committed_parent: F) -> Result<(), FileError>
366    where
367        F: FnOnce(&Path) -> Fut,
368        Fut: std::future::Future<Output = Result<(), FileError>>,
369    {
370        let mut staged = self.take_stage();
371        staged.close();
372        let result = async {
373            tokio::fs::rename(&staged.path, &self.destination)
374                .await
375                .map_err(|source| {
376                    FileError::between(
377                        "rename verified blob",
378                        &staged.path,
379                        &self.destination,
380                        source,
381                    )
382                })?;
383            if let Err(operation) = sync_committed_parent(&self.destination).await {
384                if let Err(source) = tokio::fs::rename(&self.destination, &staged.path).await {
385                    return Err(FileError::rollback(
386                        operation,
387                        FileError::between(
388                            "rollback verified blob rename",
389                            &self.destination,
390                            &staged.path,
391                            source,
392                        ),
393                    ));
394                }
395                if let Err(rollback) = self.file_sync.sync_parent(&staged.path).await {
396                    return Err(FileError::rollback(operation, rollback));
397                }
398                return Err(operation);
399            }
400            Ok(())
401        }
402        .await;
403        match result {
404            Ok(()) => {
405                staged.disarm();
406                Ok(())
407            }
408            Err(operation) => staged.fail(operation).await,
409        }
410    }
411
412    /// Publish a verified user-owned destination without replacing an existing
413    /// path. The staged file is a sibling, so the no-clobber rename exposes the
414    /// complete file atomically and fails if another file already owns the name.
415    pub async fn commit_new(self) -> Result<(), CommitNewFileError> {
416        let file_sync = self.file_sync.clone();
417        self.commit_new_with_sync(|path| {
418            let path = path.to_path_buf();
419            let file_sync = file_sync.clone();
420            async move { file_sync.sync_parent(&path).await }
421        })
422        .await
423    }
424
425    async fn commit_new_with_sync<F, Fut>(
426        mut self,
427        mut sync_committed_parent: F,
428    ) -> Result<(), CommitNewFileError>
429    where
430        F: FnMut(&Path) -> Fut,
431        Fut: std::future::Future<Output = Result<(), FileError>>,
432    {
433        let mut staged = self.take_stage();
434        staged.close();
435        match staged.rename_noreplace(&self.destination) {
436            Ok(()) => {}
437            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
438                let operation = CommitNewFileError::DestinationExists(self.destination.clone());
439                return match staged.cleanup().await {
440                    Ok(()) => Err(operation),
441                    Err(cleanup) => Err(CommitNewFileError::rollback(operation, cleanup)),
442                };
443            }
444            Err(source) => {
445                let operation = FileError::between(
446                    "rename verified blob without replacement",
447                    &staged.path,
448                    &self.destination,
449                    source,
450                );
451                return match staged.cleanup().await {
452                    Ok(()) => Err(operation.into()),
453                    Err(cleanup) => Err(CommitNewFileError::rollback(operation.into(), cleanup)),
454                };
455            }
456        }
457
458        if let Err(operation) = sync_committed_parent(&self.destination).await {
459            return match self.rollback_new_destination().await {
460                Ok(()) => Err(operation.into()),
461                Err(rollback) => Err(CommitNewFileError::rollback(operation.into(), rollback)),
462            };
463        }
464        Ok(())
465    }
466
467    pub async fn discard(mut self) -> Result<(), FileError> {
468        self.take_stage().cleanup().await
469    }
470
471    pub fn discard_blocking(mut self) -> Result<(), FileError> {
472        self.take_stage().cleanup_blocking()
473    }
474
475    pub fn publish_for_transaction(mut self) -> Result<PublishedAtomicFile, FileError> {
476        let staged = self.take_stage();
477        staged.publish_blocking(&self.destination, &self.file_sync)?;
478        Ok(PublishedAtomicFile {
479            destination: self.destination.clone(),
480            file_sync: self.file_sync.clone(),
481        })
482    }
483
484    fn take_stage(&mut self) -> AtomicTempFile {
485        self.staged.take().expect("atomic stage is unpublished")
486    }
487
488    async fn rollback_new_destination(&self) -> Result<(), FileError> {
489        tokio::fs::remove_file(&self.destination)
490            .await
491            .map_err(|source| FileError::at("remove new destination", &self.destination, source))?;
492        self.file_sync.sync_parent(&self.destination).await
493    }
494
495    #[cfg(any(test, feature = "test-utils"))]
496    pub async fn write_for_test(destination: &Path, bytes: &[u8]) -> Result<(), FileError> {
497        let mut staged = Self::create_with_file_sync(destination, FileSync::Disabled).await?;
498        staged.write_bytes(bytes).await?;
499        staged.commit().await
500    }
501
502    #[cfg(any(test, feature = "test-utils"))]
503    pub fn leave_unpublished_for_test(mut self) -> PathBuf {
504        let mut staged = self.take_stage();
505        let path = staged.path.clone();
506        staged.disarm();
507        path
508    }
509}
510
511impl Drop for AtomicStagedFile {
512    fn drop(&mut self) {
513        // `AtomicTempFile` owns cancellation cleanup. Taking it explicitly is
514        // reserved for commit, transaction publication, and reported discard.
515        self.staged.take();
516    }
517}
518
519impl PublishedAtomicFile {
520    pub fn rollback(self) -> Result<(), FileError> {
521        match std::fs::remove_file(&self.destination) {
522            Ok(()) => {}
523            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
524            Err(source) => {
525                return Err(FileError::at(
526                    "remove published file",
527                    &self.destination,
528                    source,
529                ));
530            }
531        }
532        self.file_sync.sync_parent_blocking(&self.destination)
533    }
534}
535
536impl AtomicTempFile {
537    fn create_in(parent: &Path) -> Result<Self, FileError> {
538        let named = tempfile::Builder::new()
539            .prefix(TEMP_BLOB_PREFIX)
540            .tempfile_in(parent)
541            .map_err(|source| FileError::at("create temporary blob", parent, source))?;
542        let (file, path) = named.into_parts();
543        let path = path
544            .keep()
545            .map_err(|source| FileError::at("retain temporary blob path", parent, source.error))?;
546        Ok(Self {
547            path,
548            file: Some(tokio::fs::File::from_std(file)),
549            armed: true,
550        })
551    }
552
553    fn file_mut(&mut self) -> &mut tokio::fs::File {
554        self.file.as_mut().expect("atomic temp file is open")
555    }
556
557    fn close(&mut self) {
558        self.file.take();
559    }
560
561    #[cfg(any(
562        target_os = "android",
563        target_os = "linux",
564        target_os = "macos",
565        target_os = "ios",
566        target_os = "tvos",
567        target_os = "visionos",
568        target_os = "watchos",
569        target_os = "redox",
570    ))]
571    fn rename_noreplace(&mut self, destination: &Path) -> Result<(), std::io::Error> {
572        use rustix::fs::{renameat_with, RenameFlags, CWD};
573
574        self.close();
575        renameat_with(CWD, &self.path, CWD, destination, RenameFlags::NOREPLACE)?;
576        self.armed = false;
577        Ok(())
578    }
579
580    #[cfg(target_os = "windows")]
581    fn rename_noreplace(&mut self, destination: &Path) -> Result<(), std::io::Error> {
582        self.close();
583        let path = tempfile::TempPath::try_from_path(self.path.clone())?;
584        match path.persist_noclobber(destination) {
585            Ok(()) => {
586                self.armed = false;
587                Ok(())
588            }
589            Err(error) => {
590                let source = error.error;
591                // `AtomicTempFile` still owns cleanup after a failed rename.
592                // Disarm the temporary guard constructed only to perform the
593                // platform's no-clobber rename.
594                let mut path = error.path;
595                path.disable_cleanup(true);
596                Err(source)
597            }
598        }
599    }
600
601    fn disarm(&mut self) {
602        self.close();
603        self.armed = false;
604    }
605
606    async fn cleanup(mut self) -> Result<(), FileError> {
607        self.close();
608        let cleanup = match tokio::fs::remove_file(&self.path).await {
609            Ok(()) => Ok(()),
610            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
611            Err(source) => Err(FileError::at("remove temporary blob", &self.path, source)),
612        };
613        self.armed = false;
614        cleanup
615    }
616
617    fn cleanup_blocking(mut self) -> Result<(), FileError> {
618        self.close();
619        let cleanup = match std::fs::remove_file(&self.path) {
620            Ok(()) => Ok(()),
621            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
622            Err(source) => Err(FileError::at("remove temporary blob", &self.path, source)),
623        };
624        self.armed = false;
625        cleanup
626    }
627
628    async fn fail<T>(self, operation: FileError) -> Result<T, FileError> {
629        match self.cleanup().await {
630            Ok(()) => Err(operation),
631            Err(cleanup) => Err(FileError::rollback(operation, cleanup)),
632        }
633    }
634
635    fn publish_blocking(
636        mut self,
637        destination: &Path,
638        file_sync: &FileSync,
639    ) -> Result<(), FileError> {
640        self.close();
641        let operation = match std::fs::rename(&self.path, destination) {
642            Ok(()) => match file_sync.sync_parent_blocking(destination) {
643                Ok(()) => {
644                    self.armed = false;
645                    return Ok(());
646                }
647                Err(operation) => {
648                    let rollback = std::fs::rename(destination, &self.path)
649                        .map_err(|source| {
650                            FileError::between(
651                                "rollback published file rename",
652                                destination,
653                                &self.path,
654                                source,
655                            )
656                        })
657                        .and_then(|()| file_sync.sync_parent_blocking(&self.path));
658                    match rollback {
659                        Ok(()) => operation,
660                        Err(rollback) => FileError::rollback(operation, rollback),
661                    }
662                }
663            },
664            Err(source) => {
665                FileError::between("rename temporary blob", &self.path, destination, source)
666            }
667        };
668        match self.cleanup_blocking() {
669            Ok(()) => Err(operation),
670            Err(cleanup) => Err(FileError::rollback(operation, cleanup)),
671        }
672    }
673}
674
675impl Drop for AtomicTempFile {
676    fn drop(&mut self) {
677        self.file.take();
678        if !self.armed {
679            return;
680        }
681        match std::fs::remove_file(&self.path) {
682            Ok(()) => {}
683            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
684            Err(error) => {
685                tracing::warn!(
686                    path = %self.path.display(),
687                    %error,
688                    "could not remove canceled atomic-write temp blob"
689                );
690            }
691        }
692    }
693}
694
695/// Size and SHA-256 digest of the file at `path`, streamed. The one
696/// filesystem primitive for computing a file's identity facts; callers hold
697/// the protocol reference and compare.
698pub async fn file_facts(path: &Path) -> Result<(u64, [u8; 32]), FileError> {
699    let (_, size, digest) =
700        read_selected_with_facts(path, ExactReadSelection::IdentityOnly).await?;
701    Ok((size, digest))
702}
703
704/// Size and SHA-256 digest of the file at `path`, reporting cumulative bytes
705/// consumed after each read.
706pub async fn file_facts_with_progress(
707    path: &Path,
708    progress: impl Fn(u64) + Send + Sync,
709) -> Result<(u64, [u8; 32]), FileError> {
710    let (_, size, digest) =
711        read_selected_with_facts_and_progress(path, ExactReadSelection::IdentityOnly, &progress)
712            .await?;
713    Ok((size, digest))
714}
715
716pub async fn file_len(path: &Path) -> Result<u64, FileError> {
717    tokio::fs::metadata(path)
718        .await
719        .map(|metadata| metadata.len())
720        .map_err(|source| FileError::at("stat local blob", path, source))
721}
722
723#[derive(Clone, Copy)]
724enum ExactReadSelection {
725    IdentityOnly,
726    #[cfg(test)]
727    Whole,
728}
729
730async fn read_selected_with_facts(
731    path: &Path,
732    selection: ExactReadSelection,
733) -> Result<(Vec<u8>, u64, [u8; 32]), FileError> {
734    read_selected_with_facts_and_progress(path, selection, &|_| {}).await
735}
736
737async fn read_selected_with_facts_and_progress(
738    path: &Path,
739    selection: ExactReadSelection,
740    progress: &(dyn Fn(u64) + Sync),
741) -> Result<(Vec<u8>, u64, [u8; 32]), FileError> {
742    let mut file = tokio::fs::File::open(path)
743        .await
744        .map_err(|source| FileError::at("open exact file", path, source))?;
745    read_open_file_with_facts_and_progress(&mut file, path, selection, progress).await
746}
747
748#[cfg(test)]
749async fn read_open_file_with_facts(
750    file: &mut tokio::fs::File,
751    path: &Path,
752    selection: ExactReadSelection,
753) -> Result<(Vec<u8>, u64, [u8; 32]), FileError> {
754    read_open_file_with_facts_and_progress(file, path, selection, &|_| {}).await
755}
756
757async fn read_open_file_with_facts_and_progress(
758    file: &mut tokio::fs::File,
759    path: &Path,
760    selection: ExactReadSelection,
761    progress: &(dyn Fn(u64) + Sync),
762) -> Result<(Vec<u8>, u64, [u8; 32]), FileError> {
763    use sha2::{Digest, Sha256};
764
765    #[cfg(test)]
766    let mut selected = Vec::new();
767    #[cfg(not(test))]
768    let selected = Vec::new();
769    let mut size = 0_u64;
770    let mut hasher = Sha256::new();
771    let mut buffer = vec![0_u8; 1 << 20];
772    loop {
773        let read = file
774            .read(&mut buffer)
775            .await
776            .map_err(|source| FileError::at("read exact file", path, source))?;
777        if read == 0 {
778            break;
779        }
780        size = size
781            .checked_add(read as u64)
782            .ok_or_else(|| FileError::SizeOverflow {
783                subject: "exact file",
784                path: path.to_path_buf(),
785            })?;
786        hasher.update(&buffer[..read]);
787        progress(size);
788        match selection {
789            ExactReadSelection::IdentityOnly => {}
790            #[cfg(test)]
791            ExactReadSelection::Whole => selected.extend_from_slice(&buffer[..read]),
792        }
793    }
794    Ok((selected, size, hasher.finalize().into()))
795}
796
797/// One open file handle serving positioned reads of a local plaintext file.
798///
799/// Opening reads no content: a local file's current bytes are the answer to a
800/// read of it, and the one place a blob's bytes are checked against the hash its
801/// row declares is publication, where they become canonical synced content.
802/// A read here is a read.
803///
804/// The handle is held for the reader's life rather than reopened per range, and
805/// that is a property, not an optimization: a path can be replaced between two
806/// reads, a descriptor cannot, so every range comes from the one file that was
807/// opened even if it is later evicted, renamed, or replaced.
808///
809/// Each read positions the descriptor itself, and the mutex makes that seek and
810/// its read one operation, so concurrent readers of one handle cannot interleave
811/// into each other's ranges.
812pub struct OpenFile {
813    file: tokio::sync::Mutex<tokio::fs::File>,
814    path: PathBuf,
815    size: u64,
816}
817
818impl OpenFile {
819    /// Open `path` and stat it for the length its reads are bounded by.
820    pub async fn open(path: &Path) -> Result<Self, FileError> {
821        let file = tokio::fs::File::open(path)
822            .await
823            .map_err(|source| FileError::at("open local file", path, source))?;
824        let size = file
825            .metadata()
826            .await
827            .map_err(|source| FileError::at("stat local file", path, source))?
828            .len();
829        Ok(Self {
830            file: tokio::sync::Mutex::new(file),
831            path: path.to_path_buf(),
832            size,
833        })
834    }
835
836    pub fn size(&self) -> u64 {
837        self.size
838    }
839
840    /// Read exactly `len` bytes at `offset`. The caller bounds the range against
841    /// [`size`](Self::size); a file that cannot supply them is an error, never a
842    /// short result.
843    pub async fn read_at(&self, offset: u64, len: u64) -> Result<Vec<u8>, FileError> {
844        use tokio::io::AsyncSeekExt;
845
846        if len == 0 {
847            return Ok(Vec::new());
848        }
849        let mut buffer =
850            vec![0_u8; usize::try_from(len).map_err(|_| FileError::RangeTooLarge { len })?];
851        let mut file = self.file.lock().await;
852        file.seek(std::io::SeekFrom::Start(offset))
853            .await
854            .map_err(|source| FileError::at("seek local file range", &self.path, source))?;
855        file.read_exact(&mut buffer)
856            .await
857            .map_err(|source| FileError::at("read local file range", &self.path, source))?;
858        Ok(buffer)
859    }
860}
861
862#[cfg(test)]
863pub(crate) async fn read(path: &Path) -> Result<Vec<u8>, FileError> {
864    tokio::fs::read(path)
865        .await
866        .map_err(|source| FileError::at("read local blob", path, source))
867}
868
869#[cfg(test)]
870pub(crate) async fn exists(path: &Path) -> Result<bool, FileError> {
871    tokio::fs::try_exists(path)
872        .await
873        .map_err(|source| FileError::at("check local blob", path, source))
874}
875
876#[cfg(test)]
877pub(crate) async fn rename(from: &Path, to: &Path) -> Result<(), FileError> {
878    tokio::fs::rename(from, to)
879        .await
880        .map_err(|source| FileError::between("rename file", from, to, source))
881}
882
883#[cfg(test)]
884pub(crate) async fn remove_file(path: &Path) -> Result<bool, FileError> {
885    match tokio::fs::remove_file(path).await {
886        Ok(()) => Ok(true),
887        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
888        Err(source) => Err(FileError::at("remove file", path, source)),
889    }
890}
891
892#[cfg(test)]
893#[path = "local_file_tests.rs"]
894mod tests;