rebecca-core 0.2.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use crate::error::{RebeccaError, Result};
use crate::scan::ScanReport;

pub const SCAN_CACHE_VERSION: u32 = 1;
pub const DEFAULT_DIRECTORY_SCAN_CACHE_MAX_AGE_SECONDS: u64 = 5 * 60;
const SCAN_CACHE_PRUNE_BATCH_LIMIT: usize = 64;
const SCAN_CACHE_DIR: &str = "scan";

mod store;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScanCachePolicy {
    directory_record_max_age_seconds: u64,
}

impl ScanCachePolicy {
    pub const fn new(directory_record_max_age_seconds: u64) -> Self {
        Self {
            directory_record_max_age_seconds,
        }
    }

    pub const fn directory_record_max_age_seconds(self) -> u64 {
        self.directory_record_max_age_seconds
    }
}

impl Default for ScanCachePolicy {
    fn default() -> Self {
        Self::new(DEFAULT_DIRECTORY_SCAN_CACHE_MAX_AGE_SECONDS)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScanCacheRecord {
    pub version: u32,
    pub root: PathBuf,
    pub fingerprint: ScanCacheFingerprint,
    pub report: ScanReport,
    pub written_at_unix_seconds: u64,
}

impl ScanCacheRecord {
    pub fn new(root: PathBuf, fingerprint: ScanCacheFingerprint, report: ScanReport) -> Self {
        Self {
            version: SCAN_CACHE_VERSION,
            root,
            fingerprint,
            report,
            written_at_unix_seconds: unix_now(),
        }
    }

    fn miss_reason(
        &self,
        root: &Path,
        fingerprint: &ScanCacheFingerprint,
        policy: ScanCachePolicy,
        now_unix_seconds: u64,
    ) -> Option<ScanCacheMiss> {
        if self.version != SCAN_CACHE_VERSION
            || self.root != root
            || &self.fingerprint != fingerprint
        {
            return Some(ScanCacheMiss::Stale);
        }

        if self.is_expired_directory_record(policy, now_unix_seconds) {
            return Some(ScanCacheMiss::Expired);
        }

        None
    }

    fn is_expired_directory_record(&self, policy: ScanCachePolicy, now_unix_seconds: u64) -> bool {
        self.fingerprint.file_type == ScanCacheFileType::Directory
            && now_unix_seconds.saturating_sub(self.written_at_unix_seconds)
                > policy.directory_record_max_age_seconds()
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScanCacheFingerprint {
    pub file_type: ScanCacheFileType,
    pub len: u64,
    pub modified_unix_seconds: Option<u64>,
}

impl ScanCacheFingerprint {
    pub fn from_path(path: &Path) -> Result<Self> {
        Self::read_from_path(path).map_err(|err| {
            RebeccaError::ScanCacheUnavailable(format!(
                "scan cache metadata unavailable for {}: {}",
                path.display(),
                err
            ))
        })
    }

    pub(crate) fn read_from_path(path: &Path) -> std::io::Result<Self> {
        let metadata = std::fs::symlink_metadata(path)?;

        let file_type = metadata.file_type();
        let file_type = if file_type.is_symlink() {
            ScanCacheFileType::Symlink
        } else if metadata.is_file() {
            ScanCacheFileType::File
        } else if metadata.is_dir() {
            ScanCacheFileType::Directory
        } else {
            ScanCacheFileType::Other
        };

        let modified_unix_seconds = metadata
            .modified()
            .ok()
            .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
            .map(|duration| duration.as_secs());

        Ok(Self {
            file_type,
            len: metadata.len(),
            modified_unix_seconds,
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ScanCacheFileType {
    File,
    Directory,
    Symlink,
    Other,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanCacheLookup {
    Hit(ScanReport),
    Miss(ScanCacheMissOutcome),
}

impl ScanCacheLookup {
    pub fn miss(reason: ScanCacheMiss) -> Self {
        Self::Miss(ScanCacheMissOutcome {
            reason,
            pruned: false,
        })
    }

    pub fn pruned_miss(reason: ScanCacheMiss) -> Self {
        Self::Miss(ScanCacheMissOutcome {
            reason,
            pruned: true,
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScanCacheMissOutcome {
    pub reason: ScanCacheMiss,
    pub pruned: bool,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ScanCachePruneReport {
    pub inspected: usize,
    pub pruned: usize,
    pub retained: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanCacheMiss {
    Missing,
    Stale,
    Expired,
    Corrupted,
    MetadataUnavailable,
}

impl ScanCacheMiss {
    pub fn label(self) -> &'static str {
        match self {
            Self::Missing => "missing",
            Self::Stale => "stale",
            Self::Expired => "expired",
            Self::Corrupted => "corrupted",
            Self::MetadataUnavailable => "metadata-unavailable",
        }
    }

    fn should_prune_cache_file(self) -> bool {
        matches!(self, Self::Stale | Self::Expired | Self::Corrupted)
    }
}

#[derive(Debug, Clone)]
pub struct ScanCacheStore {
    root_dir: PathBuf,
}

fn unix_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs())
        .unwrap_or_default()
}