liteboxfs 0.2.0

A modern POSIX filesystem in a SQLite database
Documentation
use std::borrow::Cow;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::ops::Range;
use std::os::fd::AsFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use nix::errno::Errno;
use nix::fcntl;
use nix::sys::stat;
use nix::unistd;

use crate::FileOrigin;
use crate::file_metadata::{Device, FileKind, FileMetadata, FileMode, Xattrs};
use crate::files::File;

pub fn classify_source_error(e: Errno, path: &Path) -> crate::Error {
    match e {
        Errno::ENOENT => crate::Error::FileNotFound {
            file: FileOrigin::Host {
                path: Cow::Owned(path.to_path_buf()),
            },
        },
        other => {
            let io_err = io::Error::from(other);
            crate::Error::Io {
                kind: io_err.kind(),
                code: io_err.raw_os_error(),
            }
        }
    }
}

pub fn classify_dest_error(e: io::Error, path: &Path) -> crate::Error {
    match e.kind() {
        io::ErrorKind::AlreadyExists => crate::Error::FileAlreadyExists {
            path: FileOrigin::Host {
                path: Cow::Owned(path.to_path_buf()),
            },
        },
        io::ErrorKind::NotFound => crate::Error::NoParentDirectory {
            file: FileOrigin::Host {
                path: Cow::Owned(path.to_path_buf()),
            },
        },
        io::ErrorKind::NotADirectory => crate::Error::NotADirectory {
            file: FileOrigin::Host {
                path: Cow::Owned(path.to_path_buf()),
            },
        },
        _ => crate::Error::from(e),
    }
}

pub fn file_kind_from_stat(stat: &stat::FileStat, path: &Path) -> crate::Result<FileKind> {
    let file_type = stat::SFlag::from_bits_truncate(stat.st_mode) & stat::SFlag::S_IFMT;

    if file_type == stat::SFlag::S_IFREG {
        Ok(FileKind::Regular)
    } else if file_type == stat::SFlag::S_IFDIR {
        Ok(FileKind::Dir)
    } else if file_type == stat::SFlag::S_IFLNK {
        let target = fcntl::readlink(path).map_err(|e| {
            let io_err = io::Error::from(e);
            crate::Error::Io {
                kind: io_err.kind(),
                code: io_err.raw_os_error(),
            }
        })?;
        Ok(FileKind::Symlink {
            target: target.into(),
        })
    } else if file_type == stat::SFlag::S_IFBLK {
        Ok(FileKind::Block {
            dev: Device::new(stat::major(stat.st_rdev), stat::minor(stat.st_rdev)),
        })
    } else if file_type == stat::SFlag::S_IFCHR {
        Ok(FileKind::Char {
            dev: Device::new(stat::major(stat.st_rdev), stat::minor(stat.st_rdev)),
        })
    } else if file_type == stat::SFlag::S_IFIFO {
        Ok(FileKind::Pipe)
    } else {
        Err(crate::Error::UnsupportedFileType {
            file: FileOrigin::Host {
                path: path.to_owned().into(),
            },
        })
    }
}

pub fn stat_time(secs: i64, nsec: i64) -> SystemTime {
    if secs >= 0 {
        UNIX_EPOCH + Duration::new(secs as u64, nsec as u32)
    } else {
        UNIX_EPOCH - Duration::new((-secs) as u64, 0) + Duration::new(0, nsec as u32)
    }
}

fn is_xattr_not_supported(e: &io::Error) -> bool {
    matches!(e.raw_os_error(), Some(code) if code == nix::libc::ENOTSUP || code == nix::libc::EOPNOTSUPP)
}

pub fn read_host_xattrs(path: &Path) -> crate::Result<Xattrs> {
    let names = match xattr::list(path) {
        Ok(names) => names,
        Err(e) if is_xattr_not_supported(&e) => return Ok(Xattrs::new()),
        Err(e) => return Err(crate::Error::from(e)),
    };

    let mut xattrs = Xattrs::new();
    for name in names {
        match xattr::get(path, &name) {
            Ok(Some(value)) => {
                xattrs.set(name, value);
            }
            Ok(None) => {}
            Err(e) if is_xattr_not_supported(&e) => return Ok(Xattrs::new()),
            Err(e) => return Err(crate::Error::from(e)),
        }
    }

    Ok(xattrs)
}

