exhume_filesystem 0.5.2

This exhume module is proposing a standard abstraction layer of a FileSystem, File and Directory for any exhume filesystem modules (extfs, ...).
Documentation
use crate::filesystem::{DirectoryCommon, File, FileCommon, Filesystem};
use exhume_exfat::compat::CompatDirEntry;
use exhume_exfat::exinode::ExInode;
use exhume_exfat::{BootSector, ExFatFS};
use serde_json::Value;

use std::error::Error;
use std::io::{Read, Seek};
use std::path::Path;

/// Minimal attribute string (read-only, hidden, system, dir, archive)
fn exfat_attr_string(attrs: u16, is_dir: bool) -> String {
    let mut s = String::new();
    if (attrs & 0x0001) != 0 {
        s.push('R');
    } // READ_ONLY
    if (attrs & 0x0002) != 0 {
        s.push('H');
    } // HIDDEN
    if (attrs & 0x0004) != 0 {
        s.push('S');
    } // SYSTEM
    if is_dir {
        s.push('D');
    }
    if (attrs & 0x0020) != 0 {
        s.push('A');
    } // ARCHIVE
    s
}

/// Synthesize a stable “fake” inode number for the root directory.
/// exFAT doesn’t store a directory entry for root, so we fix a sentinel low part.
fn root_inode_num(bpb: &BootSector) -> u64 {
    ((bpb.root_dir_first_cluster as u64) << 32) | 0xffff_ffff
}

/// Build a synthetic ExInode for the root directory so we can use the same API.
fn make_root_inode(bpb: &BootSector) -> ExInode {
    ExInode {
        i_num: root_inode_num(bpb),
        attributes: 0x0010, // directory
        first_cluster: bpb.root_dir_first_cluster,
        size: 0,
        name: "/".to_string(),
        create_time: 0,
        last_access_time: 0,
        last_mod_time: 0,
    }
}

impl FileCommon for ExInode {
    fn id(&self) -> u64 {
        self.i_num
    }
    fn size(&self) -> u64 {
        self.size()
    }
    fn is_dir(&self) -> bool {
        self.is_dir()
    }
    fn to_string(&self) -> String {
        ToString::to_string(self)
    }
    fn to_json(&self) -> Value {
        self.to_json()
    }
}

impl DirectoryCommon for CompatDirEntry {
    fn file_id(&self) -> u64 {
        self.inode
    }
    fn name(&self) -> &str {
        &self.name
    }
    fn to_string(&self) -> String {
        ToString::to_string(self)
    }
    fn to_json(&self) -> Value {
        self.to_json()
    }
}

impl<T: Read + Seek> Filesystem for ExFatFS<T> {
    type FileType = ExInode;
    type DirectoryType = CompatDirEntry;

    fn filesystem_type(&self) -> String {
        "exFAT".to_string()
    }

    fn path_separator(&self) -> String {
        "/".to_string()
    }

    /// There isn't a fixed “record count” in exFAT; return 0 (unknown).
    fn record_count(&mut self) -> u64 {
        0
    }

    fn block_size(&self) -> u64 {
        self.bpb.bytes_per_cluster()
    }

    fn get_metadata(&self) -> Result<Value, Box<dyn Error>> {
        Ok(self.super_info_json())
    }

    fn get_metadata_pretty(&self) -> Result<String, Box<dyn Error>> {
        Ok(self.bpb.to_string())
    }

    /// Get a file by its fake inode number. We handle our synthetic root specially.
    fn get_file(&mut self, file_id: u64) -> Result<Self::FileType, Box<dyn Error>> {
        if file_id == root_inode_num(&self.bpb) {
            return Ok(make_root_inode(&self.bpb));
        }
        Ok(self.get_inode(file_id)?)
    }

    fn read_file_content(&mut self, inode: &Self::FileType) -> Result<Vec<u8>, Box<dyn Error>> {
        if inode.is_dir() {
            return Err("exFAT: requested content for a directory".into());
        }
        Ok(self.read_inode(inode)?)
    }

    fn read_file_prefix(
        &mut self,
        inode: &Self::FileType,
        length: usize,
    ) -> Result<Vec<u8>, Box<dyn Error>> {
        let mut data = self.read_file_content(inode)?;
        if data.len() > length {
            data.truncate(length);
        }
        Ok(data)
    }

    fn read_file_slice(
        &mut self,
        inode: &Self::FileType,
        offset: u64,
        length: usize,
    ) -> Result<Vec<u8>, Box<dyn Error>> {
        let data = self.read_file_content(inode)?;
        let off = offset as usize;
        if off >= data.len() {
            return Ok(Vec::new());
        }
        let end = off.saturating_add(length).min(data.len());
        Ok(data[off..end].to_vec())
    }

    fn list_dir(
        &mut self,
        inode: &Self::FileType,
    ) -> Result<Vec<Self::DirectoryType>, Box<dyn Error>> {
        if !inode.is_dir() {
            return Err("not a directory".into());
        }
        Ok(self.list_dir_inode(inode)?)
    }

    fn record_to_file(&self, inode: &Self::FileType, file_id: u64, absolute_path: &str) -> File {
        let is_dir = inode.is_dir();
        let ftype = if is_dir { "dir" } else { "file" }.to_string();

        File {
            id: None,
            identifier: file_id,
            absolute_path: absolute_path.to_string(),
            name: match Path::new(absolute_path).file_name() {
                Some(n) => n.to_string_lossy().to_string(),
                None => absolute_path.to_string(),
            },
            created: Some(inode.create_time as u64),
            modified: Some(inode.last_mod_time as u64),
            accessed: Some(inode.last_access_time as u64),
            permissions: Some(exfat_attr_string(inode.attributes, is_dir)),
            owner: None,
            group: None,
            ftype,
            size: inode.size(),
            display: Some(format!(
                "{:016x} - {:>4} - {:>10} - {}",
                file_id,
                if is_dir { "DIR" } else { "FILE" },
                inode.size(),
                absolute_path
            )),
            sig_name: None,
            sig_mime: None,
            sig_exts: None,
            metadata: inode.to_json(),
        }
    }

    fn get_root_file_id(&self) -> u64 {
        root_inode_num(&self.bpb)
    }
}