rebecca-core 0.3.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};

use globset::{GlobBuilder, GlobMatcher};

use crate::applications::{ApplicationDiscovery, NoopApplicationDiscovery};
use crate::environment::Environment;
use crate::error::{RebeccaError, Result};
use crate::model::RuleTargetSpec;
use crate::path_template::expand_template;
use crate::protection::{ProtectionAssessment, ProtectionPolicy};
use crate::safety::is_reparse_like;

#[derive(Debug, Default)]
pub struct DiscoveryIndex {
    glob_directories: BTreeMap<PathBuf, Vec<GlobDirectoryEntry>>,
}

impl DiscoveryIndex {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn cached_glob_directory_count(&self) -> usize {
        self.glob_directories.len()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct GlobDirectoryEntry {
    file_name: OsString,
    path: PathBuf,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TargetResolution {
    Paths(Vec<PathBuf>),
    Skipped(String),
}

pub fn resolve_rule_target(
    target: &RuleTargetSpec,
    env: &impl Environment,
) -> Result<TargetResolution> {
    resolve_rule_target_with_applications(target, env, &NoopApplicationDiscovery::new())
}

pub fn resolve_rule_target_with_applications<A>(
    target: &RuleTargetSpec,
    env: &impl Environment,
    applications: &A,
) -> Result<TargetResolution>
where
    A: ApplicationDiscovery + ?Sized,
{
    let mut discovery_index = DiscoveryIndex::new();
    resolve_rule_target_with_applications_and_index(target, env, applications, &mut discovery_index)
}

pub fn resolve_rule_target_with_applications_and_index<A>(
    target: &RuleTargetSpec,
    env: &impl Environment,
    applications: &A,
    discovery_index: &mut DiscoveryIndex,
) -> Result<TargetResolution>
where
    A: ApplicationDiscovery + ?Sized,
{
    match target {
        RuleTargetSpec::Template(template) => match expand_template(template, env)? {
            Some(path) => Ok(TargetResolution::Paths(vec![path])),
            None => Ok(TargetResolution::Skipped(
                "path template could not be resolved in the current environment".to_string(),
            )),
        },
        RuleTargetSpec::ExactPath(path) => Ok(TargetResolution::Paths(vec![path.clone()])),
        RuleTargetSpec::GlobTemplate(template) => {
            let pattern = match expand_template(template, env)? {
                Some(path) => path,
                None => {
                    return Ok(TargetResolution::Skipped(
                        "glob template could not be resolved in the current environment"
                            .to_string(),
                    ));
                }
            };

            let paths = discover_glob_paths_with_index(&pattern, discovery_index)?;
            if paths.is_empty() {
                Ok(TargetResolution::Skipped(
                    "glob pattern matched no existing paths".to_string(),
                ))
            } else {
                Ok(TargetResolution::Paths(paths))
            }
        }
        RuleTargetSpec::SteamInstallTemplate(template) => resolve_steam_relative_target(
            applications,
            template,
            env,
            "Steam install template could not be resolved in the current environment",
            SteamRootScope::InstallOnly,
        ),
        RuleTargetSpec::SteamLibraryTemplate(template) => resolve_steam_relative_target(
            applications,
            template,
            env,
            "Steam library template could not be resolved in the current environment",
            SteamRootScope::IncludeLibraries,
        ),
    }
}

fn resolve_steam_relative_target<A>(
    applications: &A,
    template: &crate::PathTemplate,
    env: &impl Environment,
    skipped_message: &'static str,
    scope: SteamRootScope,
) -> Result<TargetResolution>
where
    A: ApplicationDiscovery + ?Sized,
{
    let Some(steam) = applications.steam_installation()? else {
        return Ok(TargetResolution::Skipped(
            "Steam installation was not discovered".to_string(),
        ));
    };

    let roots = match scope {
        SteamRootScope::InstallOnly => vec![steam.install_path()],
        SteamRootScope::IncludeLibraries => std::iter::once(steam.install_path())
            .chain(steam.library_paths().iter().map(PathBuf::as_path))
            .collect(),
    };

    let Some(relative) = expand_template(template, env)? else {
        return Ok(TargetResolution::Skipped(skipped_message.to_string()));
    };

    ensure_safe_relative_steam_target(&relative)?;

    Ok(TargetResolution::Paths(
        roots.into_iter().map(|root| root.join(&relative)).collect(),
    ))
}

#[derive(Debug, Clone, Copy)]
enum SteamRootScope {
    InstallOnly,
    IncludeLibraries,
}

fn ensure_safe_relative_steam_target(path: &Path) -> Result<()> {
    if matches!(
        ProtectionPolicy::new().assess_relative_target_shape(path),
        ProtectionAssessment::Allowed
    ) {
        return Ok(());
    }

    Err(unsafe_steam_relative_path_error(path))
}

fn unsafe_steam_relative_path_error(path: &Path) -> RebeccaError {
    RebeccaError::PathExpansionFailed(format!(
        "Steam target {} must be a safe relative path",
        path.display()
    ))
}

fn discover_glob_paths_with_index(
    pattern: &Path,
    discovery_index: &mut DiscoveryIndex,
) -> Result<Vec<PathBuf>> {
    let normalized = normalize_separators(&pattern.as_os_str().to_string_lossy());
    let segments = split_segments(&normalized);

    let mut results = Vec::new();
    expand_segments(
        root_path(&normalized),
        &segments,
        discovery_index,
        &mut results,
    )?;
    results.sort();
    results.dedup();

    Ok(results)
}

fn expand_segments(
    current: PathBuf,
    remaining: &[String],
    discovery_index: &mut DiscoveryIndex,
    results: &mut Vec<PathBuf>,
) -> Result<()> {
    let Some((segment, tail)) = remaining.split_first() else {
        if let Some(metadata) = non_reparse_metadata(&current)?
            && (metadata.is_dir() || metadata.is_file())
        {
            results.push(current);
        }
        return Ok(());
    };

    if !has_wildcards(segment) {
        let mut next = current;
        next.push(segment);
        if !tail.is_empty() && fixed_prefix_metadata(&next)?.is_none() {
            return Ok(());
        }
        return expand_segments(next, tail, discovery_index, results);
    }

    let Some(metadata) = non_reparse_metadata(&current)? else {
        return Ok(());
    };
    if !metadata.is_dir() {
        return Ok(());
    }

    let matcher = segment_matcher(segment)?;
    for entry in read_glob_directory(&current, discovery_index)? {
        if matcher.is_match(&entry.file_name) {
            if !tail.is_empty() && non_reparse_metadata(&entry.path)?.is_none() {
                continue;
            }
            expand_segments(entry.path, tail, discovery_index, results)?;
        }
    }

    Ok(())
}

fn non_reparse_metadata(path: &Path) -> Result<Option<std::fs::Metadata>> {
    match fs::symlink_metadata(path) {
        Ok(metadata) => {
            if is_reparse_like(&metadata) {
                Ok(None)
            } else {
                Ok(Some(metadata))
            }
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(err) => Err(err.into()),
    }
}

fn fixed_prefix_metadata(path: &Path) -> Result<Option<std::fs::Metadata>> {
    match fs::symlink_metadata(path) {
        Ok(metadata) => {
            if !is_reparse_like(&metadata) {
                return Ok(Some(metadata));
            }
            if is_platform_path_alias(path) {
                return fs::metadata(path).map(Some).map_err(Into::into);
            }
            Ok(None)
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(err) => Err(err.into()),
    }
}

fn is_platform_path_alias(path: &Path) -> bool {
    #[cfg(target_os = "macos")]
    {
        matches!(path.to_str(), Some("/var" | "/tmp" | "/etc"))
    }

    #[cfg(not(target_os = "macos"))]
    {
        let _ = path;
        false
    }
}

fn read_glob_directory(
    current: &Path,
    discovery_index: &mut DiscoveryIndex,
) -> Result<Vec<GlobDirectoryEntry>> {
    if let Some(entries) = discovery_index.glob_directories.get(current) {
        return Ok(entries.clone());
    }

    let entries = fs::read_dir(current)?
        .map(|entry| {
            entry.map(|entry| GlobDirectoryEntry {
                file_name: entry.file_name(),
                path: entry.path(),
            })
        })
        .collect::<std::io::Result<Vec<_>>>()?;
    discovery_index
        .glob_directories
        .insert(current.to_path_buf(), entries.clone());
    Ok(entries)
}

fn normalize_separators(raw: &str) -> String {
    if std::path::MAIN_SEPARATOR == '\\' {
        raw.replace('/', "\\")
    } else {
        raw.replace('\\', "/")
    }
}

fn split_segments(normalized: &str) -> Vec<String> {
    let mut segments = normalized
        .split(std::path::MAIN_SEPARATOR)
        .filter(|segment| !segment.is_empty())
        .map(ToOwned::to_owned)
        .collect::<Vec<_>>();

    if is_drive_absolute(normalized)
        && segments
            .first()
            .is_some_and(|segment| segment.ends_with(':'))
    {
        segments.remove(0);
    }

    segments
}

fn root_path(normalized: &str) -> PathBuf {
    let separator = std::path::MAIN_SEPARATOR;

    if is_drive_absolute(normalized) {
        return PathBuf::from(format!("{}{}", &normalized[..2], separator));
    }

    if normalized.starts_with(separator) {
        return PathBuf::from(separator.to_string());
    }

    PathBuf::new()
}

fn is_drive_absolute(normalized: &str) -> bool {
    let separator = std::path::MAIN_SEPARATOR;
    let bytes = normalized.as_bytes();

    bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == separator as u8
}

fn has_wildcards(segment: &str) -> bool {
    segment.contains('*') || segment.contains('?') || segment.contains('[')
}

fn segment_matcher(segment: &str) -> Result<GlobMatcher> {
    let mut builder = GlobBuilder::new(segment);
    builder.literal_separator(true);

    if cfg!(windows) {
        builder.case_insensitive(true);
    }

    builder
        .build()
        .map(|glob| glob.compile_matcher())
        .map_err(|err| {
            RebeccaError::PathExpansionFailed(format!("invalid glob segment {segment:?}: {err}"))
        })
}