1use std::collections::HashMap;
20use std::path::PathBuf;
21use std::sync::{Arc, Mutex, PoisonError};
22use std::time::SystemTime;
23
24use leviath_providers::{ModelCapabilities, Provider, RateLimitConfig, RhaiProvider};
25
26#[derive(Clone, Debug, Default)]
30pub struct ScriptProviderSpec {
31 pub script: Option<String>,
33 pub rate_limit: Option<RateLimitConfig>,
35 pub init_config: serde_json::Value,
38}
39
40struct Cached {
42 mtime: SystemTime,
43 provider: Arc<dyn Provider>,
44}
45
46pub struct ScriptProviderLayer {
48 dir: PathBuf,
49 overrides: HashMap<String, ScriptProviderSpec>,
50 default_caps: HashMap<String, ModelCapabilities>,
51 request_timeout_secs: Option<u64>,
52 env_allowlist: Arc<Vec<String>>,
57 cache: Mutex<HashMap<String, Cached>>,
58}
59
60impl ScriptProviderLayer {
61 pub fn new(
64 dir: PathBuf,
65 overrides: HashMap<String, ScriptProviderSpec>,
66 default_caps: HashMap<String, ModelCapabilities>,
67 request_timeout_secs: Option<u64>,
68 env_allowlist: Vec<String>,
69 ) -> Self {
70 Self {
71 dir,
72 overrides,
73 default_caps,
74 request_timeout_secs,
75 env_allowlist: Arc::new(env_allowlist),
76 cache: Mutex::new(HashMap::new()),
77 }
78 }
79
80 fn resolve_path(&self, name: &str) -> Option<PathBuf> {
93 let stem = self
94 .overrides
95 .get(name)
96 .and_then(|s| s.script.as_deref())
97 .unwrap_or(name);
98 let candidate = PathBuf::from(stem);
99 if candidate.is_absolute() {
100 return Some(candidate);
101 }
102 let filename = match stem.ends_with(".rhai") {
103 true => stem.to_string(),
104 false => format!("{stem}.rhai"),
105 };
106 let joined = PathBuf::from(&filename);
110 if joined
111 .components()
112 .any(|c| matches!(c, std::path::Component::ParentDir))
113 {
114 return None;
115 }
116 Some(self.dir.join(joined))
117 }
118
119 pub fn get_or_load(&self, name: &str) -> Option<Arc<dyn Provider>> {
135 let Some(path) = self.resolve_path(name) else {
136 tracing::warn!(
137 provider = %name,
138 "script provider path escapes the providers directory - refusing to load"
139 );
140 self.evict(name);
141 return None;
142 };
143 let Some(mtime) = std::fs::metadata(&path).and_then(|m| m.modified()).ok() else {
144 self.evict(name);
146 return None;
147 };
148 if let Some(cached) = self.cached_fresh(name, mtime) {
149 return Some(cached);
150 }
151
152 let spec = self.overrides.get(name);
153 let init_config = spec
154 .map(|s| s.init_config.clone())
155 .unwrap_or_else(|| serde_json::json!({}));
156 let rate_limit = spec.and_then(|s| s.rate_limit.clone());
157 match RhaiProvider::from_script(
159 name.to_string(),
160 &path,
161 init_config,
162 self.default_caps.clone(),
163 rate_limit,
164 self.request_timeout_secs,
165 self.env_allowlist.clone(),
166 ) {
167 Ok(p) => {
168 let provider: Arc<dyn Provider> = Arc::new(p);
169 self.cache
170 .lock()
171 .unwrap_or_else(PoisonError::into_inner)
172 .insert(
173 name.to_string(),
174 Cached {
175 mtime,
176 provider: provider.clone(),
177 },
178 );
179 Some(provider)
180 }
181 Err(e) => {
182 self.evict(name);
183 tracing::warn!(provider = %name, error = %e, "script provider load failed");
184 None
185 }
186 }
187 }
188
189 fn cached_fresh(&self, name: &str, mtime: SystemTime) -> Option<Arc<dyn Provider>> {
193 let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
194 let cached = cache.get(name)?;
195 if cached.mtime == mtime {
196 Some(cached.provider.clone())
197 } else {
198 None
199 }
200 }
201
202 fn evict(&self, name: &str) {
204 self.cache
205 .lock()
206 .unwrap_or_else(PoisonError::into_inner)
207 .remove(name);
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use std::time::Duration;
215
216 const GOOD: &str = "fn initialize(config) { #{ base: config.base_url } }\n\
217 fn inference(state, request) { #{ content: \"ok\" } }";
218
219 fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
220 let path = dir.join(name);
221 std::fs::write(&path, body).unwrap();
222 path
223 }
224
225 fn bump_mtime(path: &std::path::Path) {
228 let later = SystemTime::now() + Duration::from_secs(5);
229 let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
230 f.set_modified(later).unwrap();
231 }
232
233 fn layer(dir: PathBuf) -> ScriptProviderLayer {
234 ScriptProviderLayer::new(dir, HashMap::new(), HashMap::new(), None, Vec::new())
235 }
236
237 #[test]
238 fn loads_by_convention_and_caches() {
239 let dir = tempfile::tempdir().unwrap();
240 write(dir.path(), "groq.rhai", GOOD);
241 let l = layer(dir.path().to_path_buf());
242 let first = l.get_or_load("groq").expect("loads");
243 assert_eq!(first.name(), "groq");
244 let second = l.get_or_load("groq").expect("cached");
246 assert!(Arc::ptr_eq(&first, &second));
247 }
248
249 #[test]
250 fn missing_file_is_none() {
251 let dir = tempfile::tempdir().unwrap();
252 let l = layer(dir.path().to_path_buf());
253 assert!(l.get_or_load("nope").is_none());
254 }
255
256 #[test]
257 fn hot_reload_on_mtime_change() {
258 let dir = tempfile::tempdir().unwrap();
259 let path = write(dir.path(), "p.rhai", GOOD);
260 let l = layer(dir.path().to_path_buf());
261 let first = l.get_or_load("p").unwrap();
262 write(dir.path(), "p.rhai", GOOD);
264 bump_mtime(&path);
265 let second = l.get_or_load("p").unwrap();
266 assert!(!Arc::ptr_eq(&first, &second));
267 }
268
269 #[test]
270 fn new_file_after_construction_loads() {
271 let dir = tempfile::tempdir().unwrap();
272 let l = layer(dir.path().to_path_buf());
273 assert!(l.get_or_load("late").is_none());
274 write(dir.path(), "late.rhai", GOOD);
275 assert!(l.get_or_load("late").is_some());
276 }
277
278 #[test]
279 fn deleted_file_evicts() {
280 let dir = tempfile::tempdir().unwrap();
281 let path = write(dir.path(), "gone.rhai", GOOD);
282 let l = layer(dir.path().to_path_buf());
283 assert!(l.get_or_load("gone").is_some());
284 std::fs::remove_file(&path).unwrap();
285 assert!(l.get_or_load("gone").is_none());
286 }
287
288 #[test]
289 fn broken_script_not_resolved() {
290 let dir = tempfile::tempdir().unwrap();
291 write(dir.path(), "bad.rhai", "fn inference( { oops");
292 let l = layer(dir.path().to_path_buf());
293 assert!(l.get_or_load("bad").is_none());
294 let path = write(dir.path(), "bad.rhai", GOOD);
296 bump_mtime(&path);
297 assert!(l.get_or_load("bad").is_some());
298 }
299
300 #[test]
301 fn resolve_path_honors_overrides() {
302 let dir = tempfile::tempdir().unwrap();
303 let abs = dir.path().join("elsewhere.rhai");
304 std::fs::write(&abs, GOOD).unwrap();
305 let mut overrides = HashMap::new();
306 overrides.insert(
308 "a".to_string(),
309 ScriptProviderSpec {
310 script: Some("custom".to_string()),
311 ..Default::default()
312 },
313 );
314 overrides.insert(
316 "b".to_string(),
317 ScriptProviderSpec {
318 script: Some("custom.rhai".to_string()),
319 ..Default::default()
320 },
321 );
322 overrides.insert(
324 "c".to_string(),
325 ScriptProviderSpec {
326 script: Some(abs.to_string_lossy().into_owned()),
327 ..Default::default()
328 },
329 );
330 let l = ScriptProviderLayer::new(
331 dir.path().to_path_buf(),
332 overrides,
333 HashMap::new(),
334 None,
335 Vec::new(),
336 );
337 assert_eq!(l.resolve_path("a"), Some(dir.path().join("custom.rhai")));
338 assert_eq!(l.resolve_path("b"), Some(dir.path().join("custom.rhai")));
339 assert_eq!(l.resolve_path("c"), Some(abs));
340 assert_eq!(l.resolve_path("z"), Some(dir.path().join("z.rhai")));
341 }
342
343 #[test]
348 fn relative_script_override_cannot_escape_the_providers_dir() {
349 let dir = tempfile::tempdir().unwrap();
350 let mut overrides = HashMap::new();
351 for (name, script) in [
352 ("a", "../../tools/evil"),
353 ("b", "../evil.rhai"),
354 ("c", "sub/../../evil"),
355 ] {
356 overrides.insert(
357 name.to_string(),
358 ScriptProviderSpec {
359 script: Some(script.to_string()),
360 ..Default::default()
361 },
362 );
363 }
364 let l = ScriptProviderLayer::new(
365 dir.path().to_path_buf(),
366 overrides,
367 HashMap::new(),
368 None,
369 Vec::new(),
370 );
371 for name in ["a", "b", "c"] {
372 assert_eq!(l.resolve_path(name), None, "{name} should be refused");
373 assert!(l.get_or_load(name).is_none(), "{name} must not load");
374 }
375 }
376
377 #[test]
379 fn nested_relative_override_inside_the_dir_still_resolves() {
380 let dir = tempfile::tempdir().unwrap();
381 let mut overrides = HashMap::new();
382 overrides.insert(
383 "a".to_string(),
384 ScriptProviderSpec {
385 script: Some("vendor/custom".to_string()),
386 ..Default::default()
387 },
388 );
389 let l = ScriptProviderLayer::new(
390 dir.path().to_path_buf(),
391 overrides,
392 HashMap::new(),
393 None,
394 Vec::new(),
395 );
396 assert_eq!(
397 l.resolve_path("a"),
398 Some(dir.path().join("vendor/custom.rhai"))
399 );
400 }
401
402 #[test]
403 fn init_config_reaches_the_script() {
404 let dir = tempfile::tempdir().unwrap();
405 write(
407 dir.path(),
408 "echo.rhai",
409 "fn initialize(config) { #{ b: config.base_url } }\n\
410 fn inference(state, request) { #{ content: state.b } }",
411 );
412 let mut overrides = HashMap::new();
413 overrides.insert(
414 "echo".to_string(),
415 ScriptProviderSpec {
416 init_config: serde_json::json!({ "base_url": "http://cfg" }),
417 ..Default::default()
418 },
419 );
420 let l = ScriptProviderLayer::new(
421 dir.path().to_path_buf(),
422 overrides,
423 HashMap::new(),
424 None,
425 Vec::new(),
426 );
427 assert!(l.get_or_load("echo").is_some());
428 }
429}