Skip to main content

rget/
file.rs

1//! Random-access destination file (PRD §9).
2//!
3//! Workers write straight into the final file at the right offset. There are no
4//! per-chunk temp files and no merge pass, so a 500 GB download needs 500 GB of
5//! disk, not 1 TB, and finishing costs nothing.
6//!
7//! Positioned writes (`pwrite`) are used rather than seek+write so that
8//! concurrent writers share one file descriptor without a lock and without
9//! racing on the file cursor.
10
11use std::fs::{File, OpenOptions};
12use std::io;
13use std::path::{Path, PathBuf};
14
15/// Identifies the *file*, not the path. Used on resume to detect that the file
16/// we recorded progress against has been replaced by a different one.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct FileIdentity {
19    pub dev: u64,
20    pub ino: u64,
21}
22
23pub struct DestFile {
24    file: File,
25    path: PathBuf,
26}
27
28impl DestFile {
29    /// Open (or create) the destination for random-access writing without
30    /// truncating: an existing partial file is exactly what we want to resume
31    /// into.
32    pub fn open(path: &Path) -> io::Result<Self> {
33        if let Some(parent) = path.parent() {
34            if !parent.as_os_str().is_empty() {
35                std::fs::create_dir_all(parent)?;
36            }
37        }
38        let file = OpenOptions::new()
39            .read(true)
40            .write(true)
41            .create(true)
42            .truncate(false)
43            .open(path)?;
44        Ok(Self {
45            file,
46            path: path.to_path_buf(),
47        })
48    }
49
50    /// Size the file up front. This turns "out of disk" into an error now
51    /// rather than at 90%, and gives the filesystem a chance to lay the file
52    /// out contiguously.
53    ///
54    /// `set_len` creates a sparse file on every filesystem we target; it
55    /// reserves the *size*, not necessarily the *blocks*. We accept that: the
56    /// alternative (writing zeroes over the whole range) would double the I/O
57    /// for a 500 GB download.
58    pub fn preallocate(&self, size: u64) -> io::Result<()> {
59        if self.file.metadata()?.len() != size {
60            self.file.set_len(size)?;
61        }
62        Ok(())
63    }
64
65    /// Write every byte of `buf` at `offset`. Short writes are retried, so a
66    /// successful return means the whole buffer reached the kernel.
67    pub fn write_at(&self, buf: &[u8], offset: u64) -> io::Result<()> {
68        #[cfg(unix)]
69        {
70            use std::os::unix::fs::FileExt;
71            self.file.write_all_at(buf, offset)
72        }
73        #[cfg(windows)]
74        {
75            use std::os::windows::fs::FileExt;
76            let mut written = 0;
77            while written < buf.len() {
78                let n = self
79                    .file
80                    .seek_write(&buf[written..], offset + written as u64)?;
81                if n == 0 {
82                    return Err(io::Error::new(
83                        io::ErrorKind::WriteZero,
84                        "failed to write whole buffer",
85                    ));
86                }
87                written += n;
88            }
89            Ok(())
90        }
91    }
92
93    pub fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize> {
94        #[cfg(unix)]
95        {
96            use std::os::unix::fs::FileExt;
97            self.file.read_at(buf, offset)
98        }
99        #[cfg(windows)]
100        {
101            use std::os::windows::fs::FileExt;
102            self.file.seek_read(buf, offset)
103        }
104    }
105
106    /// The durability barrier. See `docs/CRASH_CONSISTENCY.md`: this must
107    /// return before any of the bytes it covers may be recorded as complete.
108    ///
109    /// `sync_data` rather than `sync_all` — we need the data and the block map
110    /// durable, not the mtime.
111    pub fn sync_data(&self) -> io::Result<()> {
112        self.file.sync_data()
113    }
114
115    /// Named `size` rather than `len` because a file has no meaningful
116    /// `is_empty` counterpart and the pair would only mislead.
117    pub fn size(&self) -> io::Result<u64> {
118        Ok(self.file.metadata()?.len())
119    }
120
121    pub fn truncate(&self, size: u64) -> io::Result<()> {
122        self.file.set_len(size)
123    }
124
125    pub fn identity(&self) -> io::Result<FileIdentity> {
126        identity_of(&self.file)
127    }
128
129    pub fn path(&self) -> &Path {
130        &self.path
131    }
132
133    /// Reopen a fresh handle for hashing, so verification reads do not disturb
134    /// the write handle.
135    pub fn open_for_read(&self) -> io::Result<File> {
136        File::open(&self.path)
137    }
138}
139
140fn identity_of(file: &File) -> io::Result<FileIdentity> {
141    #[cfg(unix)]
142    {
143        use std::os::unix::fs::MetadataExt;
144        let m = file.metadata()?;
145        Ok(FileIdentity {
146            dev: m.dev(),
147            ino: m.ino(),
148        })
149    }
150    #[cfg(windows)]
151    {
152        // The equivalents on `Metadata` are still unstable
153        // (`windows_by_handle`), so ask the OS directly. Volume serial plus
154        // file index is Windows' answer to dev+ino.
155        use std::os::windows::io::AsRawHandle;
156        use windows_sys::Win32::Foundation::HANDLE;
157        use windows_sys::Win32::Storage::FileSystem::{
158            BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
159        };
160
161        let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
162        // Safe: the handle is owned by `file` and outlives the call, and `info`
163        // is a valid, correctly sized out-parameter.
164        let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle() as HANDLE, &mut info) };
165        if ok == 0 {
166            return Err(io::Error::last_os_error());
167        }
168        Ok(FileIdentity {
169            dev: u64::from(info.dwVolumeSerialNumber),
170            ino: (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
171        })
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn tmpdir(name: &str) -> PathBuf {
180        let dir =
181            std::env::temp_dir().join(format!("rget-file-test-{name}-{}", std::process::id()));
182        std::fs::create_dir_all(&dir).unwrap();
183        dir
184    }
185
186    #[test]
187    fn writes_at_offsets_out_of_order() {
188        let dir = tmpdir("offsets");
189        let path = dir.join("out.bin");
190        let f = DestFile::open(&path).unwrap();
191        f.preallocate(10).unwrap();
192        f.write_at(b"world", 5).unwrap();
193        f.write_at(b"hello", 0).unwrap();
194        f.sync_data().unwrap();
195        assert_eq!(std::fs::read(&path).unwrap(), b"helloworld");
196        std::fs::remove_dir_all(&dir).ok();
197    }
198
199    #[test]
200    fn opening_does_not_truncate() {
201        let dir = tmpdir("notrunc");
202        let path = dir.join("keep.bin");
203        std::fs::write(&path, b"existing").unwrap();
204        let f = DestFile::open(&path).unwrap();
205        assert_eq!(f.size().unwrap(), 8);
206        drop(f);
207        assert_eq!(std::fs::read(&path).unwrap(), b"existing");
208        std::fs::remove_dir_all(&dir).ok();
209    }
210
211    #[test]
212    fn preallocate_sets_exact_size() {
213        let dir = tmpdir("prealloc");
214        let path = dir.join("big.bin");
215        let f = DestFile::open(&path).unwrap();
216        f.preallocate(1024 * 1024).unwrap();
217        assert_eq!(f.size().unwrap(), 1024 * 1024);
218        // Idempotent.
219        f.preallocate(1024 * 1024).unwrap();
220        assert_eq!(f.size().unwrap(), 1024 * 1024);
221        std::fs::remove_dir_all(&dir).ok();
222    }
223
224    #[test]
225    fn identity_distinguishes_files() {
226        let dir = tmpdir("identity");
227        let a = DestFile::open(&dir.join("a.bin")).unwrap();
228        let b = DestFile::open(&dir.join("b.bin")).unwrap();
229
230        // Two different files never share an identity...
231        assert_ne!(a.identity().unwrap(), b.identity().unwrap());
232        // ...and reopening the same file yields the same one.
233        let a_again = DestFile::open(&dir.join("a.bin")).unwrap();
234        assert_eq!(a.identity().unwrap(), a_again.identity().unwrap());
235
236        // Note: delete-then-recreate can legitimately reuse an inode on Linux,
237        // which is why resume also checks the recorded size, not identity alone.
238        std::fs::remove_dir_all(&dir).ok();
239    }
240
241    #[test]
242    fn creates_missing_parents() {
243        let dir = tmpdir("parents");
244        let path = dir.join("a/b/c/deep.bin");
245        let f = DestFile::open(&path).unwrap();
246        f.write_at(b"x", 0).unwrap();
247        assert!(path.exists());
248        std::fs::remove_dir_all(&dir).ok();
249    }
250}