Skip to main content

fxcp_core/
security.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4//! Metadata sync, permissions, ownership, xattr operations.
5#![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
30/// Clone a file via FICLONE ioctl (CoW reflink from src_fd to a new file at dst_path)
31pub 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    // SAFETY: dst_fd is a valid fd from File::create, src_fd is a valid fd from caller.
35    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
51/// Return false if available space is below threshold_mb or free inodes are below 1000
52pub 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
64/// Check if the filesystem at path uses transparent compression (btrfs or F2FS)
65pub 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
73/// Preallocate disk space with FALLOC_FL_KEEP_SIZE (best-effort, ignores errors)
74pub fn preallocate(fd: i32, size: u64) {
75    if size > 0 {
76        // SAFETY: fd is a valid open file descriptor passed by the caller.
77        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
82/// Enable filesystem-level compression via FS_IOC_SETFLAGS
83pub fn enable_compression(fd: i32) -> Result<()> {
84    let flags: u32 = FS_COMPR_FL;
85    // SAFETY: fd is a valid open fd. FS_IOC_SETFLAGS sets filesystem flags.
86    let ret = unsafe { libc::ioctl(fd, 0x40086602, &flags) };
87    if ret != 0 { return Err(FxcpError::System(nix::Error::last())); }
88    Ok(())
89}
90
91/// Pin a file on F2FS to prevent garbage collection from moving its blocks
92pub fn enable_f2fs_pinning(fd: i32) -> Result<()> {
93    let pin: u32 = 1;
94    // SAFETY: fd is a valid open fd on an F2FS filesystem.
95    let ret = unsafe { libc::ioctl(fd, 0xF50D, &pin) };
96    if ret != 0 { return Err(FxcpError::System(nix::Error::last())); }
97    Ok(())
98}
99
100/// Set the XFS/ext4 project ID for directory quota tracking
101pub 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    // SAFETY: fd is a valid open fd. attr is a repr(C) FsxAttr struct.
119    let ret = unsafe { libc::ioctl(fd, 0x40205820, &attr) };
120    if ret != 0 { return Err(FxcpError::System(nix::Error::last())); }
121    Ok(())
122}
123
124/// Acquire an exclusive OFD write lock on the entire file
125pub 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    // SAFETY: fd is a valid open fd. F_OFD_SETLK sets an open file description lock.
128    if unsafe { libc::fcntl(fd, libc::F_OFD_SETLK, &lock) } < 0 {
129        return Err(FxcpError::System(nix::Error::last()));
130    }
131    Ok(())
132}
133
134/// Acquire a shared OFD read lock on the entire file
135pub 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    // SAFETY: fd is a valid open fd. F_OFD_SETLK sets an open file description lock.
138    if unsafe { libc::fcntl(fd, libc::F_OFD_SETLK, &lock) } < 0 {
139        return Err(FxcpError::System(nix::Error::last()));
140    }
141    Ok(())
142}
143
144/// Set file ownership (uid/gid) via chown
145pub 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
156/// Copy atime and mtime from src to dst with nanosecond precision via utimensat
157pub 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    // SAFETY: c_path is a valid null-terminated C string. times is a valid
166    // pointer to a [timespec; 2] array with atime and mtime.
167    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
177/// Copy all extended attributes from src to dst (best-effort, ignores errors)
178pub 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
188/// Stores a strict signature of the source file state into the target's xattrs.
189/// This allows us to skip re-copying even if the target filesystem mangles timestamps.
190fn 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
198/// Verifies if the target has a signature matching the current source state.
199pub 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
213/// Apply ownership, permissions, timestamps, and sync marker from src to dst
214pub 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 the sync signature to authorize skipping next time
228    write_sync_marker(dst, meta.len(), meta.mtime(), meta.mtime_nsec());
229    
230    Ok(())
231}
232
233/// Check if directory metadata (uid, gid, mode, mtime) matches between src and dst
234pub 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
245/// Read the replication epoch counter from xattr/sidecar metadata
246pub 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
254/// Get the stored directory integrity hash, returning 0 if stale or missing
255pub 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
275/// Write a directory integrity hash with mtime guard for staleness detection
276pub 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
293/// Compute a directory integrity hash from inode metadata and immediate children
294pub 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
317/// Acquire a lock and read the directory integrity hash atomically
318pub 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
338/// Probe whether the filesystem at target_root supports extended attributes
339pub 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
353/// Probe whether the filesystem supports O_DIRECT by writing a test block
354pub 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    // SAFETY: path_c is a valid null-terminated C string. O_DIRECT requires
364    // aligned I/O which is guaranteed by AlignedBuffer.
365    let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o644) };
366    if fd < 0 { return false; }
367    // SAFETY: fd is valid (checked >= 0 above). buf.ptr() points to a 4096-byte
368    // aligned allocation from AlignedBuffer.
369    let ret = unsafe { libc::write(fd, buf.ptr() as *const _, 4096) };
370    // SAFETY: fd is a valid open file descriptor.
371    unsafe { libc::close(fd); }
372    let _ = std::fs::remove_file(&probe_file);
373    ret == 4096
374}
375
376/// Probe whether the kernel supports RWF_UNCACHED via io_uring registered buffer read
377pub 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    // SAFETY: iov points to a valid AlignedBuffer allocation that outlives the ring.
398    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    // SAFETY: the SQE is fully built with a valid fd and registered buffer index.
409    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
447/// Create a CoW (FICLONE) snapshot of a file for MARS versioning
448pub 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
517/// Revert a file to a previous snapshot version by copying the version file over the live path.
518pub 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
535/// Truncate or extend a file to the specified size
536pub 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
541/// Call fallocate(2) on a file with the given mode, offset, and length
542pub 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    // SAFETY: fd is a valid open file descriptor from File::open.
546    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
551/// Create a symbolic link pointing to target
552pub fn create_symlink(target: &str, link: &Path) -> Result<()> {
553    std::os::unix::fs::symlink(target, link).map_err(FxcpError::Io)
554}
555
556/// Create a hard link to an existing file
557pub fn create_hard_link(original: &Path, link: &Path) -> Result<()> {
558    std::fs::hard_link(original, link).map_err(FxcpError::Io)
559}
560
561/// Create a special file (device node, FIFO, socket) via mknod(2)
562pub 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    // SAFETY: cpath is a valid null-terminated C string.
565    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
570/// Set filesystem inode flags via FS_IOC_SETFLAGS (e.g. immutable, append-only)
571pub 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    // SAFETY: fd is a valid open fd. FS_IOC_SETFLAGS sets filesystem inode flags.
576    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
593// ---------------------------------------------------------------------------
594// Path sanitization  --  prevent path traversal and symlink escape
595// ---------------------------------------------------------------------------
596
597/// Check that `path` is within `root` after canonicalization.
598/// Rejects symlinks that escape the root directory.
599pub 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
609/// Canonicalize `path` and verify it stays within `root`.
610/// Returns the canonical path or a security error.
611pub 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
626// ---------------------------------------------------------------------------
627// openat2 RESOLVE_BENEATH  --  kernel-enforced path containment (Linux 5.6+)
628// ---------------------------------------------------------------------------
629
630/// Open a file beneath `root` using openat2(2) with RESOLVE_BENEATH.
631/// The kernel rejects any path that escapes `root` via `..` or symlinks.
632/// Falls back to canonicalize_safe + standard open if openat2 unavailable.
633pub 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    // Open the root directory
643    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            // openat2 not available  --  fall back to canonicalize_safe
660            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    // -----------------------------------------------------------------------
712    // Metadata preservation gap tests
713    // -----------------------------------------------------------------------
714
715    /// Documents the gap: `preserve_metadata()` (in sync/tree.rs) does NOT call
716    /// `set_ownership()` / chown. Both src and dst end up owned by the current
717    /// process uid/gid regardless of the source file's ownership metadata.
718    ///
719    /// This test passes because preserve_metadata intentionally omits chown  -- 
720    /// it is the "lightweight" metadata path used by `fxcp -a`.
721    /// `apply_metadata()` (in this file) is the "complete" path that DOES call
722    /// `set_ownership()`.
723    #[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    /// Documents that `apply_metadata()` DOES call `set_ownership()` (chown).
752    /// This test requires root to actually change ownership, so it is #[ignore]
753    /// for unprivileged CI runs. When running as root:
754    ///   CARGO_TARGET_DIR=/var/tmp/foxing-local-target cargo test -p fxcp-core -- test_apply_metadata_sets_ownership --ignored
755    #[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    /// Documents that `sync_xattrs()` returns `()` (not `Result`) and silently
786    /// swallows all errors from `xattr::set()` via `let _ = ...`.
787    ///
788    /// This is a design gap: callers cannot distinguish between "xattrs copied
789    /// successfully" and "xattr copy failed silently". The function signature
790    /// itself proves the gap  --  it returns unit, not Result.
791    #[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    /// Verifies that `sync_xattrs()` copies user.* namespace xattrs from src
819    /// to dst. Documents that security.* and trusted.* namespaces would also
820    /// be copied (the function copies ALL namespaces) but cannot be tested
821    /// without root/CAP_SYS_ADMIN.
822    #[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}