Skip to main content

reifydb_runtime/io/fs/
host.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	fs,
6	fs::{File, OpenOptions},
7	os::unix::fs::FileExt,
8	path::{Path, PathBuf},
9};
10
11use crate::io::fs::{
12	Create, Filesystem, FsError, Len, Mkdir, Open, OpenMut, Pread, Pwrite, ReadDir, Rename, Result, SyncData,
13	SyncDir, Truncate, Unlink,
14};
15
16#[derive(Debug, Clone, Default)]
17pub struct HostFs;
18
19impl HostFs {
20	pub fn new() -> Self {
21		Self
22	}
23}
24
25#[derive(Debug)]
26pub struct HostFile {
27	path: PathBuf,
28	file: File,
29}
30
31#[derive(Debug)]
32pub struct HostFileMut {
33	path: PathBuf,
34	file: File,
35}
36
37impl Filesystem for HostFs {
38	type File = HostFile;
39	type FileMut = HostFileMut;
40}
41
42impl Mkdir for HostFs {
43	fn mkdir(&self, path: &Path) -> Result<()> {
44		fs::create_dir(path).map_err(|err| FsError::from_io(path, err))
45	}
46}
47
48impl Create for HostFs {
49	fn create(&self, path: &Path, len: u64) -> Result<HostFileMut> {
50		let file = OpenOptions::new()
51			.create_new(true)
52			.read(true)
53			.write(true)
54			.open(path)
55			.map_err(|err| FsError::from_io(path, err))?;
56		file.set_len(len).map_err(|err| FsError::from_io(path, err))?;
57		Ok(HostFileMut {
58			path: path.to_path_buf(),
59			file,
60		})
61	}
62}
63
64impl Open for HostFs {
65	fn open(&self, path: &Path) -> Result<HostFile> {
66		let file = OpenOptions::new().read(true).open(path).map_err(|err| FsError::from_io(path, err))?;
67		Ok(HostFile {
68			path: path.to_path_buf(),
69			file,
70		})
71	}
72}
73
74impl OpenMut for HostFs {
75	fn open_mut(&self, path: &Path) -> Result<HostFileMut> {
76		let file = OpenOptions::new()
77			.read(true)
78			.write(true)
79			.open(path)
80			.map_err(|err| FsError::from_io(path, err))?;
81		Ok(HostFileMut {
82			path: path.to_path_buf(),
83			file,
84		})
85	}
86}
87
88impl ReadDir for HostFs {
89	fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>> {
90		let mut entries = Vec::new();
91		for entry in fs::read_dir(path).map_err(|err| FsError::from_io(path, err))? {
92			let entry = entry.map_err(|err| FsError::from_io(path, err))?;
93			entries.push(entry.path());
94		}
95		entries.sort();
96		Ok(entries)
97	}
98}
99
100impl Rename for HostFs {
101	fn rename(&self, from: &Path, to: &Path) -> Result<()> {
102		fs::rename(from, to).map_err(|err| FsError::from_io(from, err))
103	}
104}
105
106impl Unlink for HostFs {
107	fn unlink(&self, path: &Path) -> Result<()> {
108		fs::remove_file(path).map_err(|err| FsError::from_io(path, err))
109	}
110}
111
112impl SyncDir for HostFs {
113	fn sync_dir(&self, path: &Path) -> Result<()> {
114		File::open(path).and_then(|dir| dir.sync_all()).map_err(|err| FsError::from_io(path, err))
115	}
116}
117
118impl Pread for HostFile {
119	fn pread(&self, offset: u64, buf: &mut [u8]) -> Result<usize> {
120		self.file.read_at(buf, offset).map_err(|err| FsError::from_io(&self.path, err))
121	}
122}
123
124impl Len for HostFile {
125	fn len(&self) -> Result<u64> {
126		self.file.metadata().map(|meta| meta.len()).map_err(|err| FsError::from_io(&self.path, err))
127	}
128}
129
130impl Pread for HostFileMut {
131	fn pread(&self, offset: u64, buf: &mut [u8]) -> Result<usize> {
132		self.file.read_at(buf, offset).map_err(|err| FsError::from_io(&self.path, err))
133	}
134}
135
136impl Pwrite for HostFileMut {
137	fn pwrite(&self, offset: u64, buf: &[u8]) -> Result<usize> {
138		self.file.write_at(buf, offset).map_err(|err| FsError::from_io(&self.path, err))
139	}
140}
141
142impl SyncData for HostFileMut {
143	fn sync_data(&self) -> Result<()> {
144		self.file.sync_data().map_err(|err| FsError::from_io(&self.path, err))
145	}
146}
147
148impl Truncate for HostFileMut {
149	fn truncate(&self, len: u64) -> Result<()> {
150		self.file.set_len(len).map_err(|err| FsError::from_io(&self.path, err))
151	}
152}
153
154impl Len for HostFileMut {
155	fn len(&self) -> Result<u64> {
156		self.file.metadata().map(|meta| meta.len()).map_err(|err| FsError::from_io(&self.path, err))
157	}
158}
159
160#[cfg(test)]
161mod tests {
162	use std::{
163		env, process,
164		sync::atomic::{AtomicU64, Ordering},
165	};
166
167	use super::*;
168
169	static COUNTER: AtomicU64 = AtomicU64::new(0);
170
171	struct Scratch {
172		root: PathBuf,
173	}
174
175	impl Scratch {
176		fn new() -> Self {
177			let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
178			let root = env::temp_dir().join(format!("reifydb-host-fs-{}-{}", process::id(), seq));
179			fs::create_dir_all(&root).unwrap();
180			Self {
181				root,
182			}
183		}
184
185		fn path(&self, name: &str) -> PathBuf {
186			self.root.join(name)
187		}
188	}
189
190	impl Drop for Scratch {
191		fn drop(&mut self) {
192			let _ = fs::remove_dir_all(&self.root);
193		}
194	}
195
196	#[test]
197	fn test_create_preallocates_length() {
198		// preallocation is what lets later appends write in place without ever moving the file end
199		let scratch = Scratch::new();
200		let fs = HostFs::new();
201		let file = fs.create(&scratch.path("seg"), 4096).unwrap();
202		assert_eq!(file.len().unwrap(), 4096);
203	}
204
205	#[test]
206	fn test_pwrite_pread_roundtrip_at_offsets() {
207		// unaligned offsets must behave exactly like aligned ones, otherwise record packing corrupts data
208		let scratch = Scratch::new();
209		let fs = HostFs::new();
210		let file = fs.create(&scratch.path("seg"), 4096).unwrap();
211
212		for offset in [0u64, 512, 700] {
213			let payload = [offset as u8; 64];
214			assert_eq!(file.pwrite(offset, &payload).unwrap(), 64);
215			let mut buf = [0u8; 64];
216			assert_eq!(file.pread(offset, &mut buf).unwrap(), 64);
217			assert_eq!(buf, payload);
218		}
219
220		assert_eq!(file.len().unwrap(), 4096);
221	}
222
223	#[test]
224	fn test_pread_past_end_is_short_not_error() {
225		// recovery scans read speculatively past the tail and must see a short count, never a failure
226		let scratch = Scratch::new();
227		let fs = HostFs::new();
228		let file = fs.create(&scratch.path("seg"), 16).unwrap();
229
230		let mut buf = [0u8; 32];
231		assert_eq!(file.pread(8, &mut buf).unwrap(), 8);
232		assert_eq!(file.pread(16, &mut buf).unwrap(), 0);
233		assert_eq!(file.pread(4096, &mut buf).unwrap(), 0);
234	}
235
236	#[test]
237	fn test_sync_data_succeeds() {
238		// a durable commit is only durable if this call reaches the disk and reports success
239		let scratch = Scratch::new();
240		let fs = HostFs::new();
241		let file = fs.create(&scratch.path("seg"), 64).unwrap();
242		file.pwrite(0, b"durable").unwrap();
243		file.sync_data().unwrap();
244	}
245
246	#[test]
247	fn test_truncate_shortens_file() {
248		// truncation is how a torn tail is discarded, so the reported length must follow immediately
249		let scratch = Scratch::new();
250		let fs = HostFs::new();
251		let file = fs.create(&scratch.path("seg"), 4096).unwrap();
252		file.truncate(1000).unwrap();
253		assert_eq!(file.len().unwrap(), 1000);
254	}
255
256	#[test]
257	fn test_rename_moves_file_and_old_path_is_gone() {
258		// atomic rename is the install step, and a lingering source path would leave a duplicate segment
259		let scratch = Scratch::new();
260		let fs = HostFs::new();
261		let from = scratch.path("tmp");
262		let to = scratch.path("final");
263
264		fs.create(&from, 32).unwrap();
265		fs.rename(&from, &to).unwrap();
266
267		assert_eq!(fs.open(&to).unwrap().len().unwrap(), 32);
268		assert_eq!(fs.open(&from).unwrap_err(), FsError::NotFound(from.clone()));
269	}
270
271	#[test]
272	fn test_unlink_removes_file() {
273		// garbage collection relies on the path being unresolvable right after the call returns
274		let scratch = Scratch::new();
275		let fs = HostFs::new();
276		let path = scratch.path("seg");
277
278		fs.create(&path, 32).unwrap();
279		fs.unlink(&path).unwrap();
280
281		assert_eq!(fs.open(&path).unwrap_err(), FsError::NotFound(path.clone()));
282	}
283
284	#[test]
285	fn test_read_dir_returns_sorted_entries() {
286		// another suite diffs this listing byte for byte against a different backend, so order is part of the
287		// contract
288		let scratch = Scratch::new();
289		let fs = HostFs::new();
290
291		for name in ["c", "a", "b"] {
292			fs.create(&scratch.path(name), 0).unwrap();
293		}
294
295		let entries = fs.read_dir(&scratch.root).unwrap();
296		assert_eq!(entries, vec![scratch.path("a"), scratch.path("b"), scratch.path("c")]);
297	}
298
299	#[test]
300	fn test_mkdir_creates_and_rejects_existing() {
301		// directory creation is the exclusive claim on a store location, so a second claim must be refused
302		let scratch = Scratch::new();
303		let fs = HostFs::new();
304		let path = scratch.path("store");
305
306		fs.mkdir(&path).unwrap();
307		assert!(path.is_dir());
308		assert_eq!(fs.mkdir(&path).unwrap_err(), FsError::AlreadyExists(path.clone()));
309	}
310
311	#[test]
312	fn test_open_missing_path_is_not_found() {
313		// a missing segment must be distinguishable from a broken one, so the kind is mapped, not flattened
314		let scratch = Scratch::new();
315		let fs = HostFs::new();
316		let path = scratch.path("absent");
317
318		assert_eq!(fs.open(&path).unwrap_err(), FsError::NotFound(path.clone()));
319		assert_eq!(fs.open_mut(&path).unwrap_err(), FsError::NotFound(path.clone()));
320	}
321
322	#[test]
323	fn test_create_existing_path_is_already_exists() {
324		// create must never silently reopen and re-truncate a segment that already holds committed data
325		let scratch = Scratch::new();
326		let fs = HostFs::new();
327		let path = scratch.path("seg");
328
329		fs.create(&path, 128).unwrap();
330		assert_eq!(fs.create(&path, 128).unwrap_err(), FsError::AlreadyExists(path.clone()));
331		assert_eq!(fs.open(&path).unwrap().len().unwrap(), 128);
332	}
333
334	#[test]
335	fn test_open_reads_and_open_mut_writes_same_file() {
336		// the split handle is the whole point: reads go through a read-only descriptor, writes through a
337		// separate one
338		let scratch = Scratch::new();
339		let fs = HostFs::new();
340		let path = scratch.path("seg");
341
342		fs.create(&path, 256).unwrap();
343
344		let writer = fs.open_mut(&path).unwrap();
345		assert_eq!(writer.pwrite(64, b"payload").unwrap(), 7);
346		writer.sync_data().unwrap();
347
348		let reader = fs.open(&path).unwrap();
349		let mut buf = [0u8; 7];
350		assert_eq!(reader.pread(64, &mut buf).unwrap(), 7);
351		assert_eq!(&buf, b"payload");
352		assert_eq!(reader.len().unwrap(), 256);
353	}
354}