#![allow(dead_code)]
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::cache::tmp_path_for;
use crate::config::{DashboardConfig, WidgetConfig};
use crate::fetcher::{Registry, Safety};
use crate::payload::Payload;
const TRUST_ALL_ENV: &str = "SPLASHBOARD_TRUST_ALL";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustDecision {
ImplicitlyTrusted,
Trusted,
Untrusted,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustStore {
#[serde(default, rename = "entry")]
entries: Vec<TrustEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustEntry {
pub path: PathBuf,
pub sha256: String,
}
impl TrustStore {
pub fn load() -> Self {
store_path()
.and_then(|p| std::fs::read_to_string(&p).ok())
.and_then(|s| toml::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) -> io::Result<()> {
let Some(path) = store_path() else {
return Err(io::Error::other("could not resolve trust store path"));
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string(self).map_err(io::Error::other)?;
let tmp = tmp_path_for(&path);
std::fs::write(&tmp, body)?;
std::fs::rename(tmp, path)?;
Ok(())
}
pub fn decide(&self, ident: Option<(&Path, &str)>) -> TrustDecision {
if trust_all_override() {
return TrustDecision::ImplicitlyTrusted;
}
let Some((path, hash)) = ident else {
return TrustDecision::ImplicitlyTrusted;
};
let canon = canonicalize_or(path);
for entry in &self.entries {
if canonicalize_or(&entry.path) == canon && entry.sha256 == hash {
return TrustDecision::Trusted;
}
}
TrustDecision::Untrusted
}
pub fn trust(&mut self, path: &Path, hash: String) -> io::Result<()> {
let canon = path.canonicalize()?;
self.entries.retain(|e| canonicalize_or(&e.path) != canon);
self.entries.push(TrustEntry {
path: canon,
sha256: hash,
});
self.save()
}
pub fn revoke(&mut self, path: &Path) -> io::Result<bool> {
let canon = canonicalize_or(path);
let before = self.entries.len();
self.entries.retain(|e| canonicalize_or(&e.path) != canon);
let removed = self.entries.len() < before;
if removed {
self.save()?;
}
Ok(removed)
}
pub fn list(&self) -> &[TrustEntry] {
&self.entries
}
}
pub fn load_dashboard_and_hash(path: &Path) -> io::Result<(DashboardConfig, String)> {
let bytes = std::fs::read(path)?;
let hash = hash_bytes(&bytes);
let text = std::str::from_utf8(&bytes).map_err(io::Error::other)?;
let dashboard = DashboardConfig::parse(text).map_err(io::Error::other)?;
Ok((dashboard, hash))
}
pub fn hash_file(path: &Path) -> io::Result<String> {
Ok(hash_bytes(&std::fs::read(path)?))
}
pub fn hash_bytes(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn partition_by_trust(
widgets: &[WidgetConfig],
registry: &Registry,
decision: TrustDecision,
) -> (Vec<WidgetConfig>, Vec<WidgetConfig>) {
if !matches!(decision, TrustDecision::Untrusted) {
return (widgets.to_vec(), Vec::new());
}
widgets
.iter()
.cloned()
.partition(|w| match registry.get(&w.fetcher) {
Some(f) => matches!(f.safety(), Safety::Safe),
None => true,
})
}
pub fn requires_trust_placeholder() -> Payload {
Payload {
icon: None,
status: Some(crate::payload::Status::Error),
format: None,
body: crate::payload::Body::Error(crate::payload::ErrorData {
kind: crate::payload::ErrorKind::RequiresTrust,
message: "🔒 requires trust".into(),
detail: Some("run `splashboard trust`".into()),
}),
}
}
fn trust_all_override() -> bool {
matches!(
std::env::var(TRUST_ALL_ENV).ok().as_deref(),
Some("1") | Some("true")
)
}
fn canonicalize_or(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn store_path() -> Option<PathBuf> {
crate::paths::trust_store_path()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fetcher::{FetchContext, FetchError, Fetcher, RealtimeFetcher};
use crate::render::Shape;
use async_trait::async_trait;
use std::ffi::{OsStr, OsString};
use std::sync::Arc;
struct FakeFetcher {
name: &'static str,
safety: Safety,
}
#[async_trait]
impl Fetcher for FakeFetcher {
fn name(&self) -> &str {
self.name
}
fn safety(&self) -> Safety {
self.safety
}
fn description(&self) -> &'static str {
"test fixture"
}
fn refresh_interval(&self) -> u64 {
60
}
fn shapes(&self) -> &[Shape] {
&[Shape::Text]
}
async fn fetch(&self, _: &FetchContext) -> Result<Payload, FetchError> {
unimplemented!("fake fetcher not invoked in tests")
}
}
struct FakeRealtime {
name: &'static str,
safety: Safety,
}
impl RealtimeFetcher for FakeRealtime {
fn name(&self) -> &str {
self.name
}
fn safety(&self) -> Safety {
self.safety
}
fn description(&self) -> &'static str {
"test fixture"
}
fn shapes(&self) -> &[Shape] {
&[Shape::Text]
}
fn compute(&self, _: &FetchContext) -> Payload {
unimplemented!("fake realtime not invoked in tests")
}
}
fn registry_with(fetchers: &[(&'static str, Safety)]) -> Registry {
let mut r = Registry::default();
for (name, safety) in fetchers {
r.register(Arc::new(FakeFetcher {
name,
safety: *safety,
}));
}
r
}
fn widget(id: &str, fetcher: &str) -> WidgetConfig {
WidgetConfig {
id: id.into(),
fetcher: fetcher.into(),
..Default::default()
}
}
struct EnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: Option<&OsStr>) -> Self {
let previous = std::env::var_os(key);
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner())
}
#[test]
fn hash_file_is_deterministic() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("c.toml");
std::fs::write(&p, "hello").unwrap();
let a = hash_file(&p).unwrap();
let b = hash_file(&p).unwrap();
assert_eq!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn hash_file_changes_when_contents_change() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("c.toml");
std::fs::write(&p, "hello").unwrap();
let before = hash_file(&p).unwrap();
std::fs::write(&p, "world").unwrap();
let after = hash_file(&p).unwrap();
assert_ne!(before, after);
}
#[test]
fn load_dashboard_and_hash_returns_matching_hash() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".splashboard.toml");
std::fs::write(&p, "").unwrap();
let (_cfg, hash) = load_dashboard_and_hash(&p).unwrap();
assert_eq!(hash, hash_file(&p).unwrap());
}
#[test]
fn load_dashboard_and_hash_rejects_invalid_utf8() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".splashboard.toml");
std::fs::write(&p, [0xff]).unwrap();
assert!(load_dashboard_and_hash(&p).is_err());
}
#[test]
fn load_dashboard_and_hash_rejects_invalid_toml() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".splashboard.toml");
std::fs::write(&p, "[widgets").unwrap();
assert!(load_dashboard_and_hash(&p).is_err());
}
#[test]
fn decide_none_ident_is_implicitly_trusted() {
let _lock = lock_env();
let _guard = EnvGuard::set(TRUST_ALL_ENV, None);
let store = TrustStore::default();
assert_eq!(store.decide(None), TrustDecision::ImplicitlyTrusted);
}
#[test]
fn decide_unlisted_local_config_is_untrusted() {
let _lock = lock_env();
let _guard = EnvGuard::set(TRUST_ALL_ENV, None);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".splashboard.toml");
std::fs::write(&p, "").unwrap();
let hash = hash_file(&p).unwrap();
let store = TrustStore::default();
assert_eq!(store.decide(Some((&p, &hash))), TrustDecision::Untrusted);
}
#[test]
fn decide_honors_trust_all_override() {
let _lock = lock_env();
let _guard = EnvGuard::set(TRUST_ALL_ENV, Some(OsStr::new("true")));
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".splashboard.toml");
std::fs::write(&p, "").unwrap();
let hash = hash_file(&p).unwrap();
let store = TrustStore::default();
assert_eq!(
store.decide(Some((&p, &hash))),
TrustDecision::ImplicitlyTrusted
);
}
#[test]
fn decide_trusted_after_explicit_trust() {
let _lock = lock_env();
let _guard = EnvGuard::set(TRUST_ALL_ENV, None);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".splashboard.toml");
std::fs::write(&p, "hello").unwrap();
let hash = hash_file(&p).unwrap();
let canon = p.canonicalize().unwrap();
let store = TrustStore {
entries: vec![TrustEntry {
path: canon,
sha256: hash.clone(),
}],
};
assert_eq!(store.decide(Some((&p, &hash))), TrustDecision::Trusted);
}
#[test]
fn decide_rejects_mismatched_hash_even_if_path_matches() {
let _lock = lock_env();
let _guard = EnvGuard::set(TRUST_ALL_ENV, None);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".splashboard.toml");
std::fs::write(&p, "original").unwrap();
let stored_hash = hash_file(&p).unwrap();
let store = TrustStore {
entries: vec![TrustEntry {
path: p.canonicalize().unwrap(),
sha256: stored_hash,
}],
};
let attacker_hash = "f".repeat(64);
assert_eq!(
store.decide(Some((&p, &attacker_hash))),
TrustDecision::Untrusted
);
}
#[test]
fn home_backed_source_is_implicitly_trusted_via_none_ident() {
let _lock = lock_env();
let _guard = EnvGuard::set(TRUST_ALL_ENV, None);
let store = TrustStore::default();
assert_eq!(store.decide(None), TrustDecision::ImplicitlyTrusted);
}
#[test]
fn partition_keeps_everything_when_trusted() {
let registry = registry_with(&[
("basic_static", Safety::Safe),
("github_prs", Safety::Network),
("plugin", Safety::Exec),
]);
let widgets = vec![
widget("g", "basic_static"),
widget("p", "github_prs"),
widget("x", "plugin"),
];
let (fetchable, gated) = partition_by_trust(&widgets, ®istry, TrustDecision::Trusted);
assert_eq!(fetchable.len(), 3);
assert!(gated.is_empty());
}
#[test]
fn partition_gates_network_and_exec_when_untrusted() {
let registry = registry_with(&[
("basic_static", Safety::Safe),
("github_prs", Safety::Network),
("plugin", Safety::Exec),
]);
let widgets = vec![
widget("g", "basic_static"),
widget("p", "github_prs"),
widget("x", "plugin"),
];
let (fetchable, gated) = partition_by_trust(&widgets, ®istry, TrustDecision::Untrusted);
let fetchable_ids: Vec<String> = fetchable.iter().map(|w| w.id.clone()).collect();
let gated_ids: Vec<String> = gated.iter().map(|w| w.id.clone()).collect();
assert_eq!(fetchable_ids, vec!["g".to_string()]);
assert!(gated_ids.iter().any(|s| s == "p"));
assert!(gated_ids.iter().any(|s| s == "x"));
}
#[test]
fn partition_treats_realtime_safe_fetcher_as_fetchable_when_untrusted() {
let mut registry = Registry::default();
registry.register_realtime(Arc::new(FakeRealtime {
name: "clock",
safety: Safety::Safe,
}));
let widgets = vec![widget("c", "clock")];
let (fetchable, gated) = partition_by_trust(&widgets, ®istry, TrustDecision::Untrusted);
assert_eq!(fetchable.len(), 1);
assert!(gated.is_empty());
}
#[test]
fn partition_keeps_unknown_fetchers_in_fetchable_when_untrusted() {
let registry = registry_with(&[("basic_static", Safety::Safe)]);
let widgets = vec![widget("x", "mystery")];
let (fetchable, gated) = partition_by_trust(&widgets, ®istry, TrustDecision::Untrusted);
assert_eq!(fetchable.len(), 1);
assert!(gated.is_empty());
}
#[test]
fn trust_store_round_trips_via_toml() {
let store = TrustStore {
entries: vec![TrustEntry {
path: PathBuf::from("/tmp/x/.splashboard.toml"),
sha256: "a".repeat(64),
}],
};
let s = toml::to_string(&store).unwrap();
let back: TrustStore = toml::from_str(&s).unwrap();
assert_eq!(back.entries, store.entries);
}
#[test]
fn load_returns_default_for_missing_or_corrupt_store() {
let _lock = lock_env();
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join(".splashboard");
let _guard = EnvGuard::set("SPLASHBOARD_HOME", Some(home.as_os_str()));
assert!(TrustStore::load().list().is_empty());
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("trust.toml"), "not = [valid").unwrap();
assert!(TrustStore::load().list().is_empty());
}
#[test]
fn save_and_load_round_trip_via_store_path() {
let _lock = lock_env();
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join(".splashboard");
let _guard = EnvGuard::set("SPLASHBOARD_HOME", Some(home.as_os_str()));
let store = TrustStore {
entries: vec![TrustEntry {
path: dir.path().join(".splashboard.toml"),
sha256: "a".repeat(64),
}],
};
store.save().unwrap();
assert!(home.join("trust.toml").exists());
assert_eq!(TrustStore::load().list(), store.list());
}
#[test]
fn trust_replaces_existing_entry_and_revoke_persists_removal() {
let _lock = lock_env();
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join(".splashboard");
let _guard = EnvGuard::set("SPLASHBOARD_HOME", Some(home.as_os_str()));
let dashboard = dir.path().join(".splashboard.toml");
let mut store = TrustStore::default();
std::fs::write(&dashboard, "alpha").unwrap();
store
.trust(&dashboard, hash_file(&dashboard).unwrap())
.unwrap();
std::fs::write(&dashboard, "beta").unwrap();
let latest_hash = hash_file(&dashboard).unwrap();
store.trust(&dashboard, latest_hash.clone()).unwrap();
assert_eq!(store.list().len(), 1);
assert_eq!(store.list()[0].path, dashboard.canonicalize().unwrap());
assert_eq!(store.list()[0].sha256, latest_hash);
assert_eq!(TrustStore::load().list(), store.list());
assert!(store.revoke(&dashboard).unwrap());
assert!(TrustStore::load().list().is_empty());
assert!(!store.revoke(&dir.path().join("missing.toml")).unwrap());
}
#[test]
fn placeholder_carries_lock_icon_and_trust_command() {
let p = requires_trust_placeholder();
match p.body {
crate::payload::Body::Error(err) => {
assert_eq!(err.kind, crate::payload::ErrorKind::RequiresTrust);
assert!(err.message.contains("🔒"));
assert!(
err.detail
.as_deref()
.is_some_and(|d| d.contains("splashboard trust"))
);
}
other => panic!("expected Body::Error, got {other:?}"),
}
assert_eq!(p.status, Some(crate::payload::Status::Error));
}
}