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, RemoveDir, Rename, Result,
13	SyncData, 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 RemoveDir for HostFs {
113	fn remove_dir(&self, path: &Path) -> Result<()> {
114		fs::remove_dir_all(path).map_err(|err| FsError::from_io(path, err))
115	}
116}
117
118impl SyncDir for HostFs {
119	fn sync_dir(&self, path: &Path) -> Result<()> {
120		File::open(path).and_then(|dir| dir.sync_all()).map_err(|err| FsError::from_io(path, err))
121	}
122}
123
124impl Pread for HostFile {
125	fn pread(&self, offset: u64, buf: &mut [u8]) -> Result<usize> {
126		self.file.read_at(buf, offset).map_err(|err| FsError::from_io(&self.path, err))
127	}
128}
129
130impl Len for HostFile {
131	fn len(&self) -> Result<u64> {
132		self.file.metadata().map(|meta| meta.len()).map_err(|err| FsError::from_io(&self.path, err))
133	}
134}
135
136impl Pread for HostFileMut {
137	fn pread(&self, offset: u64, buf: &mut [u8]) -> Result<usize> {
138		self.file.read_at(buf, offset).map_err(|err| FsError::from_io(&self.path, err))
139	}
140}
141
142impl Pwrite for HostFileMut {
143	fn pwrite(&self, offset: u64, buf: &[u8]) -> Result<usize> {
144		self.file.write_at(buf, offset).map_err(|err| FsError::from_io(&self.path, err))
145	}
146}
147
148impl SyncData for HostFileMut {
149	fn sync_data(&self) -> Result<()> {
150		self.file.sync_data().map_err(|err| FsError::from_io(&self.path, err))
151	}
152}
153
154impl Truncate for HostFileMut {
155	fn truncate(&self, len: u64) -> Result<()> {
156		self.file.set_len(len).map_err(|err| FsError::from_io(&self.path, err))
157	}
158}
159
160impl Len for HostFileMut {
161	fn len(&self) -> Result<u64> {
162		self.file.metadata().map(|meta| meta.len()).map_err(|err| FsError::from_io(&self.path, err))
163	}
164}
165
166#[cfg(test)]
167mod tests {
168	use std::{
169		env, process,
170		sync::atomic::{AtomicU64, Ordering},
171	};
172
173	use super::*;
174
175	static COUNTER: AtomicU64 = AtomicU64::new(0);
176
177	struct Scratch {
178		root: PathBuf,
179	}
180
181	impl Scratch {
182		fn new() -> Self {
183			let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
184			let root = env::temp_dir().join(format!("reifydb-host-fs-{}-{}", process::id(), seq));
185			fs::create_dir_all(&root).unwrap();
186			Self {
187				root,
188			}
189		}
190
191		fn path(&self, name: &str) -> PathBuf {
192			self.root.join(name)
193		}
194	}
195
196	impl Drop for Scratch {
197		fn drop(&mut self) {
198			let _ = fs::remove_dir_all(&self.root);
199		}
200	}
201
202	#[test]
203	fn test_create_preallocates_length() {
204		// preallocation is what lets later appends write in place without ever moving the file end
205		let scratch = Scratch::new();
206		let fs = HostFs::new();
207		let file = fs.create(&scratch.path("seg"), 4096).unwrap();
208		assert_eq!(file.len().unwrap(), 4096);
209	}
210
211	#[test]
212	fn test_pwrite_pread_roundtrip_at_offsets() {
213		// unaligned offsets must behave exactly like aligned ones, otherwise record packing corrupts data
214		let scratch = Scratch::new();
215		let fs = HostFs::new();
216		let file = fs.create(&scratch.path("seg"), 4096).unwrap();
217
218		for offset in [0u64, 512, 700] {
219			let payload = [offset as u8; 64];
220			assert_eq!(file.pwrite(offset, &payload).unwrap(), 64);
221			let mut buf = [0u8; 64];
222			assert_eq!(file.pread(offset, &mut buf).unwrap(), 64);
223			assert_eq!(buf, payload);
224		}
225
226		assert_eq!(file.len().unwrap(), 4096);
227	}
228
229	#[test]
230	fn test_pread_past_end_is_short_not_error() {
231		// recovery scans read speculatively past the tail and must see a short count, never a failure
232		let scratch = Scratch::new();
233		let fs = HostFs::new();
234		let file = fs.create(&scratch.path("seg"), 16).unwrap();
235
236		let mut buf = [0u8; 32];
237		assert_eq!(file.pread(8, &mut buf).unwrap(), 8);
238		assert_eq!(file.pread(16, &mut buf).unwrap(), 0);
239		assert_eq!(file.pread(4096, &mut buf).unwrap(), 0);
240	}
241
242	#[test]
243	fn test_sync_data_succeeds() {
244		// a durable commit is only durable if this call reaches the disk and reports success
245		let scratch = Scratch::new();
246		let fs = HostFs::new();
247		let file = fs.create(&scratch.path("seg"), 64).unwrap();
248		file.pwrite(0, b"durable").unwrap();
249		file.sync_data().unwrap();
250	}
251
252	#[test]
253	fn test_truncate_shortens_file() {
254		// truncation is how a torn tail is discarded, so the reported length must follow immediately
255		let scratch = Scratch::new();
256		let fs = HostFs::new();
257		let file = fs.create(&scratch.path("seg"), 4096).unwrap();
258		file.truncate(1000).unwrap();
259		assert_eq!(file.len().unwrap(), 1000);
260	}
261
262	#[test]
263	fn test_rename_moves_file_and_old_path_is_gone() {
264		// atomic rename is the install step, and a lingering source path would leave a duplicate segment
265		let scratch = Scratch::new();
266		let fs = HostFs::new();
267		let from = scratch.path("tmp");
268		let to = scratch.path("final");
269
270		fs.create(&from, 32).unwrap();
271		fs.rename(&from, &to).unwrap();
272
273		assert_eq!(fs.open(&to).unwrap().len().unwrap(), 32);
274		assert_eq!(fs.open(&from).unwrap_err(), FsError::NotFound(from.clone()));
275	}
276
277	#[test]
278	fn test_unlink_removes_file() {
279		// garbage collection relies on the path being unresolvable right after the call returns
280		let scratch = Scratch::new();
281		let fs = HostFs::new();
282		let path = scratch.path("seg");
283
284		fs.create(&path, 32).unwrap();
285		fs.unlink(&path).unwrap();
286
287		assert_eq!(fs.open(&path).unwrap_err(), FsError::NotFound(path.clone()));
288	}
289
290	#[test]
291	fn test_read_dir_returns_sorted_entries() {
292		// another suite diffs this listing byte for byte against a different backend, so order is part of the
293		// contract
294		let scratch = Scratch::new();
295		let fs = HostFs::new();
296
297		for name in ["c", "a", "b"] {
298			fs.create(&scratch.path(name), 0).unwrap();
299		}
300
301		let entries = fs.read_dir(&scratch.root).unwrap();
302		assert_eq!(entries, vec![scratch.path("a"), scratch.path("b"), scratch.path("c")]);
303	}
304
305	#[test]
306	fn test_mkdir_creates_and_rejects_existing() {
307		// directory creation is the exclusive claim on a store location, so a second claim must be refused
308		let scratch = Scratch::new();
309		let fs = HostFs::new();
310		let path = scratch.path("store");
311
312		fs.mkdir(&path).unwrap();
313		assert!(path.is_dir());
314		assert_eq!(fs.mkdir(&path).unwrap_err(), FsError::AlreadyExists(path.clone()));
315	}
316
317	#[test]
318	fn test_open_missing_path_is_not_found() {
319		// a missing segment must be distinguishable from a broken one, so the kind is mapped, not flattened
320		let scratch = Scratch::new();
321		let fs = HostFs::new();
322		let path = scratch.path("absent");
323
324		assert_eq!(fs.open(&path).unwrap_err(), FsError::NotFound(path.clone()));
325		assert_eq!(fs.open_mut(&path).unwrap_err(), FsError::NotFound(path.clone()));
326	}
327
328	#[test]
329	fn test_create_existing_path_is_already_exists() {
330		// create must never silently reopen and re-truncate a segment that already holds committed data
331		let scratch = Scratch::new();
332		let fs = HostFs::new();
333		let path = scratch.path("seg");
334
335		fs.create(&path, 128).unwrap();
336		assert_eq!(fs.create(&path, 128).unwrap_err(), FsError::AlreadyExists(path.clone()));
337		assert_eq!(fs.open(&path).unwrap().len().unwrap(), 128);
338	}
339
340	#[test]
341	fn test_open_reads_and_open_mut_writes_same_file() {
342		// the split handle is the whole point: reads go through a read-only descriptor, writes through a
343		// separate one
344		let scratch = Scratch::new();
345		let fs = HostFs::new();
346		let path = scratch.path("seg");
347
348		fs.create(&path, 256).unwrap();
349
350		let writer = fs.open_mut(&path).unwrap();
351		assert_eq!(writer.pwrite(64, b"payload").unwrap(), 7);
352		writer.sync_data().unwrap();
353
354		let reader = fs.open(&path).unwrap();
355		let mut buf = [0u8; 7];
356		assert_eq!(reader.pread(64, &mut buf).unwrap(), 7);
357		assert_eq!(&buf, b"payload");
358		assert_eq!(reader.len().unwrap(), 256);
359	}
360}