1use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
12pub struct ProviderTrustState {
13 #[serde(default)]
16 pub disabled: BTreeMap<String, ProviderTrustEntry>,
17}
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
20pub struct ProviderTrustEntry {
21 pub disabled: bool,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub reason: Option<String>,
24}
25
26pub fn trust_file_path() -> PathBuf {
29 trust_file_path_for(&crate::config::base_dir())
30}
31
32pub(crate) fn trust_file_path_for(base: &Path) -> PathBuf {
34 base.join("extensions").join("trust.json")
35}
36
37pub fn load_trust_state() -> Result<ProviderTrustState, String> {
40 load_trust_state_from(&crate::config::base_dir())
41}
42
43pub(crate) fn load_trust_state_from(base: &Path) -> Result<ProviderTrustState, String> {
45 let path = trust_file_path_for(base);
46 match std::fs::read_to_string(&path) {
47 Ok(contents) => serde_json::from_str(&contents)
48 .map_err(|e| format!("failed to parse trust.json at {}: {}", path.display(), e)),
49 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ProviderTrustState::default()),
50 Err(e) => Err(format!(
51 "failed to read trust.json at {}: {}",
52 path.display(),
53 e
54 )),
55 }
56}
57
58pub fn save_trust_state(state: &ProviderTrustState) -> Result<(), String> {
60 save_trust_state_to(&crate::config::base_dir(), state)
61}
62
63pub(crate) fn save_trust_state_to(base: &Path, state: &ProviderTrustState) -> Result<(), String> {
65 let path = trust_file_path_for(base);
66 let parent = path
67 .parent()
68 .ok_or_else(|| format!("trust.json path has no parent: {}", path.display()))?;
69 std::fs::create_dir_all(parent)
70 .map_err(|e| format!("failed to create dir {}: {}", parent.display(), e))?;
71 let serialized = serde_json::to_string_pretty(state)
72 .map_err(|e| format!("failed to serialize trust state: {}", e))?;
73
74 let tmp = tempfile::NamedTempFile::new_in(parent)
78 .map_err(|e| format!("failed to create temp file in {}: {}", parent.display(), e))?;
79 std::fs::write(tmp.path(), serialized.as_bytes())
80 .map_err(|e| format!("failed to write {}: {}", tmp.path().display(), e))?;
81 std::fs::File::open(tmp.path())
83 .and_then(|f| f.sync_all())
84 .map_err(|e| format!("failed to fsync {}: {}", tmp.path().display(), e))?;
85 tmp.persist(&path).map_err(|e| {
86 format!(
87 "failed to rename {} -> {}: {}",
88 e.file.path().display(),
89 path.display(),
90 e.error,
91 )
92 })?;
93 Ok(())
94}
95
96pub fn is_provider_enabled(state: &ProviderTrustState, runtime_id: &str) -> bool {
99 match state.disabled.get(runtime_id) {
100 Some(entry) => !entry.disabled,
101 None => true,
102 }
103}
104
105pub fn disable_provider(state: &mut ProviderTrustState, runtime_id: &str, reason: Option<String>) {
107 state.disabled.insert(
108 runtime_id.to_string(),
109 ProviderTrustEntry {
110 disabled: true,
111 reason,
112 },
113 );
114}
115
116pub fn enable_provider(state: &mut ProviderTrustState, runtime_id: &str) {
118 state.disabled.remove(runtime_id);
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use tempfile::TempDir;
125
126 #[test]
127 fn enabled_by_default_when_entry_absent() {
128 let state = ProviderTrustState::default();
129 assert!(is_provider_enabled(&state, "plug:prov"));
130 }
131
132 #[test]
133 fn disabled_entry_makes_provider_not_enabled() {
134 let mut state = ProviderTrustState::default();
135 state.disabled.insert(
136 "plug:prov".to_string(),
137 ProviderTrustEntry {
138 disabled: true,
139 reason: None,
140 },
141 );
142 assert!(!is_provider_enabled(&state, "plug:prov"));
143 }
144
145 #[test]
146 fn disable_then_check() {
147 let mut state = ProviderTrustState::default();
148 disable_provider(&mut state, "plug:prov", Some("untrusted".into()));
149 assert!(!is_provider_enabled(&state, "plug:prov"));
150 let entry = state.disabled.get("plug:prov").unwrap();
151 assert!(entry.disabled);
152 assert_eq!(entry.reason.as_deref(), Some("untrusted"));
153 }
154
155 #[test]
156 fn enable_after_disable_removes_entry() {
157 let mut state = ProviderTrustState::default();
158 disable_provider(&mut state, "plug:prov", None);
159 enable_provider(&mut state, "plug:prov");
160 assert!(!state.disabled.contains_key("plug:prov"));
161 assert!(is_provider_enabled(&state, "plug:prov"));
162 }
163
164 #[test]
165 fn load_from_missing_file_returns_default() {
166 let dir = TempDir::new().unwrap();
167 let state = load_trust_state_from(dir.path()).unwrap();
168 assert_eq!(state, ProviderTrustState::default());
169 }
170
171 #[test]
172 fn save_then_load_round_trip() {
173 let dir = TempDir::new().unwrap();
174 let mut state = ProviderTrustState::default();
175 disable_provider(&mut state, "plug:prov", Some("nope".into()));
176 disable_provider(&mut state, "other:thing", None);
177 save_trust_state_to(dir.path(), &state).unwrap();
178 let loaded = load_trust_state_from(dir.path()).unwrap();
179 assert_eq!(loaded, state);
180 }
181
182 #[test]
183 fn malformed_json_errors_with_context() {
184 let dir = TempDir::new().unwrap();
185 let path = trust_file_path_for(dir.path());
186 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
187 std::fs::write(&path, "{ this is not json").unwrap();
188 let err = load_trust_state_from(dir.path()).unwrap_err();
189 assert!(
190 err.contains("trust.json"),
191 "error should mention trust.json: {}",
192 err
193 );
194 }
195
196 #[test]
197 fn save_is_atomic_replacement() {
198 let dir = TempDir::new().unwrap();
199 let mut s1 = ProviderTrustState::default();
200 disable_provider(&mut s1, "a:b", None);
201 save_trust_state_to(dir.path(), &s1).unwrap();
202
203 let mut s2 = ProviderTrustState::default();
204 disable_provider(&mut s2, "c:d", Some("reason".into()));
205 disable_provider(&mut s2, "e:f", None);
206 save_trust_state_to(dir.path(), &s2).unwrap();
207
208 let loaded = load_trust_state_from(dir.path()).unwrap();
209 assert_eq!(loaded, s2);
210 let tmp = trust_file_path_for(dir.path()).with_extension("json.tmp");
212 assert!(!tmp.exists(), "temp file should not remain after rename");
213 }
214}