pub fn archive_metadata(
    stat: &stat::FileStat,
    btime: Option<SystemTime>,
    file: &mut File<'_, '_>,
) -> crate::Result<()> {
    file.set_mode(FileMode::from_bits_truncate(stat.st_mode))?;
    file.set_accessed(stat_time(stat.st_atime, stat.st_atime_nsec))?;
    file.set_modified(stat_time(stat.st_mtime, stat.st_mtime_nsec))?;
    file.set_changed(stat_time(stat.st_ctime, stat.st_ctime_nsec))?;
    file.set_created(btime)?;

    Ok(())
}

pub fn archive_contents(
    source: &Path,
    file_size: u64,
    dest: &mut File<'_, '_>,
) -> crate::Result<()> {
    if file_size == 0 {
        return Ok(());
    }

    let host_file = std::fs::File::open(source)?;

    // Try to detect sparse files using SEEK_DATA.
    match unistd::lseek(host_file.as_fd(), 0, unistd::Whence::SeekData) {
        Err(Errno::EINVAL) | Err(Errno::EOPNOTSUPP) => {
            // Filesystem doesn't support SEEK_DATA/SEEK_HOLE, fall back to plain copy.
            let mut reader = host_file;
            io::copy(&mut reader, dest)?;
            dest.flush()?;
            return Ok(());
        }
        Err(Errno::ENXIO) => {
            // Entire file is a hole.
            dest.set_len(file_size)?;
            return Ok(());
        }
        Ok(_) | Err(_) => {
            // Reset position for the sparse loop below.
            unistd::lseek(host_file.as_fd(), 0, unistd::Whence::SeekSet)
                .map_err(|e| crate::Error::from(io::Error::from(e)))?;
        }
    }

    // Sparse copy loop.
    let mut reader = host_file;
    let mut pos: u64 = 0;
    loop {
        // Find the next data region.
        let data_start = match unistd::lseek(reader.as_fd(), pos as i64, unistd::Whence::SeekData) {
            Ok(offset) => offset as u64,
            Err(Errno::ENXIO) => {
                // Trailing hole to end of file.
                dest.set_len(file_size)?;
                break;
            }
            Err(e) => return Err(crate::Error::from(io::Error::from(e))),
        };

        // Write the hole region [pos, data_start) if any.
        if data_start > pos {
            dest.set_len(data_start)?;
            dest.seek(SeekFrom::Start(data_start))?;
        }

        // Find the end of this data region.
        let hole_start =
            match unistd::lseek(reader.as_fd(), data_start as i64, unistd::Whence::SeekHole) {
                Ok(offset) => offset as u64,
                Err(Errno::ENXIO) => file_size,
                Err(e) => return Err(crate::Error::from(io::Error::from(e))),
            };

        let data_end = hole_start.min(file_size);

        // Read the data region and write it to the litebox.
        reader.seek(SeekFrom::Start(data_start))?;
        let bytes_to_copy = data_end - data_start;
        io::copy(&mut Read::by_ref(&mut reader).take(bytes_to_copy), dest)?;

        pos = data_end;

        if pos >= file_size {
            break;
        }
    }

    dest.flush()?;
    Ok(())
}

/// Copy file contents from a litebox file to a host file, preserving sparse holes.
pub fn extract_contents_sparse(
    source: &mut (impl Read + Seek),
    dest: &mut std::fs::File,
    holes: &[Range<u64>],
) -> crate::Result<()> {
    let file_len = source.seek(SeekFrom::End(0))?;
    source.seek(SeekFrom::Start(0))?;

    let mut pos: u64 = 0;

    for hole in holes {
        // Copy the data region before this hole.
        if hole.start > pos {
            let data_len = hole.start - pos;
            io::copy(&mut Read::by_ref(source).take(data_len), dest)?;
        }

        // Skip past the hole in both source and dest.
        source.seek(SeekFrom::Start(hole.end))?;
        dest.seek(SeekFrom::Start(hole.end))?;
        pos = hole.end;
    }

    // Copy any remaining data after the last hole.
    if pos < file_len {
        io::copy(&mut Read::by_ref(source).take(file_len - pos), dest)?;
    }

    // Ensure the file is the correct length (handles trailing holes).
    dest.flush()?;
    dest.set_len(file_len)?;

    Ok(())
}

