Skip to main content

objects/store/fs/
fs_io.rs

1// SPDX-License-Identifier: Apache-2.0
2#![deny(clippy::cast_possible_truncation)]
3
4//! IO helpers for FsStore.
5
6use std::{
7    collections::BTreeSet,
8    fs::File,
9    io::Read,
10    path::{Path, PathBuf},
11    sync::Mutex,
12};
13
14use bytes::Bytes;
15
16use crate::{
17    error::HeddleError,
18    fs_atomic::{
19        create_dir_all_durable, enrich_fs_error, enrich_rename_error, sync_directory, temp_path,
20    },
21    store::Result,
22};
23
24const MMAP_THRESHOLD_BYTES: u64 = 256 * 1024;
25
26pub(super) enum FileBytes {
27    Vec(Vec<u8>),
28    Mmap(memmap2::Mmap),
29}
30
31impl FileBytes {
32    pub(super) fn as_slice(&self) -> &[u8] {
33        match self {
34            FileBytes::Vec(data) => data,
35            FileBytes::Mmap(data) => data,
36        }
37    }
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub(super) enum AtomicWriteMode {
42    Durable,
43    BatchDirectorySync,
44    /// No fsync at all. Caller asserts the file is a recoverable
45    /// cache mirror — the authoritative copy lives elsewhere
46    /// (typically a pack) and re-derivation on read is correct.
47    /// On macOS APFS, `sync_data` alone is ~5 ms per call
48    /// (`F_FULLFSYNC`-class cost); skipping it cuts cache-write
49    /// throughput from ~200 writes/s to ~5500 writes/s. The price
50    /// is that a torn write after a crash could leave the cached
51    /// file with garbage bytes — readers must guard with a hash
52    /// check before trusting the content.
53    NoSync,
54}
55
56pub(super) fn write_atomic(
57    path: &Path,
58    data: &[u8],
59    mode: AtomicWriteMode,
60    pending_directory_syncs: Option<&Mutex<BTreeSet<PathBuf>>>,
61) -> Result<()> {
62    let parent = path
63        .parent()
64        .ok_or_else(|| std::io::Error::other("invalid atomic write path"))?;
65    create_dir_all_durable(parent)
66        .map_err(|e| HeddleError::Io(enrich_fs_error(parent, "creating", e)))?;
67
68    let temp_path = temp_path(path);
69    // Tag each fallible op with the verb that should appear in the
70    // user-facing message if it trips. We compute the wrapped error at
71    // the boundary so an EXDEV from `rename` gets the src+dst-aware
72    // message via `enrich_rename_error`, while a write into the temp
73    // file gets the "writing" verb against the destination path. The
74    // deferred-directory-sync lock-poison case is non-IO and gets a
75    // synthetic `io::Error::other`.
76    enum Op {
77        Write,
78        Rename,
79        SyncDir,
80    }
81    let mut failing_op = Op::Write;
82    let write_result: std::io::Result<()> = (|| {
83        // Open with explicit mode 0o644 instead of relying on the
84        // process umask. This makes loose objects byte-and-mode
85        // deterministic: clonefile on macOS preserves source mode,
86        // so a worktree materialised from a loose blob inherits
87        // 0o644 *without* an extra chmod. `repository_materialization`
88        // skips `set_file_mode` on non-executable files because of
89        // this contract — see `materialize_blob`'s comment near the
90        // `set_file_mode(dest, true)` call.
91        let mut opts = std::fs::OpenOptions::new();
92        opts.write(true).create_new(true);
93        #[cfg(unix)]
94        {
95            use std::os::unix::fs::OpenOptionsExt;
96            opts.mode(0o644);
97        }
98        let mut file = opts.open(&temp_path)?;
99        use std::io::Write as _;
100        file.write_all(data)?;
101        match mode {
102            // `Durable` is the strongest mode: data + metadata fsync
103            // before rename, then directory fsync after — so the file
104            // is fully on disk and discoverable through the parent
105            // directory before this returns.
106            AtomicWriteMode::Durable => file.sync_all()?,
107            // `BatchDirectorySync` keeps per-file content durability
108            // (so a crash mid-batch can't leave a renamed-but-empty
109            // file behind) but defers parent-directory fsyncs to
110            // `flush_snapshot_write_batch`. The trees + state file
111            // written during a snapshot rely on this mode for
112            // durability of their *contents*; the deferred dir fsync
113            // is what makes the rename observable to a fresh process.
114            // Without `sync_data` here, a crash after rename + before
115            // flush could leave a file that "exists" in the directory
116            // but whose data blocks weren't flushed — exactly the
117            // ACID violation we want to avoid for state/tree writes.
118            AtomicWriteMode::BatchDirectorySync => file.sync_data()?,
119            // Cache-mirror writes: no fsync. Caller guards reads
120            // with a hash check, so torn-write corruption is
121            // recoverable (re-promote from the authoritative copy).
122            AtomicWriteMode::NoSync => {}
123        }
124        failing_op = Op::Rename;
125        std::fs::rename(&temp_path, path)?;
126        failing_op = Op::SyncDir;
127        match mode {
128            AtomicWriteMode::Durable => sync_directory(parent)?,
129            AtomicWriteMode::BatchDirectorySync => {
130                if let Some(pending) = pending_directory_syncs {
131                    let mut dirs = pending.lock().map_err(|_| {
132                        std::io::Error::other("failed to acquire pending directory sync lock")
133                    })?;
134                    dirs.insert(parent.to_path_buf());
135                }
136            }
137            AtomicWriteMode::NoSync => {}
138        }
139        Ok(())
140    })();
141
142    if let Err(err) = write_result {
143        let _ = std::fs::remove_file(&temp_path);
144        let wrapped = match failing_op {
145            Op::Write => enrich_fs_error(path, "writing", err),
146            Op::Rename => enrich_rename_error(&temp_path, path, err),
147            Op::SyncDir => enrich_fs_error(parent, "syncing", err),
148        };
149        return Err(HeddleError::Io(wrapped));
150    }
151
152    Ok(())
153}
154
155/// Read the file's header (up to `header_len` bytes) and report its
156/// total on-disk size, without loading the body. Returns `Ok(None)`
157/// when the file is missing.
158///
159/// Used by [`crate::store::ObjectStore::blob_size`] on `FsStore` to
160/// avoid pulling whole blobs through `get_blob` just to learn their
161/// uncompressed size — the size is recorded in the compression header
162/// for compressed blobs, and equals the file length for raw blobs.
163pub(super) fn read_file_header(path: &Path, header_len: usize) -> Result<Option<(Vec<u8>, u64)>> {
164    let mut file = match File::open(path) {
165        Ok(file) => file,
166        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
167        Err(e) => return Err(e.into()),
168    };
169
170    let metadata = file.metadata()?;
171    let len = metadata.len();
172    let to_read = if len > header_len as u64 {
173        header_len
174    } else {
175        checked_file_len_to_usize(len)?
176    };
177    let mut header = vec![0u8; to_read];
178    if to_read > 0 {
179        use std::io::Read as _;
180        file.read_exact(&mut header)?;
181    }
182    Ok(Some((header, len)))
183}
184
185/// Read a pack file as zero-copy [`Bytes`]. For packs that clear the
186/// mmap threshold, the underlying memory is the mmap'd region —
187/// every `Bytes::slice` into it is a zero-copy view. Smaller packs
188/// fall back to a heap read wrapped in `Bytes`. Public because the
189/// pack reader lives in a sibling module and needs to bypass the
190/// `pub(super)` gate on `read_file_bytes`.
191pub fn read_file_bytes_for_pack(path: &Path) -> Result<Bytes> {
192    let file = File::open(path)?;
193    let len = file.metadata()?.len();
194    if len == 0 {
195        return Ok(Bytes::new());
196    }
197    if len >= MMAP_THRESHOLD_BYTES {
198        let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
199        if mmap.len() != checked_file_len_to_usize(len)? {
200            return Err(HeddleError::InvalidObject(
201                "pack file size changed during memory mapping".to_string(),
202            ));
203        }
204        return Ok(Bytes::from_owner(mmap));
205    }
206    let mut data = Vec::with_capacity(checked_file_len_to_usize(len)?);
207    let mut reader = file;
208    reader.read_to_end(&mut data)?;
209    Ok(Bytes::from(data))
210}
211
212pub(super) fn read_file_bytes(path: &Path) -> Result<Option<FileBytes>> {
213    let file = match File::open(path) {
214        Ok(file) => file,
215        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
216        Err(e) => return Err(e.into()),
217    };
218
219    let metadata = file.metadata()?;
220    let len = metadata.len();
221    if len == 0 {
222        return Ok(Some(FileBytes::Vec(vec![])));
223    }
224    if len >= MMAP_THRESHOLD_BYTES {
225        let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
226        if mmap.len() != checked_file_len_to_usize(len)? {
227            return Err(crate::store::HeddleError::InvalidObject(
228                "file size changed during memory mapping".to_string(),
229            ));
230        }
231        return Ok(Some(FileBytes::Mmap(mmap)));
232    }
233
234    let mut data = Vec::with_capacity(checked_file_len_to_usize(len)?);
235    let mut reader = file;
236    reader.read_to_end(&mut data)?;
237    Ok(Some(FileBytes::Vec(data)))
238}
239
240fn checked_file_len_to_usize(len: u64) -> Result<usize> {
241    usize::try_from(len).map_err(|_| {
242        HeddleError::InvalidObject(format!("file length {len} exceeds platform limits"))
243    })
244}
245
246/// List all content hashes from a sharded directory structure (aa/bbcc... → aabbcc...).
247pub(super) fn list_hashes_from_dir(
248    dir: &std::path::Path,
249) -> Result<Vec<crate::object::ContentHash>> {
250    use std::fs;
251
252    use tracing::debug;
253
254    if !dir.exists() {
255        return Ok(Vec::new());
256    }
257
258    let mut hashes = Vec::new();
259    for entry in fs::read_dir(dir)? {
260        let entry = entry?;
261        let path = entry.path();
262        if path.is_dir() {
263            let prefix = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
264            if prefix.len() == 2 {
265                for sub_entry in fs::read_dir(&path)? {
266                    let sub_entry = sub_entry?;
267                    let sub_path = sub_entry.path();
268                    if let Some(name) = sub_path.file_name().and_then(|n| n.to_str()) {
269                        let full_hash = format!("{}{}", prefix, name);
270                        if let Ok(hash) = crate::object::ContentHash::from_hex(&full_hash) {
271                            hashes.push(hash);
272                        }
273                    }
274                }
275            }
276        }
277    }
278    debug!(count = hashes.len(), "Listed hashes");
279    Ok(hashes)
280}