rebecca-core 0.2.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
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};

#[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,
{
    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(&pattern)?;
            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(pattern: &Path) -> 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, &mut results)?;
    results.sort();
    results.dedup();

    Ok(results)
}

fn expand_segments(
    current: PathBuf,
    remaining: &[String],
    results: &mut Vec<PathBuf>,
) -> Result<()> {
    let Some((segment, tail)) = remaining.split_first() else {
        if current.exists() {
            results.push(current);
        }
        return Ok(());
    };

    if !has_wildcards(segment) {
        let mut next = current;
        next.push(segment);
        return expand_segments(next, tail, results);
    }

    if !current.is_dir() {
        return Ok(());
    }

    let matcher = segment_matcher(segment)?;
    for entry in fs::read_dir(&current)? {
        let entry = entry?;
        if matcher.is_match(entry.file_name()) {
            expand_segments(entry.path(), tail, results)?;
        }
    }

    Ok(())
}

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}"))
        })
}