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(¤t)?
&& (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(¤t)? else {
return Ok(());
};
if !metadata.is_dir() {
return Ok(());
}
let matcher = segment_matcher(segment)?;
for entry in read_glob_directory(¤t, 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}"))
})
}