use std::path::{Path, PathBuf};
mod patterns;
use self::patterns::{
NormalizedPath, contains_relative_control_segment, contains_traversal,
is_allowed_steam_install_catalog_shape, is_allowed_steam_library_catalog_shape,
is_allowlisted_maintenance_path, is_app_leftover_cache_path, is_root, is_user_profile_root,
is_windows_critical_path, looks_absolute_shape, normalize_raw_shape, normalize_shape_path,
protected_category,
};
use crate::config::AppStorageEntry;
use crate::model::RuleTargetSpec;
use crate::path_overlap::paths_overlap;
#[derive(Debug, Clone, Copy)]
pub struct ProtectionPolicy<'a> {
protected_storage: Option<&'a [AppStorageEntry]>,
protected_paths: Option<&'a [PathBuf]>,
}
impl<'a> ProtectionPolicy<'a> {
pub fn new() -> Self {
Self {
protected_storage: None,
protected_paths: None,
}
}
pub fn with_protected_storage(mut self, protected_storage: &'a [AppStorageEntry]) -> Self {
self.protected_storage = Some(protected_storage);
self
}
pub fn with_protected_paths(mut self, protected_paths: &'a [PathBuf]) -> Self {
self.protected_paths = Some(protected_paths);
self
}
pub fn protected_storage(&self) -> Option<&'a [AppStorageEntry]> {
self.protected_storage
}
pub fn protected_paths(&self) -> Option<&'a [PathBuf]> {
self.protected_paths
}
pub fn assess_path(&self, path: &Path) -> ProtectionAssessment {
let normalized = NormalizedPath::new(path);
if normalized.raw.trim().is_empty() {
return blocked(
ProtectionBlockKind::EmptyPath,
"empty path is not allowed".to_string(),
);
}
if contains_traversal(&normalized.raw) {
return blocked(
ProtectionBlockKind::PathTraversal,
"path traversal is not allowed".to_string(),
);
}
if is_root(&normalized.raw) {
return blocked(
ProtectionBlockKind::FilesystemRoot,
"filesystem roots are protected".to_string(),
);
}
if is_user_profile_root(&normalized.lower) {
return blocked(
ProtectionBlockKind::UserProfileRoot,
"user profile root is protected".to_string(),
);
}
if let Some(entry) = self.protected_storage_overlap(path) {
return blocked(
ProtectionBlockKind::RebeccaOwnedStorage,
format!(
"target overlaps Rebecca-owned {} at {}",
entry.id.label(),
entry.path.display()
),
);
}
if let Some(protected_path) = self.protected_path_overlap(path) {
return blocked(
ProtectionBlockKind::UserProtectedPath,
format!(
"target overlaps user-protected path at {}",
protected_path.display()
),
);
}
if is_allowlisted_maintenance_path(&normalized) {
return ProtectionAssessment::Allowed;
}
if is_windows_critical_path(&normalized.lower) {
return blocked(
ProtectionBlockKind::WindowsCriticalPath,
"critical Windows path is protected".to_string(),
);
}
if let Some(category) = protected_category(&normalized) {
return blocked(
ProtectionBlockKind::ProtectedCategory(category),
format!("{} is protected", category.description()),
);
}
ProtectionAssessment::Allowed
}
pub fn assess_app_leftover_path(&self, path: &Path) -> ProtectionAssessment {
let normalized = NormalizedPath::new(path);
let base_assessment = self.assess_path(path);
match base_assessment {
ProtectionAssessment::Allowed => {
if is_app_leftover_cache_path(&normalized) {
ProtectionAssessment::Allowed
} else {
blocked(
ProtectionBlockKind::ProtectedCategory(
ProtectedCategory::ApplicationDurableData,
),
format!(
"app leftover target {} is not a recognized rebuildable cache path",
path.display()
),
)
}
}
ProtectionAssessment::Blocked(block)
if matches!(
block.kind,
ProtectionBlockKind::ProtectedCategory(
ProtectedCategory::ApplicationDurableData
)
) && is_app_leftover_cache_path(&normalized) =>
{
ProtectionAssessment::Allowed
}
ProtectionAssessment::Blocked(block) => ProtectionAssessment::Blocked(block),
}
}
pub fn assess_existing_app_leftover_path(&self, path: &Path) -> AppLeftoverPathDisposition {
match self.assess_app_leftover_path(path) {
ProtectionAssessment::Allowed => {}
ProtectionAssessment::Blocked(block) => {
return AppLeftoverPathDisposition::Blocked(block.message);
}
}
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
if crate::safety::is_reparse_like(&metadata) {
return AppLeftoverPathDisposition::Blocked(
"reparse-point traversal is disabled".to_string(),
);
}
AppLeftoverPathDisposition::Allowed
}
Err(_) => AppLeftoverPathDisposition::Missing,
}
}
pub fn assess_relative_target_shape(&self, path: &Path) -> ProtectionAssessment {
let normalized = normalize_shape_path(path);
if normalized.is_empty() {
return blocked(
ProtectionBlockKind::EmptyPath,
"empty relative target is not allowed".to_string(),
);
}
if looks_absolute_shape(&normalized) {
return blocked(
ProtectionBlockKind::FilesystemRoot,
"relative target must not be absolute".to_string(),
);
}
if contains_relative_control_segment(&normalized) {
return blocked(
ProtectionBlockKind::PathTraversal,
"relative target must not contain current or parent directory segments".to_string(),
);
}
ProtectionAssessment::Allowed
}
pub fn assess_catalog_target_shape(&self, target: &RuleTargetSpec) -> ProtectionAssessment {
match target {
RuleTargetSpec::Template(template) | RuleTargetSpec::GlobTemplate(template) => {
self.assess_path(Path::new(template.raw()))
}
RuleTargetSpec::ExactPath(path) => self.assess_path(path),
RuleTargetSpec::SteamInstallTemplate(template) => self.assess_steam_catalog_shape(
"Steam install",
template.raw(),
is_allowed_steam_install_catalog_shape,
),
RuleTargetSpec::SteamLibraryTemplate(template) => self.assess_steam_catalog_shape(
"Steam library",
template.raw(),
is_allowed_steam_library_catalog_shape,
),
}
}
fn protected_storage_overlap(&self, path: &Path) -> Option<&'a AppStorageEntry> {
self.protected_storage?
.iter()
.find(|entry| paths_overlap(path, &entry.path))
}
fn protected_path_overlap(&self, path: &Path) -> Option<&'a PathBuf> {
self.protected_paths?
.iter()
.find(|protected_path| paths_overlap(path, protected_path))
}
fn assess_steam_catalog_shape(
&self,
scope: &'static str,
raw: &str,
is_allowlisted: fn(&str) -> bool,
) -> ProtectionAssessment {
let path = Path::new(raw);
if let ProtectionAssessment::Blocked(block) = self.assess_relative_target_shape(path) {
return ProtectionAssessment::Blocked(block);
}
let normalized = normalize_raw_shape(raw);
if is_allowlisted(&normalized) {
return ProtectionAssessment::Allowed;
}
blocked(
ProtectionBlockKind::ProtectedCategory(ProtectedCategory::ApplicationDurableData),
format!("{scope} target {raw} is not an allowlisted maintenance path"),
)
}
}
impl Default for ProtectionPolicy<'_> {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppLeftoverPathDisposition {
Allowed,
Missing,
Blocked(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProtectionAssessment {
Allowed,
Blocked(ProtectionBlock),
}
impl ProtectionAssessment {
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Allowed)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtectionBlock {
pub kind: ProtectionBlockKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ProtectionBlockKind {
EmptyPath,
PathTraversal,
FilesystemRoot,
WindowsCriticalPath,
UserProfileRoot,
RebeccaOwnedStorage,
UserProtectedPath,
ProtectedCategory(ProtectedCategory),
}
impl ProtectionBlockKind {
pub fn label(self) -> &'static str {
match self {
Self::EmptyPath => "empty-path",
Self::PathTraversal => "path-traversal",
Self::FilesystemRoot => "filesystem-root",
Self::WindowsCriticalPath => "windows-critical-path",
Self::UserProfileRoot => "user-profile-root",
Self::RebeccaOwnedStorage => "rebecca-owned-storage",
Self::UserProtectedPath => "user-protected-path",
Self::ProtectedCategory(category) => category.label(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ProtectedCategory {
Credentials,
VpnProxyState,
AiToolDurableState,
BrowserPrivateData,
CloudSyncedData,
ContainerRuntimeState,
StartupAutomation,
ApplicationDurableData,
}
impl ProtectedCategory {
pub fn label(self) -> &'static str {
match self {
Self::Credentials => "credentials",
Self::VpnProxyState => "vpn-proxy-state",
Self::AiToolDurableState => "ai-tool-durable-state",
Self::BrowserPrivateData => "browser-private-data",
Self::CloudSyncedData => "cloud-synced-data",
Self::ContainerRuntimeState => "container-runtime-state",
Self::StartupAutomation => "startup-automation",
Self::ApplicationDurableData => "application-durable-data",
}
}
fn description(self) -> &'static str {
match self {
Self::Credentials => "credential and password-manager data",
Self::VpnProxyState => "VPN and proxy configuration",
Self::AiToolDurableState => "AI and coding tool durable state",
Self::BrowserPrivateData => "browser private data",
Self::CloudSyncedData => "cloud-synced user data",
Self::ContainerRuntimeState => "container and VM runtime state",
Self::StartupAutomation => "startup automation",
Self::ApplicationDurableData => "application durable data",
}
}
}
fn blocked(kind: ProtectionBlockKind, message: String) -> ProtectionAssessment {
ProtectionAssessment::Blocked(ProtectionBlock { kind, message })
}