1#![allow(clippy::unwrap_used, clippy::bad_bit_mask)]
6use std::path::Path;
7use crate::error::{FxcpError, Result};
8use std::os::unix::io::{BorrowedFd, AsRawFd};
9use nix::fcntl::{fallocate, FallocateFlags};
10use libc;
11use nix::sys::statvfs::statvfs;
12use std::fs::File;
13use crate::operations;
14use nix::unistd::{chown, Uid, Gid};
15use xattr;
16use tracing::{debug, warn};
17use std::fs::OpenOptions;
18use std::ffi::CString;
19use crate::buffer::AlignedBuffer;
20use crate::sidecar;
21use std::io::{Read, Seek, SeekFrom};
22use walkdir::WalkDir;
23use std::os::unix::fs::MetadataExt;
24use std::hash::Hasher;
25use std::collections::hash_map::DefaultHasher;
26use fxhash::FxHasher;
27const FS_COMPR_FL: u32 = 0x00000004;
28const FICLONE: u64 = crate::constants::FICLONE_IOCTL;
29
30pub fn ioctl_ficlone(src_fd: i32, dst_path: &Path) -> Result<()> {
32 let dst_file = File::create(dst_path).map_err(FxcpError::Io)?;
33 let dst_fd = dst_file.as_raw_fd();
34 let ret = unsafe {
36 libc::ioctl(dst_fd, FICLONE, src_fd)
37 };
38 if ret != 0 {
39 let err = std::io::Error::last_os_error();
40 if err.raw_os_error() == Some(libc::EINVAL) {
41 return Err(FxcpError::Io(std::io::Error::new(
42 std::io::ErrorKind::Unsupported,
43 format!("FICLONE (CoW) not supported by filesystem: {}", dst_path.to_string_lossy())
44 )));
45 }
46 return Err(FxcpError::System(nix::Error::last()));
47 }
48 Ok(())
49}
50
51pub fn check_capacity(path: &Path, threshold_mb: u64) -> bool {
53 if let Ok(s) = statvfs(path) {
54 let avail = s.blocks_available() * s.block_size();
55 let avail_inodes = s.files_available();
56 let threshold_bytes = threshold_mb * 1024 * 1024;
57
58 if avail < threshold_bytes { return false; }
59 if avail_inodes < crate::constants::MIN_INODES_THRESHOLD { return false; }
60 }
61 true
62}
63
64pub fn is_filesystem_compressed(path: &Path) -> bool {
66 if let Ok(s) = nix::sys::statfs::statfs(path) {
67 let magic = s.filesystem_type().0;
68 return magic == 0x9123683E || magic == 0xF2F52010;
69 }
70 false
71}
72
73pub fn preallocate(fd: i32, size: u64) {
75 if size > 0 {
76 let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) };
78 let _ = fallocate(borrowed_fd, FallocateFlags::FALLOC_FL_KEEP_SIZE, 0, size as i64);
79 }
80}
81
82pub fn enable_compression(fd: i32) -> Result<()> {
84 let flags: u32 = FS_COMPR_FL;
85 let ret = unsafe { libc::ioctl(fd, 0x40086602, &flags) };
87 if ret != 0 { return Err(FxcpError::System(nix::Error::last())); }
88 Ok(())
89}
90
91pub fn enable_f2fs_pinning(fd: i32) -> Result<()> {
93 let pin: u32 = 1;
94 let ret = unsafe { libc::ioctl(fd, 0xF50D, &pin) };
96 if ret != 0 { return Err(FxcpError::System(nix::Error::last())); }
97 Ok(())
98}
99
100pub fn set_project_id(fd: i32, projid: u32) -> Result<()> {
102 if projid == 0 { return Ok(()); }
103
104 #[repr(C)]
105 #[derive(Default)]
106 struct FsxAttr {
107 fsx_xflags: u32,
108 fsx_extsize: u32,
109 fsx_nextents: u32,
110 fsx_projid: u32,
111 fsx_cowextsize: u32,
112 fsx_pad: [u8; 8]
113 }
114
115 let mut attr: FsxAttr = Default::default();
116 attr.fsx_projid = projid;
117
118 let ret = unsafe { libc::ioctl(fd, 0x40205820, &attr) };
120 if ret != 0 { return Err(FxcpError::System(nix::Error::last())); }
121 Ok(())
122}
123
124pub fn acquire_mandatory_lock(fd: i32) -> Result<()> {
126 let lock = libc::flock { l_type: libc::F_WRLCK as i16, l_whence: libc::SEEK_SET as i16, l_start: 0, l_len: 0, l_pid: 0 };
127 if unsafe { libc::fcntl(fd, libc::F_OFD_SETLK, &lock) } < 0 {
129 return Err(FxcpError::System(nix::Error::last()));
130 }
131 Ok(())
132}
133
134pub fn acquire_read_lock(fd: i32) -> Result<()> {
136 let lock = libc::flock { l_type: libc::F_RDLCK as i16, l_whence: libc::SEEK_SET as i16, l_start: 0, l_len: 0, l_pid: 0 };
137 if unsafe { libc::fcntl(fd, libc::F_OFD_SETLK, &lock) } < 0 {
139 return Err(FxcpError::System(nix::Error::last()));
140 }
141 Ok(())
142}
143
144pub fn set_ownership(path: &Path, uid: u32, gid: u32) -> Result<()> {
146 match chown(
147 path,
148 Some(Uid::from_raw(uid)),
149 Some(Gid::from_raw(gid)),
150 ) {
151 Ok(_) => Ok(()),
152 Err(e) => Err(FxcpError::System(e))
153 }
154}
155
156pub fn copy_timestamps(src: &Path, dst: &Path) -> Result<()> {
158 let meta = std::fs::metadata(src).map_err(FxcpError::Io)?;
159 let atime = libc::timespec { tv_sec: meta.atime(), tv_nsec: meta.atime_nsec() };
160 let mtime = libc::timespec { tv_sec: meta.mtime(), tv_nsec: meta.mtime_nsec() };
161 let times = [atime, mtime];
162
163 let c_path = CString::new(dst.to_string_lossy().as_bytes()).map_err(|_| FxcpError::Config("Invalid path".into()))?;
164
165 let ret = unsafe {
168 libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0)
169 };
170
171 if ret != 0 {
172 return Err(FxcpError::Io(std::io::Error::last_os_error()));
173 }
174 Ok(())
175}
176
177pub fn sync_xattrs(src: &Path, dst: &Path) {
179 if let Ok(iter) = xattr::list(src) {
180 for name in iter {
181 if let Ok(Some(value)) = xattr::get(src, &name) {
182 let _ = xattr::set(dst, &name, &value);
183 }
184 }
185 }
186}
187
188fn write_sync_marker(dst: &Path, len: u64, mtime: i64, mtime_nsec: i64) {
191 let mut buf = Vec::with_capacity(24);
192 buf.extend_from_slice(&len.to_le_bytes());
193 buf.extend_from_slice(&mtime.to_le_bytes());
194 buf.extend_from_slice(&mtime_nsec.to_le_bytes());
195 let _ = sidecar::set_metadata(dst, "sync_sig", &buf);
196}
197
198pub fn check_sync_marker(dst: &Path, src_len: u64, src_mtime: i64, src_mtime_nsec: i64) -> bool {
200 if let Some(val) = sidecar::get_metadata(dst, "sync_sig")
201 && val.len() == 24 {
202 let stored_len = u64::from_le_bytes(val[0..8].try_into().unwrap());
203 let stored_mtime = i64::from_le_bytes(val[8..16].try_into().unwrap());
204 let stored_nsec = i64::from_le_bytes(val[16..24].try_into().unwrap());
205
206 if stored_len == src_len && stored_mtime == src_mtime && stored_nsec == src_mtime_nsec {
207 return true;
208 }
209 }
210 false
211}
212
213pub fn apply_metadata(src: &Path, dst: &Path) -> Result<()> {
215 let meta = std::fs::metadata(src).map_err(FxcpError::Io)?;
216 use std::os::unix::fs::MetadataExt;
217
218 set_ownership(dst, meta.uid(), meta.gid())?;
219
220 let permissions = meta.permissions();
221 if let Err(e) = std::fs::set_permissions(dst, permissions) {
222 return Err(FxcpError::Io(e));
223 }
224
225 copy_timestamps(src, dst)?;
226
227 write_sync_marker(dst, meta.len(), meta.mtime(), meta.mtime_nsec());
229
230 Ok(())
231}
232
233pub fn is_dir_metadata_synced(src: &Path, dst: &Path) -> bool {
235 if let (Ok(sm), Ok(dm)) = (std::fs::metadata(src), std::fs::metadata(dst)) {
236 if sm.uid() != dm.uid() { return false; }
237 if sm.gid() != dm.gid() { return false; }
238 if sm.mode() != dm.mode() { return false; }
239 if sm.mtime() != dm.mtime() { return false; }
240 return true;
241 }
242 false
243}
244
245pub fn get_target_epoch(path: &Path) -> u64 {
247 if let Some(val) = sidecar::get_metadata(path, "epoch")
248 && val.len() == 8 {
249 return u64::from_le_bytes(val.try_into().unwrap_or_default());
250 }
251 0
252}
253
254pub fn get_valid_dir_hash(path: &Path) -> u64 {
256 let hash_bytes = match sidecar::get_metadata(path, "dir_hash") {
257 Some(b) if b.len() == 8 => b,
258 _ => return 0,
259 };
260 let hash = u64::from_le_bytes(hash_bytes.try_into().unwrap());
261
262 let guard_bytes = match sidecar::get_metadata(path, "dir_guard") {
263 Some(b) if b.len() == 8 => b,
264 _ => return 0,
265 };
266 let guard_mtime = i64::from_le_bytes(guard_bytes.try_into().unwrap());
267
268 if let Ok(meta) = std::fs::metadata(path)
269 && meta.mtime() == guard_mtime {
270 return hash;
271 }
272 0
273}
274
275pub fn write_dir_integrity_hash(target_dir: &Path, hash: u64) {
277 let hash_bytes = hash.to_le_bytes();
278 let _ = sidecar::set_metadata(target_dir, "dir_hash_pending", &hash_bytes);
279 if let Ok(dir_file) = File::open(target_dir) {
280 let _ = dir_file.sync_all();
281 }
282 let _ = sidecar::set_metadata(target_dir, "dir_hash", &hash_bytes);
283 if let Ok(meta) = std::fs::metadata(target_dir) {
284 let mtime_bytes = meta.mtime().to_le_bytes();
285 let _ = sidecar::set_metadata(target_dir, "dir_guard", &mtime_bytes);
286 }
287 if let Ok(dir_file) = File::open(target_dir) {
288 let _ = dir_file.sync_all();
289 }
290 let _ = sidecar::remove_metadata(target_dir, "dir_hash_pending");
291}
292
293pub fn calc_dir_integrity_hash_target(dir_path: &Path) -> Result<u64> {
295 if !dir_path.is_dir() { return Ok(0); }
296 let mut hasher = DefaultHasher::new();
297 if let Some(name) = dir_path.file_name() { hasher.write(name.to_string_lossy().as_bytes()); }
298 if let Ok(meta) = dir_path.metadata() {
299 hasher.write_u64(meta.ino());
300 hasher.write_u32(meta.mode());
301 hasher.write_u32(meta.uid());
302 hasher.write_u32(meta.gid());
303 }
304 for entry in WalkDir::new(dir_path).max_depth(1).min_depth(1) {
305 if let Ok(entry) = entry
306 && let Ok(meta) = entry.metadata()
307 && let Some(name) = entry.file_name().to_str() {
308 hasher.write(name.as_bytes());
309 hasher.write_u64(meta.len());
310 hasher.write_i64(meta.mtime());
311 hasher.write_u32(meta.mode());
312 }
313 }
314 Ok(hasher.finish())
315}
316
317pub fn check_and_lock_dir_integrity(dir_path: &Path, lock_file: &File) -> Result<u64> {
319 acquire_mandatory_lock(lock_file.as_raw_fd())?;
320 let _meta = lock_file.metadata().map_err(FxcpError::Io)?;
321 let hash_bytes = match sidecar::get_metadata(dir_path, "dir_hash") {
322 Some(b) if b.len() == 8 => b,
323 _ => return Ok(0),
324 };
325 let hash = u64::from_le_bytes(hash_bytes.try_into().unwrap());
326 let guard_bytes = match sidecar::get_metadata(dir_path, "dir_guard") {
327 Some(b) if b.len() == 8 => b,
328 _ => return Ok(0),
329 };
330 let guard_mtime = i64::from_le_bytes(guard_bytes.try_into().unwrap());
331 if let Ok(meta) = std::fs::metadata(dir_path)
332 && meta.mtime() == guard_mtime {
333 return Ok(hash);
334 }
335 Ok(0)
336}
337
338pub fn probe_xattr_support(target_root: &Path) -> bool {
340 let probe_file = target_root.join(".xfs_mirror_probe_xattr");
341 if probe_file.exists() { let _ = std::fs::remove_file(&probe_file); }
342 if let Ok(_f) = File::create(&probe_file) {
343 let key = "probe";
344 let val = b"1";
345 let res = sidecar::set_metadata(&probe_file, key, val);
346 let _ = std::fs::remove_file(&probe_file);
347 res.is_ok()
348 } else {
349 false
350 }
351}
352
353pub fn probe_direct_io(target_root: &Path) -> bool {
355 let probe_file = target_root.join(".xfs_mirror_probe_dio");
356 let buf = match AlignedBuffer::try_new(4096, 4096) {
357 Ok(b) => b,
358 Err(_) => return false
359 };
360 if buf.set_len(4096).is_err() { return false; }
361 let flags = libc::O_RDWR | libc::O_CREAT | libc::O_TRUNC | libc::O_DIRECT;
362 let path_c = match CString::new(probe_file.to_string_lossy().as_bytes()) { Ok(c) => c, Err(_) => return false };
363 let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o644) };
366 if fd < 0 { return false; }
367 let ret = unsafe { libc::write(fd, buf.ptr() as *const _, 4096) };
370 unsafe { libc::close(fd); }
372 let _ = std::fs::remove_file(&probe_file);
373 ret == 4096
374}
375
376pub fn probe_rwf_uncached(target_root: &Path) -> bool {
378 let probe_file = target_root.join(".xfs_mirror_probe_dontcache");
379 let buf = match AlignedBuffer::try_new(4096, 4096) {
380 Ok(b) => b,
381 Err(_) => return false
382 };
383 if buf.set_len(4096).is_err() { return false; }
384 let file_res = OpenOptions::new().read(true).write(true).create(true).truncate(true).open(&probe_file);
385 let sfd = match file_res.as_ref() {
386 Ok(f) => f.as_raw_fd(),
387 Err(_e) => {
388 let _ = std::fs::remove_file(probe_file);
389 return false;
390 }
391 };
392 let mut ring = match io_uring::IoUring::new(1) {
393 Ok(r) => r,
394 Err(_e) => { return false; }
395 };
396 let iov = [libc::iovec { iov_base: buf.ptr() as _, iov_len: 4096 }];
397 if unsafe { ring.submitter().register_buffers(&iov) }.is_err() {
399 let _ = ring.submitter().unregister_buffers();
400 let _ = std::fs::remove_file(&probe_file);
401 return false;
402 }
403 let r_op = io_uring::opcode::ReadFixed::new(io_uring::types::Fd(sfd), buf.ptr(), 4096, 0)
404 .offset(0)
405 .rw_flags(0x40_i32)
406 .build()
407 .user_data(1);
408 if unsafe { ring.submission().push(&r_op) }.is_err() {
410 let _ = ring.submitter().unregister_buffers();
411 let _ = std::fs::remove_file(&probe_file);
412 return false;
413 }
414 let result = match ring.submit_and_wait(1) {
415 Ok(_) => {
416 let mut cqe_success = false;
417 for cqe in ring.completion().take(1) {
418 if cqe.result() >= 0 { cqe_success = true; }
419 }
420 cqe_success
421 },
422 Err(_e) => {
423 false
424 }
425 };
426 let _ = ring.submitter().unregister_buffers();
427 let _ = std::fs::remove_file(&probe_file);
428 result
429}
430
431fn calculate_partial_hash(path: &Path) -> Result<u64> {
432 let mut file = File::open(path).map_err(FxcpError::Io)?;
433 let len = file.metadata().map_err(FxcpError::Io)?.len();
434 let mut hasher = FxHasher::default();
435 hasher.write_u64(len);
436 let mut buf = [0u8; crate::constants::CONTENT_VERIFY_SAMPLE_SIZE];
437 let n = file.read(&mut buf).map_err(FxcpError::Io)?;
438 hasher.write(&buf[..n]);
439 if len > crate::constants::CONTENT_VERIFY_TAIL_THRESHOLD as u64 {
440 file.seek(SeekFrom::End(-(crate::constants::CONTENT_VERIFY_SAMPLE_SIZE as i64))).map_err(FxcpError::Io)?;
441 let n = file.read(&mut buf).map_err(FxcpError::Io)?;
442 hasher.write(&buf[..n]);
443 }
444 Ok(hasher.finish())
445}
446
447pub fn create_version_snapshot(path: &Path, epoch_seq: u64, root_path: &Path, inode: u64) -> Result<Option<crate::versioning::FileVersion>> {
449 use std::fs;
450 let version_dir = root_path.join(".foxing_versions").join("live");
451 if let Err(e) = fs::create_dir_all(&version_dir) {
452 warn!("Versioning: Failed to create version directory {:?}: {}", version_dir, e);
453 return Ok(None);
454 }
455 if !path.exists() {
456 debug!("Versioning: Source path {:?} does not exist, skipping snapshot.", path);
457 return Ok(None);
458 }
459 let metadata = match fs::metadata(path) {
460 Ok(m) => m,
461 Err(e) => {
462 debug!("Versioning: Failed to stat {:?}: {}", path, e);
463 return Ok(None);
464 }
465 };
466 if !metadata.is_file() {
467 return Ok(None);
468 }
469 let file_size = metadata.len();
470 let mtime = metadata.mtime();
471 let actual_inode = metadata.ino();
472 if inode != 0 && actual_inode != inode {
473 warn!("Versioning: Inode mismatch for {:?}. Expected {}, got {}. Skipping snapshot.", path, inode, actual_inode);
474 return Ok(None);
475 }
476 let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
477 let snapshot_name = format!("{}_{}_{}", actual_inode, epoch_seq, timestamp);
478 let snapshot_path = version_dir.join(&snapshot_name);
479 if snapshot_path.exists() {
480 debug!("Versioning: Snapshot {:?} already exists, skipping duplicate.", snapshot_path);
481 return Ok(None);
482 }
483 let src_file = File::open(path)?;
484 let src_fd = src_file.as_raw_fd();
485 match ioctl_ficlone(src_fd, &snapshot_path) {
486 Ok(_) => {
487 debug!("Versioning: Created FFI FICLONE snapshot {:?} for epoch {}", snapshot_path, epoch_seq);
488 let content_hash = calculate_partial_hash(&snapshot_path).ok();
489 if let Some(hash) = content_hash {
490 let hash_bytes = hash.to_le_bytes();
491 let _ = sidecar::set_metadata(&snapshot_path, "content_hash", &hash_bytes);
492 }
493 crate::metrics::VERSIONING_SUCCESS.inc();
494 Ok(Some(crate::versioning::FileVersion {
495 inode: actual_inode,
496 epoch_seq,
497 timestamp,
498 path: snapshot_path,
499 size: file_size,
500 mtime,
501 content_hash,
502 }))
503 }
504 Err(FxcpError::Io(e)) if e.kind() == std::io::ErrorKind::Unsupported => {
505 warn!("Versioning: FICLONE (CoW) failed on {:?}: {}. Snapshot skipped.", path, e);
506 crate::metrics::VERSIONING_FAILURES.inc();
507 Ok(None)
508 }
509 Err(e) => {
510 warn!("Versioning: Failed to execute FICLONE ioctl for {:?}: {}", path, e);
511 crate::metrics::VERSIONING_FAILURES.inc();
512 Ok(None)
513 }
514 }
515}
516
517pub fn revert_snapshot(version_path: &Path, live_path: &Path) -> Result<()> {
519 let src = std::fs::File::open(version_path).map_err(FxcpError::Io)?;
520 let dst = std::fs::OpenOptions::new()
521 .write(true)
522 .truncate(true)
523 .open(live_path)
524 .map_err(FxcpError::Io)?;
525 let mut reader = std::io::BufReader::new(src);
526 let mut writer = std::io::BufWriter::new(dst);
527 std::io::copy(&mut reader, &mut writer).map_err(FxcpError::Io)?;
528 let perms = std::fs::metadata(version_path)
529 .map_err(FxcpError::Io)?
530 .permissions();
531 std::fs::set_permissions(live_path, perms).map_err(FxcpError::Io)?;
532 Ok(())
533}
534
535pub fn truncate_file(dst: &Path, size: u64) -> Result<()> {
537 let f = OpenOptions::new().write(true).open(dst).map_err(FxcpError::Io)?;
538 f.set_len(size).map_err(FxcpError::Io)
539}
540
541pub fn do_fallocate(dst: &Path, offset: u64, len: u64, mode: i32) -> Result<()> {
543 let f = OpenOptions::new().write(true).open(dst).map_err(FxcpError::Io)?;
544 let fd = f.as_raw_fd();
545 let ret = unsafe { libc::fallocate(fd, mode, offset as i64, len as i64) };
547 if ret < 0 { return Err(FxcpError::Io(std::io::Error::last_os_error())); }
548 Ok(())
549}
550
551pub fn create_symlink(target: &str, link: &Path) -> Result<()> {
553 std::os::unix::fs::symlink(target, link).map_err(FxcpError::Io)
554}
555
556pub fn create_hard_link(original: &Path, link: &Path) -> Result<()> {
558 std::fs::hard_link(original, link).map_err(FxcpError::Io)
559}
560
561pub fn create_mknod(dst: &Path, mode: u32, dev: u64) -> Result<()> {
563 let cpath = CString::new(dst.to_string_lossy().as_bytes()).map_err(|_| FxcpError::Config("Invalid path".into()))?;
564 let ret = unsafe { libc::mknod(cpath.as_ptr(), mode, dev) };
566 if ret < 0 { return Err(FxcpError::Io(std::io::Error::last_os_error())); }
567 Ok(())
568}
569
570pub fn set_file_attr(path: &Path, flags: u32) -> Result<()> {
572 let file = File::open(path).map_err(FxcpError::Io)?;
573 let fd = file.as_raw_fd();
574 let flags_long = flags as libc::c_long;
575 let ret = unsafe {
577 libc::ioctl(fd, operations::capabilities::FS_IOC_SETFLAGS, &flags_long)
578 };
579 if ret != 0 {
580 let err = std::io::Error::last_os_error();
581 match err.raw_os_error() {
582 Some(libc::EOPNOTSUPP) | Some(libc::ENOTTY) | Some(libc::EINVAL) => {
583 debug!("Security: FS_IOC_SETFLAGS not supported or invalid on {:?} (Error: {})", path, err);
584 Ok(())
585 },
586 _ => Err(FxcpError::Io(err))
587 }
588 } else {
589 Ok(())
590 }
591}
592
593pub fn path_within_root(path: &Path, root: &Path) -> crate::error::Result<bool> {
600 let canonical = path.canonicalize().map_err(|e| {
601 FxcpError::Security(format!("cannot canonicalize {:?}: {}", path, e))
602 })?;
603 let root_canonical = root.canonicalize().map_err(|e| {
604 FxcpError::Security(format!("cannot canonicalize root {:?}: {}", root, e))
605 })?;
606 Ok(canonical.starts_with(&root_canonical))
607}
608
609pub fn canonicalize_safe(path: &Path, root: &Path) -> crate::error::Result<std::path::PathBuf> {
612 let canonical = path.canonicalize().map_err(|e| {
613 FxcpError::Security(format!("cannot canonicalize {:?}: {}", path, e))
614 })?;
615 let root_canonical = root.canonicalize().map_err(|e| {
616 FxcpError::Security(format!("cannot canonicalize root {:?}: {}", root, e))
617 })?;
618 if !canonical.starts_with(&root_canonical) {
619 return Err(FxcpError::Security(format!(
620 "path {:?} escapes root {:?} (resolved to {:?})", path, root, canonical
621 )));
622 }
623 Ok(canonical)
624}
625
626pub fn open_beneath(
634 root: &Path,
635 relative_path: &Path,
636 flags: i32,
637 mode: u32,
638) -> crate::error::Result<std::fs::File> {
639 use nix::fcntl::{openat2, OpenHow as NixOpenHow, OFlag, ResolveFlag};
640 use nix::sys::stat::Mode;
641
642 let root_dir = std::fs::File::open(root).map_err(|e| {
644 FxcpError::Security(format!("cannot open root {:?}: {}", root, e))
645 })?;
646
647 let oflags = OFlag::from_bits_truncate(flags);
648 if oflags.bits() != flags {
649 tracing::warn!("open_beneath: unknown open flags dropped: {:#x} -> {:#x}", flags, oflags.bits());
650 }
651 let how = NixOpenHow::new()
652 .flags(oflags)
653 .mode(Mode::from_bits_truncate(mode))
654 .resolve(ResolveFlag::RESOLVE_BENEATH);
655
656 match openat2(&root_dir, relative_path, how) {
657 Ok(owned_fd) => Ok(std::fs::File::from(owned_fd)),
658 Err(nix::Error::ENOSYS) => {
659 let full_path = root.join(relative_path);
661 let safe = canonicalize_safe(&full_path, root)?;
662 let file = std::fs::OpenOptions::new()
663 .read((flags & libc::O_RDONLY) == libc::O_RDONLY || (flags & libc::O_RDWR) != 0)
664 .write((flags & libc::O_WRONLY) != 0 || (flags & libc::O_RDWR) != 0)
665 .create((flags & libc::O_CREAT) != 0)
666 .truncate((flags & libc::O_TRUNC) != 0)
667 .open(&safe)?;
668 Ok(file)
669 }
670 Err(nix::Error::EXDEV) => {
671 Err(FxcpError::Security(format!(
672 "path {:?} escapes root {:?} (RESOLVE_BENEATH rejected)", relative_path, root
673 )))
674 }
675 Err(e) => Err(FxcpError::Io(std::io::Error::from(e))),
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
682 use super::*;
683
684 #[test]
685 fn test_revert_snapshot_copies_file() {
686 let dir = tempfile::tempdir().unwrap();
687 let version_path = dir.path().join("version_file");
688 let live_path = dir.path().join("live_file");
689
690 std::fs::write(&version_path, b"snapshot-content-v42").unwrap();
691 std::fs::write(&live_path, b"old-live-content").unwrap();
692
693 revert_snapshot(&version_path, &live_path).unwrap();
694
695 let live_content = std::fs::read(&live_path).unwrap();
696 assert_eq!(live_content, b"snapshot-content-v42", "live file should have version content after revert");
697 }
698
699 #[test]
700 fn test_revert_snapshot_missing_version() {
701 let dir = tempfile::tempdir().unwrap();
702 let version_path = dir.path().join("nonexistent_version");
703 let live_path = dir.path().join("live_file");
704
705 std::fs::write(&live_path, b"live-content").unwrap();
706
707 let result = revert_snapshot(&version_path, &live_path);
708 assert!(result.is_err(), "revert_snapshot should fail when version file is missing");
709 }
710
711 #[test]
724 fn test_preserve_metadata_does_not_set_ownership() {
725 use std::os::unix::fs::MetadataExt;
726 let dir = tempfile::tempdir().unwrap();
727 let src = dir.path().join("src_file");
728 let dst = dir.path().join("dst_file");
729
730 std::fs::write(&src, b"source-content").unwrap();
731 std::fs::write(&dst, b"dest-content").unwrap();
732
733 let process_uid = unsafe { libc::getuid() };
734 let process_gid = unsafe { libc::getgid() };
735
736 let opts = crate::sync::SyncOptions::default();
737 crate::sync::preserve_metadata(&src, &dst, &opts).unwrap();
738
739 let dst_meta = std::fs::metadata(&dst).unwrap();
740
741 assert_eq!(
742 dst_meta.uid(), process_uid,
743 "preserve_metadata must NOT change ownership -- dst uid should remain process uid"
744 );
745 assert_eq!(
746 dst_meta.gid(), process_gid,
747 "preserve_metadata must NOT change ownership -- dst gid should remain process gid"
748 );
749 }
750
751 #[test]
756 #[ignore = "requires root: chown needs CAP_CHOWN"]
757 fn test_apply_metadata_sets_ownership() {
758 use std::os::unix::fs::MetadataExt;
759 let dir = tempfile::tempdir().unwrap();
760 let src = dir.path().join("src_owned");
761 let dst = dir.path().join("dst_owned");
762
763 std::fs::write(&src, b"owned-content").unwrap();
764 std::fs::write(&dst, b"target-content").unwrap();
765
766 set_ownership(&src, 1000, 1000).unwrap();
767
768 let src_meta = std::fs::metadata(&src).unwrap();
769 assert_eq!(src_meta.uid(), 1000);
770 assert_eq!(src_meta.gid(), 1000);
771
772 apply_metadata(&src, &dst).unwrap();
773
774 let dst_meta = std::fs::metadata(&dst).unwrap();
775 assert_eq!(
776 dst_meta.uid(), 1000,
777 "apply_metadata must set dst uid to match src uid"
778 );
779 assert_eq!(
780 dst_meta.gid(), 1000,
781 "apply_metadata must set dst gid to match src gid"
782 );
783 }
784
785 #[test]
792 fn test_sync_xattrs_silently_swallows_errors() {
793 let dir = tempfile::tempdir().unwrap();
794 let src = dir.path().join("src_xattr");
795 let dst = dir.path().join("dst_xattr");
796
797 std::fs::write(&src, b"xattr-source").unwrap();
798 std::fs::write(&dst, b"xattr-dest").unwrap();
799
800 let set_result = xattr::set(&src, "user.test.swallow", b"value123");
801 if set_result.is_err() {
802 eprintln!("SKIP: filesystem does not support user xattrs");
803 return;
804 }
805
806 let result: () = sync_xattrs(&src, &dst);
807 assert_eq!(
808 std::mem::size_of_val(&result), 0,
809 "sync_xattrs returns () not Result -- errors are silently swallowed"
810 );
811
812 let bad_dst = Path::new("/proc/self/comm");
813 if bad_dst.exists() {
814 sync_xattrs(&src, bad_dst);
815 }
816 }
817
818 #[test]
823 fn test_sync_xattrs_copies_all_namespaces() {
824 let dir = tempfile::tempdir().unwrap();
825 let src = dir.path().join("src_ns");
826 let dst = dir.path().join("dst_ns");
827
828 std::fs::write(&src, b"namespace-source").unwrap();
829 std::fs::write(&dst, b"namespace-dest").unwrap();
830
831 if xattr::set(&src, "user.test.key1", b"alpha").is_err() {
832 eprintln!("SKIP: filesystem does not support user xattrs");
833 return;
834 }
835 xattr::set(&src, "user.test.key2", b"beta").unwrap();
836 xattr::set(&src, "user.foxing.custom", b"gamma").unwrap();
837
838 sync_xattrs(&src, &dst);
839
840 assert_eq!(
841 xattr::get(&dst, "user.test.key1").unwrap(),
842 Some(b"alpha".to_vec()),
843 "user.test.key1 should be copied by sync_xattrs"
844 );
845 assert_eq!(
846 xattr::get(&dst, "user.test.key2").unwrap(),
847 Some(b"beta".to_vec()),
848 "user.test.key2 should be copied by sync_xattrs"
849 );
850 assert_eq!(
851 xattr::get(&dst, "user.foxing.custom").unwrap(),
852 Some(b"gamma".to_vec()),
853 "user.foxing.custom should be copied by sync_xattrs"
854 );
855
856 }
857
858 #[test]
859 fn test_open_beneath_normal_file() {
860 let dir = tempfile::tempdir().unwrap();
861 let test_file = dir.path().join("test.txt");
862 std::fs::write(&test_file, b"hello").unwrap();
863
864 let result = open_beneath(
865 dir.path(),
866 std::path::Path::new("test.txt"),
867 libc::O_RDONLY,
868 0,
869 );
870 assert!(result.is_ok(), "open_beneath should succeed for a normal file");
871 }
872
873 #[test]
874 fn test_open_beneath_path_escape_rejected() {
875 let dir = tempfile::tempdir().unwrap();
876 let test_file = dir.path().join("test.txt");
877 std::fs::write(&test_file, b"hello").unwrap();
878
879 let result = open_beneath(
880 dir.path(),
881 std::path::Path::new("../../../etc/passwd"),
882 libc::O_RDONLY,
883 0,
884 );
885 assert!(result.is_err(), "open_beneath should reject path traversal");
886 }
887
888 #[test]
889 fn test_open_beneath_nested_file() {
890 let dir = tempfile::tempdir().unwrap();
891 let subdir = dir.path().join("subdir");
892 std::fs::create_dir(&subdir).unwrap();
893 let test_file = subdir.join("nested.txt");
894 std::fs::write(&test_file, b"nested").unwrap();
895
896 let result = open_beneath(
897 dir.path(),
898 std::path::Path::new("subdir/nested.txt"),
899 libc::O_RDONLY,
900 0,
901 );
902 assert!(result.is_ok(), "open_beneath should handle nested paths");
903 }
904}