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).map_err(|e| {
48 format!("failed to parse trust.json at {}: {}", path.display(), e)
49 }),
50 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ProviderTrustState::default()),
51 Err(e) => Err(format!(
52 "failed to read trust.json at {}: {}",
53 path.display(),
54 e
55 )),
56 }
57}
58
59pub fn save_trust_state(state: &ProviderTrustState) -> Result<(), String> {
61 save_trust_state_to(&crate::config::base_dir(), state)
62}
63
64pub(crate) fn save_trust_state_to(base: &Path, state: &ProviderTrustState) -> Result<(), String> {
66 let path = trust_file_path_for(base);
67 let parent = path.parent().ok_or_else(|| {
68 format!("trust.json path has no parent: {}", path.display())
69 })?;
70 std::fs::create_dir_all(parent).map_err(|e| {
71 format!("failed to create dir {}: {}", parent.display(), e)
72 })?;
73 let serialized = serde_json::to_string_pretty(state)
74 .map_err(|e| format!("failed to serialize trust state: {}", e))?;
75
76 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
80 format!("failed to create temp file in {}: {}", parent.display(), e)
81 })?;
82 std::fs::write(tmp.path(), serialized.as_bytes()).map_err(|e| {
83 format!("failed to write {}: {}", tmp.path().display(), e)
84 })?;
85 std::fs::File::open(tmp.path())
87 .and_then(|f| f.sync_all())
88 .map_err(|e| format!("failed to fsync {}: {}", tmp.path().display(), e))?;
89 tmp.persist(&path).map_err(|e| {
90 format!(
91 "failed to rename {} -> {}: {}",
92 e.file.path().display(),
93 path.display(),
94 e.error,
95 )
96 })?;
97 Ok(())
98}
99
100pub fn is_provider_enabled(state: &ProviderTrustState, runtime_id: &str) -> bool {
103 match state.disabled.get(runtime_id) {
104 Some(entry) => !entry.disabled,
105 None => true,
106 }
107}
108
109pub fn disable_provider(
111 state: &mut ProviderTrustState,
112 runtime_id: &str,
113 reason: Option<String>,
114) {
115 state.disabled.insert(
116 runtime_id.to_string(),
117 ProviderTrustEntry {
118 disabled: true,
119 reason,
120 },
121 );
122}
123
124pub fn enable_provider(state: &mut ProviderTrustState, runtime_id: &str) {
126 state.disabled.remove(runtime_id);
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use tempfile::TempDir;
133
134 #[test]
135 fn enabled_by_default_when_entry_absent() {
136 let state = ProviderTrustState::default();
137 assert!(is_provider_enabled(&state, "plug:prov"));
138 }
139
140 #[test]
141 fn disabled_entry_makes_provider_not_enabled() {
142 let mut state = ProviderTrustState::default();
143 state.disabled.insert(
144 "plug:prov".to_string(),
145 ProviderTrustEntry {
146 disabled: true,
147 reason: None,
148 },
149 );
150 assert!(!is_provider_enabled(&state, "plug:prov"));
151 }
152
153 #[test]
154 fn disable_then_check() {
155 let mut state = ProviderTrustState::default();
156 disable_provider(&mut state, "plug:prov", Some("untrusted".into()));
157 assert!(!is_provider_enabled(&state, "plug:prov"));
158 let entry = state.disabled.get("plug:prov").unwrap();
159 assert!(entry.disabled);
160 assert_eq!(entry.reason.as_deref(), Some("untrusted"));
161 }
162
163 #[test]
164 fn enable_after_disable_removes_entry() {
165 let mut state = ProviderTrustState::default();
166 disable_provider(&mut state, "plug:prov", None);
167 enable_provider(&mut state, "plug:prov");
168 assert!(state.disabled.get("plug:prov").is_none());
169 assert!(is_provider_enabled(&state, "plug:prov"));
170 }
171
172 #[test]
173 fn load_from_missing_file_returns_default() {
174 let dir = TempDir::new().unwrap();
175 let state = load_trust_state_from(dir.path()).unwrap();
176 assert_eq!(state, ProviderTrustState::default());
177 }
178
179 #[test]
180 fn save_then_load_round_trip() {
181 let dir = TempDir::new().unwrap();
182 let mut state = ProviderTrustState::default();
183 disable_provider(&mut state, "plug:prov", Some("nope".into()));
184 disable_provider(&mut state, "other:thing", None);
185 save_trust_state_to(dir.path(), &state).unwrap();
186 let loaded = load_trust_state_from(dir.path()).unwrap();
187 assert_eq!(loaded, state);
188 }
189
190 #[test]
191 fn malformed_json_errors_with_context() {
192 let dir = TempDir::new().unwrap();
193 let path = trust_file_path_for(dir.path());
194 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
195 std::fs::write(&path, "{ this is not json").unwrap();
196 let err = load_trust_state_from(dir.path()).unwrap_err();
197 assert!(err.contains("trust.json"), "error should mention trust.json: {}", err);
198 }
199
200 #[test]
201 fn save_is_atomic_replacement() {
202 let dir = TempDir::new().unwrap();
203 let mut s1 = ProviderTrustState::default();
204 disable_provider(&mut s1, "a:b", None);
205 save_trust_state_to(dir.path(), &s1).unwrap();
206
207 let mut s2 = ProviderTrustState::default();
208 disable_provider(&mut s2, "c:d", Some("reason".into()));
209 disable_provider(&mut s2, "e:f", None);
210 save_trust_state_to(dir.path(), &s2).unwrap();
211
212 let loaded = load_trust_state_from(dir.path()).unwrap();
213 assert_eq!(loaded, s2);
214 let tmp = trust_file_path_for(dir.path()).with_extension("json.tmp");
216 assert!(!tmp.exists(), "temp file should not remain after rename");
217 }
218}