use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core::trusty_tools_config::CRATE_NAME;
const TRUST_STORE_FILE: &str = "project-trust.json";
fn normalize(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
#[derive(Debug, Default)]
pub struct ProjectTrustStore {
trusted: BTreeSet<PathBuf>,
file_path: PathBuf,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct TrustStoreData {
#[serde(default)]
trusted_paths: Vec<PathBuf>,
}
impl ProjectTrustStore {
pub fn load(root: &Path) -> anyhow::Result<Self> {
let file_path = root.join(TRUST_STORE_FILE);
if !file_path.exists() {
return Ok(Self {
trusted: BTreeSet::new(),
file_path,
});
}
let raw = std::fs::read_to_string(&file_path)
.map_err(|e| anyhow::anyhow!("failed to read {}: {e}", file_path.display()))?;
let data: TrustStoreData = serde_json::from_str(&raw)
.map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", file_path.display()))?;
Ok(Self {
trusted: data.trusted_paths.into_iter().collect(),
file_path,
})
}
pub fn save(&self) -> anyhow::Result<()> {
if let Some(parent) = self.file_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("failed to create {}: {e}", parent.display()))?;
}
let data = TrustStoreData {
trusted_paths: self.trusted.iter().cloned().collect(),
};
let json = serde_json::to_string_pretty(&data)
.map_err(|e| anyhow::anyhow!("failed to serialize project-trust: {e}"))?;
let tmp_path = self.file_path.with_extension("json.tmp");
std::fs::write(&tmp_path, &json)
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", tmp_path.display()))?;
if let Err(e) = std::fs::rename(&tmp_path, &self.file_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(anyhow::anyhow!(
"failed to rename {} -> {}: {e}",
tmp_path.display(),
self.file_path.display()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.file_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| {
anyhow::anyhow!(
"failed to set owner-only permissions on {}: {e}",
self.file_path.display()
)
})?;
}
Ok(())
}
pub fn trust(&mut self, path: &Path) -> bool {
self.trusted.insert(normalize(path))
}
pub fn revoke(&mut self, path: &Path) -> bool {
self.trusted.remove(&normalize(path))
}
pub fn is_trusted(&self, path: &Path) -> bool {
self.trusted.contains(&normalize(path))
}
pub fn list(&self) -> Vec<PathBuf> {
self.trusted.iter().cloned().collect()
}
}
pub fn trust_store_root() -> Option<PathBuf> {
trusty_common::crate_config::crate_config_dir(CRATE_NAME)
}
pub fn is_project_trusted(project_path: &Path) -> bool {
match trust_store_root() {
Some(root) => is_project_trusted_at(project_path, &root),
None => {
tracing::warn!(
"skipping project-trust check: cannot resolve the home directory — \
treating project as untrusted (fail-closed)"
);
false
}
}
}
pub fn is_project_trusted_at(project_path: &Path, root: &Path) -> bool {
match ProjectTrustStore::load(root) {
Ok(store) => store.is_trusted(project_path),
Err(err) => {
tracing::warn!(
"failed to read project-trust store: {err} — treating project as untrusted \
(fail-closed)"
);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_store(dir: &TempDir) -> ProjectTrustStore {
ProjectTrustStore::load(dir.path()).expect("load")
}
#[test]
fn trust_store_new_is_empty() {
let root = TempDir::new().expect("tmpdir");
let store = make_store(&root);
assert!(store.list().is_empty());
}
#[test]
fn trust_inserts_new_entry() {
let root = TempDir::new().expect("tmpdir");
let project = TempDir::new().expect("project dir");
let mut store = make_store(&root);
assert!(store.trust(project.path()), "new trust should return true");
assert!(store.is_trusted(project.path()));
}
#[test]
fn trust_is_idempotent() {
let root = TempDir::new().expect("tmpdir");
let project = TempDir::new().expect("project dir");
let mut store = make_store(&root);
assert!(store.trust(project.path()));
let changed_again = store.trust(project.path());
assert!(!changed_again, "re-trusting the same path must be a no-op");
}
#[test]
fn revoke_removes_trust() {
let root = TempDir::new().expect("tmpdir");
let project = TempDir::new().expect("project dir");
let mut store = make_store(&root);
store.trust(project.path());
assert!(store.is_trusted(project.path()));
let revoked = store.revoke(project.path());
assert!(revoked, "revoke must report the change");
assert!(!store.is_trusted(project.path()));
}
#[test]
fn revoke_unknown_path_is_noop() {
let root = TempDir::new().expect("tmpdir");
let project = TempDir::new().expect("project dir");
let mut store = make_store(&root);
let revoked = store.revoke(project.path());
assert!(!revoked, "revoking an untrusted path must be a no-op");
}
#[test]
fn is_trusted_after_trust_and_revoke() {
let root = TempDir::new().expect("tmpdir");
let project_a = TempDir::new().expect("project a");
let project_b = TempDir::new().expect("project b");
let mut store = make_store(&root);
store.trust(project_a.path());
assert!(store.is_trusted(project_a.path()));
assert!(
!store.is_trusted(project_b.path()),
"an unrelated path must never read as trusted"
);
}
#[test]
fn save_and_reload_round_trip() {
let root = TempDir::new().expect("tmpdir");
let project = TempDir::new().expect("project dir");
{
let mut store = make_store(&root);
store.trust(project.path());
store.save().expect("save");
}
{
let store2 = make_store(&root);
assert!(store2.is_trusted(project.path()));
assert_eq!(store2.list().len(), 1);
}
}
#[test]
fn is_project_trusted_at_reflects_store() {
let root = TempDir::new().expect("tmpdir");
let project = TempDir::new().expect("project dir");
assert!(
!is_project_trusted_at(project.path(), root.path()),
"must be untrusted before any `trust` call"
);
let mut store = make_store(&root);
store.trust(project.path());
store.save().expect("save");
assert!(is_project_trusted_at(project.path(), root.path()));
}
#[test]
#[cfg(unix)]
fn save_sets_owner_only_perms_on_unix() {
use std::os::unix::fs::PermissionsExt;
let root = TempDir::new().expect("tmpdir");
let project = TempDir::new().expect("project dir");
let mut store = make_store(&root);
store.trust(project.path());
store.save().expect("save");
let meta = std::fs::metadata(root.path().join("project-trust.json")).expect("metadata");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"project-trust.json must be owner-read/write only, got {mode:o}"
);
}
#[test]
fn is_project_trusted_at_fails_closed_when_root_missing() {
let root = TempDir::new().expect("tmpdir");
std::fs::write(root.path().join("project-trust.json"), "not json").unwrap();
let project = TempDir::new().expect("project dir");
assert!(
!is_project_trusted_at(project.path(), root.path()),
"a malformed trust store must fail closed (untrusted), never panic or default open"
);
}
}