1use scc_plugin_api::{Permission, PluginManifest, PluginRequest, PluginResponse};
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12
13pub const MANIFEST_FILE: &str = "scc-plugin.toml";
15
16#[derive(Debug, Clone)]
17pub struct LoadedPlugin {
19 pub manifest: PluginManifest,
20 pub dir: PathBuf,
21 pub config: serde_json::Value,
22 pub grants: Vec<Permission>,
23}
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct PluginDiagnostic {
28 pub plugin: String,
29 pub operation: String,
30 pub error: String,
31 pub action: String,
32}
33
34#[derive(Debug, Clone, thiserror::Error)]
35pub enum HostError {
37 #[error("plugin {0}: {1}")]
38 Failed(String, String),
39 #[error("permission denied: plugin {0} lacks {1}")]
40 Denied(String, String),
41 #[error("no plugin provides operation {0}")]
42 NoProvider(String),
43 #[error("ambiguous: {0}")]
44 Ambiguous(String),
45 #[error("io: {0}")]
46 Io(String),
47 #[error("plugin {0} declares runtime wasm: no WASM host yet (see scc-plugin-api PLUGIN_WIT); use runtime.command process plugin instead")]
48 UnsupportedRuntime(String),
49}
50
51pub fn discover(repo_root: &std::path::Path) -> Vec<LoadedPlugin> {
53 let mut out = Vec::new();
54 let mut seen = std::collections::BTreeSet::new();
55 let mut dirs: Vec<PathBuf> = Vec::new();
56 if let Ok(p) = std::env::var("SCC_PLUGIN_PATH") {
57 for part in std::env::split_paths(&p) { dirs.push(part); }
58 }
59 dirs.push(repo_root.join(".scc").join("plugins"));
60 if let Some(home) = dirs_home() { dirs.push(home.join(".config").join("scc").join("plugins")); }
61 for dir in dirs {
62 let Ok(entries) = std::fs::read_dir(&dir) else { continue; };
63 for entry in entries.flatten() {
64 let path = entry.path();
65 if !path.is_dir() { continue; }
66 let manifest_path = path.join(MANIFEST_FILE);
67 let Ok(text) = std::fs::read_to_string(&manifest_path) else { continue; };
68 let Ok(manifest) = PluginManifest::from_toml(&text) else { continue; };
69 if manifest.check_api_compatible().is_err() { continue; }
70 if !seen.insert(manifest.id.clone()) { continue; }
71 out.push(LoadedPlugin { manifest, dir: path, config: serde_json::json!({}), grants: Vec::new() });
74 }
75 }
76 out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
77 out
78}
79
80fn dirs_home() -> Option<PathBuf> {
82 std::env::var_os("HOME").map(PathBuf::from)
83}
84
85pub fn apply_grants(plugins: &mut [LoadedPlugin], grants: &BTreeMap<String, Vec<Permission>>) {
87 for p in plugins {
88 if let Some(g) = grants.get(&p.manifest.id) { p.grants = g.clone(); }
89 else { p.grants = p.manifest.permissions.clone(); }
90 }
91}
92
93pub fn provider_for<'a>(plugins: &'a [LoadedPlugin], operation: &str) -> Result<&'a LoadedPlugin, HostError> {
95 let mut found: Option<&LoadedPlugin> = None;
96 for p in plugins {
97 if p.manifest.operations.iter().any(|o| o == operation) {
98 if found.is_some() {
99 return Err(HostError::Ambiguous(format!("{operation} provided by multiple plugins")));
100 }
101 found = Some(p);
102 }
103 }
104 found.ok_or_else(|| HostError::NoProvider(operation.into()))
105}
106
107pub fn call(plugin: &LoadedPlugin, operation: &str, input: serde_json::Value, timeout_override_ms: Option<u64>) -> Result<serde_json::Value, HostError> {
112 require_grant(plugin, operation)?;
113 if plugin.manifest.runtime == scc_plugin_api::PluginRuntime::Wasm {
117 return Err(HostError::UnsupportedRuntime(plugin.manifest.id.clone()));
118 }
119 let req = PluginRequest { operation: operation.into(), input, config: plugin.config.clone() };
120 let body = serde_json::to_string(&req).map_err(|e| HostError::Failed(plugin.manifest.id.clone(), e.to_string()))?;
121 let timeout = std::time::Duration::from_millis(timeout_override_ms.unwrap_or(plugin.manifest.timeout_ms));
122 let mut child = std::process::Command::new(&plugin.manifest.command[0])
123 .args(&plugin.manifest.command[1..])
124 .current_dir(&plugin.dir)
125 .stdin(std::process::Stdio::piped())
126 .stdout(std::process::Stdio::piped())
127 .stderr(std::process::Stdio::null())
128 .spawn()
129 .map_err(|e| HostError::Failed(plugin.manifest.id.clone(), format!("spawn: {e}")))?;
130 use std::io::Write;
131 if let Some(mut stdin) = child.stdin.take() {
132 let _ = stdin.write_all(body.as_bytes());
133 }
134 let out = wait_with_timeout(&mut child, timeout).map_err(|e| HostError::Failed(plugin.manifest.id.clone(), e))?;
135 let resp: PluginResponse = serde_json::from_slice(&out).map_err(|e| HostError::Failed(plugin.manifest.id.clone(), format!("bad response: {e}")))?;
136 if let Some(err) = resp.error {
137 return Err(HostError::Failed(plugin.manifest.id.clone(), err));
138 }
139 Ok(resp.output)
140}
141
142fn require_grant(plugin: &LoadedPlugin, operation: &str) -> Result<(), HostError> {
144 if operation.starts_with("state.") {
147 let need = if operation.contains("write") || operation.contains("put") {
148 Permission::StateWrite
149 } else {
150 Permission::StateRead
151 };
152 if !plugin.grants.contains(&need) && !plugin.manifest.permissions.contains(&need) {
153 return Err(HostError::Denied(plugin.manifest.id.clone(), need.as_str().into()));
154 }
155 }
156 Ok(())
157}
158
159fn wait_with_timeout(child: &mut std::process::Child, timeout: std::time::Duration) -> Result<Vec<u8>, String> {
161 let start = std::time::Instant::now();
162 loop {
163 match child.try_wait() {
164 Ok(Some(status)) => {
165 let mut out = Vec::new();
166 if let Some(mut stdout) = child.stdout.take() {
167 use std::io::Read;
168 let _ = stdout.read_to_end(&mut out);
169 }
170 if !status.success() { return Err(format!("exit {}", status)); }
171 return Ok(out);
172 }
173 Ok(None) => {
174 if start.elapsed() > timeout {
175 let _ = child.kill();
176 let _ = child.wait();
177 return Err("timeout".into());
178 }
179 std::thread::sleep(std::time::Duration::from_millis(10));
180 }
181 Err(e) => return Err(format!("wait: {e}")),
182 }
183 }
184}
185pub fn lock_entry(p: &LoadedPlugin) -> serde_json::Value {
187 let mut h = blake3::Hasher::new();
188 h.update(p.manifest.id.as_bytes());
189 h.update(p.manifest.version.as_bytes());
190 h.update(p.manifest.api.as_bytes());
191 h.update(format!("{:?}", p.manifest.runtime).as_bytes());
192 let extensions: Vec<serde_json::Value> = p.manifest.extensions.iter().map(|e| {
193 h.update(e.canonical_id().as_bytes());
194 h.update(e.priority.to_string().as_bytes());
195 for x in e.after.iter().chain(e.before.iter()) { h.update(x.as_bytes()); }
196 serde_json::json!({"type": e.extension_type, "id": e.id, "priority": e.priority, "after": e.after, "before": e.before})
197 }).collect();
198 let config_str = serde_json::to_string(&p.config).unwrap_or_default();
203 h.update(config_str.as_bytes());
204 let mut grants: Vec<&str> = p.grants.iter().map(|x| x.as_str()).collect();
205 grants.sort();
206 for g in &grants {
207 h.update(g.as_bytes());
208 h.update(b"\0");
209 }
210 serde_json::json!({
211 "id": p.manifest.id,
212 "version": p.manifest.version,
213 "api": p.manifest.api,
214 "artifact_hash": format!("{}", h.finalize().to_hex()),
215 "operations": p.manifest.operations,
216 "permissions": p.manifest.permissions.iter().map(|x| x.as_str()).collect::<Vec<_>>(),
217 "grants": grants,
218 "config": p.config,
219 "runtime": format!("{:?}", p.manifest.runtime).to_lowercase(),
220 "extensions": extensions,
221 })
222}
223
224
225pub fn lockfile_path(repo_root: &std::path::Path) -> PathBuf {
234 repo_root.join(".scc").join("plugins.lock")
235}
236
237pub fn write_lockfile(repo_root: &std::path::Path, plugins: &[LoadedPlugin]) -> Result<PathBuf, String> {
239 let path = lockfile_path(repo_root);
240 if let Some(parent) = path.parent() {
241 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
242 }
243 let entries: Vec<serde_json::Value> = plugins.iter().map(lock_entry).collect();
244 let doc = serde_json::json!({"version": 1, "plugins": entries});
245 let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
246 let tmp = path.with_extension("lock.tmp");
247 std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
248 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
249 Ok(path)
250}
251
252pub fn read_lockfile(repo_root: &std::path::Path) -> Result<Vec<serde_json::Value>, String> {
254 let path = lockfile_path(repo_root);
255 let Ok(text) = std::fs::read_to_string(&path) else { return Ok(Vec::new()); };
256 let doc: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
257 Ok(doc.get("plugins").and_then(|v| v.as_array()).cloned().unwrap_or_default())
258}
259
260pub fn check_lockfile(repo_root: &std::path::Path, plugins: &[LoadedPlugin]) -> Result<(), String> {
265 let locked = read_lockfile(repo_root)?;
266 if locked.is_empty() {
267 return Ok(());
268 }
269 let live: std::collections::BTreeMap<String, serde_json::Value> = plugins
270 .iter()
271 .map(|p| (p.manifest.id.clone(), lock_entry(p)))
272 .collect();
273 let mut drifted = Vec::new();
274 for entry in &locked {
275 let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or("");
276 match live.get(id) {
277 None => drifted.push(format!("{id} (missing)")),
278 Some(cur) => {
279 let same = cur.get("version") == entry.get("version")
280 && cur.get("artifact_hash") == entry.get("artifact_hash");
281 if !same {
282 drifted.push(id.to_string());
283 }
284 }
285 }
286 }
287 if drifted.is_empty() {
288 Ok(())
289 } else {
290 Err(format!("plugin set drifted from .scc/plugins.lock: {}", drifted.join(", ")))
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 #[test]
297fn no_provider_is_not_ambiguous() {
299 let r = super::provider_for(&[], "acme.missing");
300 assert!(matches!(r, Err(super::HostError::NoProvider(_))));
301 }
302
303 #[test]
304fn state_op_without_grant_is_denied() {
306 let p = super::LoadedPlugin {
307 manifest: scc_plugin_api::PluginManifest {
308 id: "x".into(), name: "X".into(), version: "1".into(), api: "1".into(),
309 operations: vec![], permissions: vec![], timeout_ms: 50, runtime: Default::default(),
310 failure_policy: "warn".into(), deterministic: true, command: vec!["true".into()], extensions: vec![],
311 },
312 dir: std::path::PathBuf::from("."),
313 config: serde_json::json!({}),
314 grants: vec![],
315 };
316 assert!(super::call(&p, "state.get", serde_json::json!({}), None).is_err());
317 }
318}