pub fn create_host_entry(kind: &FileKind, dest: &Path) -> io::Result<Option<std::fs::File>> {
    match kind {
        FileKind::Regular => {
            let file = std::fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .mode(0o666)
                .open(dest)?;
            Ok(Some(file))
        }
        FileKind::Dir => {
            std::fs::create_dir(dest)?;
            Ok(None)
        }
        FileKind::Symlink { target } => {
            std::os::unix::fs::symlink(target, dest)?;
            Ok(None)
        }
        FileKind::Block { dev } => {
            stat::mknod(
                dest,
                stat::SFlag::S_IFBLK,
                stat::Mode::empty(),
                stat::makedev(dev.major(), dev.minor()),
            )
            .map_err(io::Error::from)?;
            Ok(None)
        }
        FileKind::Char { dev } => {
            stat::mknod(
                dest,
                stat::SFlag::S_IFCHR,
                stat::Mode::empty(),
                stat::makedev(dev.major(), dev.minor()),
            )
            .map_err(io::Error::from)?;
            Ok(None)
        }
        FileKind::Pipe => {
            unistd::mkfifo(dest, stat::Mode::empty()).map_err(io::Error::from)?;
            Ok(None)
        }
    }
}

pub fn extract_metadata(
    kind: &FileKind,
    meta: &FileMetadata,
    xattrs: &Xattrs,
    dest: &Path,
) -> crate::Result<()> {
    // Set ownership (works on symlinks via AT_SYMLINK_NOFOLLOW).
    let nix_uid = Some(nix::unistd::Uid::from_raw(meta.user().as_raw()));
    let nix_gid = Some(nix::unistd::Gid::from_raw(meta.group().as_raw()));
    if let Err(e) = unistd::fchownat(
        fcntl::AT_FDCWD,
        dest,
        nix_uid,
        nix_gid,
        fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
    ) {
        // EPERM is expected when running as non-root.
        if e != Errno::EPERM {
            return Err(crate::Error::from(io::Error::from(e)));
        }
    }

    // Set mode (skip for symlinks — Linux has no lchmod).
    if !matches!(kind, FileKind::Symlink { .. }) {
        let mode = stat::Mode::from_bits_truncate(meta.mode().bits());
        if let Err(e) = stat::fchmodat(
            fcntl::AT_FDCWD,
            dest,
            mode,
            stat::FchmodatFlags::FollowSymlink,
        ) && e != Errno::EPERM
        {
            return Err(crate::Error::from(io::Error::from(e)));
        }
    }

    // Set timestamps.
    let atime = system_time_to_timespec(meta.accessed());
    let mtime = system_time_to_timespec(meta.modified());
    if let Err(e) = stat::utimensat(
        fcntl::AT_FDCWD,
        dest,
        &atime,
        &mtime,
        stat::UtimensatFlags::NoFollowSymlink,
    ) && e != Errno::EPERM
    {
        return Err(crate::Error::from(io::Error::from(e)));
    }

    // Set xattrs (skip for symlinks since xattr::set follows symlinks).
    if !matches!(kind, FileKind::Symlink { .. }) {
        for (name, value) in xattrs {
            if let Err(e) = xattr::set(dest, name, value) {
                if is_xattr_not_supported(&e) {
                    break;
                }
                return Err(crate::Error::from(e));
            }
        }
    }

    Ok(())
}

fn system_time_to_timespec(time: SystemTime) -> nix::sys::time::TimeSpec {
    match time.duration_since(UNIX_EPOCH) {
        Ok(d) => nix::sys::time::TimeSpec::new(d.as_secs() as i64, d.subsec_nanos() as i64),
        Err(e) => {
            let d = e.duration();
            nix::sys::time::TimeSpec::new(-(d.as_secs() as i64), d.subsec_nanos() as i64)
        }
    }
}

#[derive(Debug)]
pub struct PathPair {
    pub host: PathBuf,
    pub lbox: PathBuf,
}

pub fn push_dir_children(
    host_dir: &Path,
    lbox_dir: &Path,
    stack: &mut Vec<PathPair>,
) -> crate::Result<()> {
    let entries = std::fs::read_dir(host_dir)?;
    for entry in entries {
        let entry = entry?;
        let host_path = entry.path();
        let file_name = entry.file_name();
        let lbox_path = lbox_dir.join(&file_name);
        stack.push(PathPair {
            host: host_path,
            lbox: lbox_path,
        });
    }
    Ok(())
}