use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
mod migration;
pub use migration::{legacy_allowlist_path, try_migrate_legacy};
#[cfg(test)]
mod collision_tests;
#[cfg(test)]
mod tests;
pub const SENSITIVE_COMPONENT_NAMES: &[&str] = &[
".ssh",
".aws",
".gnupg",
".kube",
".netrc",
".npmrc",
".pypirc",
".pgpass",
".private",
".env",
"secrets",
"credentials",
"private_key",
".config",
"vault",
"keystore",
"Keychains",
];
pub const SENSITIVE_FILE_NAMES: &[&str] = &[".env"];
pub const SENSITIVE_PATH_PREFIXES: &[&str] = &[
"/tmp/",
"/private/tmp",
"/var/folders",
"/private/var/folders",
"/Library/Application Support",
];
pub const SENSITIVE_HOME_TOP_DIRS: &[&str] = &[
"", "Desktop",
"Downloads",
"Documents",
"Pictures",
"Movies",
"Music",
"Library",
];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AllowlistEntry {
pub path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub skip_kg: bool,
}
fn is_false(b: &bool) -> bool {
!b
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct AllowlistConfig {
#[serde(default, rename = "index")]
pub entries: Vec<AllowlistEntry>,
}
impl AllowlistConfig {
pub fn default_path() -> PathBuf {
match dirs::config_dir() {
Some(base) => base.join("trusty-search").join("allowlist.toml"),
None => PathBuf::from("trusty-search-allowlist.toml"),
}
}
pub fn load() -> Result<Self> {
let new_path = Self::default_path();
migration::try_migrate_legacy(&new_path, &migration::legacy_allowlist_path());
Self::load_from(&new_path)
}
pub fn load_from(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("could not read allowlist {}", path.display()))?;
if raw.trim().is_empty() {
return Ok(Self::default());
}
toml::from_str::<Self>(&raw)
.with_context(|| format!("could not parse allowlist TOML at {}", path.display()))
}
pub fn save(&self) -> Result<()> {
self.save_to(&Self::default_path())
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("could not create {}", parent.display()))?;
}
let toml_str =
toml::to_string_pretty(self).context("could not serialise allowlist as TOML")?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, &toml_str)
.with_context(|| format!("could not write {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("could not rename {} to {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn upsert(&mut self, entry: AllowlistEntry) {
let target = canonicalise(&entry.path);
if let Some(slot) = self
.entries
.iter_mut()
.find(|e| canonicalise(&e.path) == target)
{
*slot = entry;
} else {
self.entries.push(entry);
}
}
pub fn remove(&mut self, path: &Path) -> Option<AllowlistEntry> {
let target = canonicalise(path);
let pos = self
.entries
.iter()
.position(|e| canonicalise(&e.path) == target)?;
Some(self.entries.remove(pos))
}
pub fn contains(&self, path: &Path) -> bool {
let target = canonicalise(path);
self.entries.iter().any(|e| canonicalise(&e.path) == target)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum AllowlistCheck {
Allowed,
Denied { reason: String },
NotAllowlisted,
}
pub fn check_path(path: &Path, allowlist_path: Option<&Path>) -> Result<AllowlistCheck> {
if let Some(reason) = is_denied(path) {
return Ok(AllowlistCheck::Denied { reason });
}
let cfg = match allowlist_path {
Some(p) => AllowlistConfig::load_from(p)?,
None => AllowlistConfig::load()?,
};
if cfg.contains(path) {
Ok(AllowlistCheck::Allowed)
} else {
Ok(AllowlistCheck::NotAllowlisted)
}
}
pub fn add_to_allowlist(entry: AllowlistEntry, allowlist_path: Option<&Path>) -> Result<()> {
if let Some(reason) = is_denied(&entry.path) {
anyhow::bail!("cannot add to allowlist: {reason}");
}
let path = match allowlist_path {
Some(p) => p.to_path_buf(),
None => AllowlistConfig::default_path(),
};
let mut cfg = AllowlistConfig::load_from(&path)?;
cfg.upsert(entry);
cfg.save_to(&path)
}
pub fn remove_from_allowlist(path: &Path, allowlist_path: Option<&Path>) -> Result<()> {
let cfg_path = match allowlist_path {
Some(p) => p.to_path_buf(),
None => AllowlistConfig::default_path(),
};
let mut cfg = AllowlistConfig::load_from(&cfg_path)?;
cfg.remove(path);
cfg.save_to(&cfg_path)
}
pub fn is_denied(path: &Path) -> Option<String> {
let path_str = path.to_string_lossy();
let normalised = path_str.replace('\\', "/");
for &prefix in SENSITIVE_PATH_PREFIXES {
if normalised.starts_with(prefix) {
return Some(format!(
"path '{}' is under sensitive prefix '{}'; indexing refused",
path.display(),
prefix
));
}
}
for component in path.components() {
if let std::path::Component::Normal(os_name) = component {
let name = os_name.to_string_lossy();
if SENSITIVE_COMPONENT_NAMES.contains(&&*name) {
return Some(format!(
"path '{}' contains sensitive component '{}'; indexing refused",
path.display(),
name
));
}
}
}
if let Some(fname) = path.file_name() {
let name = fname.to_string_lossy();
if SENSITIVE_FILE_NAMES.contains(&&*name) {
return Some(format!(
"path '{}' has sensitive file name '{}'; indexing refused",
path.display(),
name
));
}
}
if let Some(home) = dirs::home_dir() {
let home_str = home.to_string_lossy().replace('\\', "/");
if let Some(rel) = normalised.strip_prefix(&*home_str) {
let segment = rel.trim_start_matches('/');
let first_component = segment.split('/').next().unwrap_or("");
if SENSITIVE_HOME_TOP_DIRS.contains(&first_component) {
return Some(format!(
"path '{}' is a sensitive home directory; indexing refused",
path.display()
));
}
}
}
None
}
fn canonicalise(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}