1use crate::{
7 models::{config::error::ConfigError, ConfigLoader, Monitor, SecretValue},
8 services::trigger::validate_script_config,
9 utils::normalize_string,
10};
11use async_trait::async_trait;
12use futures::TryStreamExt;
13use std::{collections::HashMap, fs, path::Path};
14
15#[async_trait]
16impl ConfigLoader for Monitor {
17 async fn resolve_secrets(&self) -> Result<Self, ConfigError> {
19 dotenvy::dotenv().ok();
20 let mut monitor = self.clone();
21
22 for chain_configuration in &mut monitor.chain_configurations {
23 if let Some(midnight) = &mut chain_configuration.midnight {
25 midnight.viewing_keys = midnight
26 .viewing_keys
27 .iter()
28 .map(|key| async {
29 key.resolve().await.map(SecretValue::Plain).map_err(|e| {
30 ConfigError::parse_error(
31 format!("failed to resolve viewing key: {}", e),
32 Some(Box::new(e)),
33 None,
34 )
35 })
36 })
37 .collect::<futures::stream::FuturesUnordered<_>>()
38 .try_collect()
39 .await?;
40 }
41 }
42 Ok(monitor)
43 }
44
45 async fn load_all<T>(path: Option<&Path>) -> Result<T, ConfigError>
50 where
51 T: FromIterator<(String, Self)>,
52 {
53 let monitor_dir = path.unwrap_or(Path::new("config/monitors"));
54 let mut pairs = Vec::new();
55
56 if !monitor_dir.exists() {
57 return Err(ConfigError::file_error(
58 "monitors directory not found",
59 None,
60 Some(HashMap::from([(
61 "path".to_string(),
62 monitor_dir.display().to_string(),
63 )])),
64 ));
65 }
66
67 for entry in fs::read_dir(monitor_dir).map_err(|e| {
68 ConfigError::file_error(
69 format!("failed to read monitors directory: {}", e),
70 Some(Box::new(e)),
71 Some(HashMap::from([(
72 "path".to_string(),
73 monitor_dir.display().to_string(),
74 )])),
75 )
76 })? {
77 let entry = entry.map_err(|e| {
78 ConfigError::file_error(
79 format!("failed to read directory entry: {}", e),
80 Some(Box::new(e)),
81 Some(HashMap::from([(
82 "path".to_string(),
83 monitor_dir.display().to_string(),
84 )])),
85 )
86 })?;
87 let path = entry.path();
88
89 if !Self::is_json_file(&path) {
90 continue;
91 }
92
93 let name = path
94 .file_stem()
95 .and_then(|s| s.to_str())
96 .unwrap_or("unknown")
97 .to_string();
98
99 let monitor = Self::load_from_path(&path).await?;
100
101 let existing_monitors: Vec<&Monitor> =
102 pairs.iter().map(|(_, monitor)| monitor).collect();
103 Self::validate_uniqueness(&existing_monitors, &monitor, &path.display().to_string())?;
105
106 pairs.push((name, monitor));
107 }
108
109 Ok(T::from_iter(pairs))
110 }
111
112 async fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
116 let file = std::fs::File::open(path).map_err(|e| {
117 ConfigError::file_error(
118 format!("failed to open monitor config file: {}", e),
119 Some(Box::new(e)),
120 Some(HashMap::from([(
121 "path".to_string(),
122 path.display().to_string(),
123 )])),
124 )
125 })?;
126 let mut config: Monitor = serde_json::from_reader(file).map_err(|e| {
127 ConfigError::parse_error(
128 format!("failed to parse monitor config: {}", e),
129 Some(Box::new(e)),
130 Some(HashMap::from([(
131 "path".to_string(),
132 path.display().to_string(),
133 )])),
134 )
135 })?;
136
137 config = config.resolve_secrets().await?;
139
140 config.validate().map_err(|e| {
142 ConfigError::validation_error(
143 format!("monitor validation failed: {}", e),
144 Some(Box::new(e)),
145 Some(HashMap::from([
146 ("path".to_string(), path.display().to_string()),
147 ("monitor_name".to_string(), config.name.clone()),
148 ])),
149 )
150 })?;
151
152 Ok(config)
153 }
154
155 fn validate(&self) -> Result<(), ConfigError> {
157 if self.name.is_empty() {
159 return Err(ConfigError::validation_error(
160 "Monitor name is required",
161 None,
162 None,
163 ));
164 }
165
166 if self.networks.is_empty() {
168 return Err(ConfigError::validation_error(
169 "At least one network must be specified",
170 None,
171 None,
172 ));
173 }
174
175 for trigger_condition in &self.trigger_conditions {
177 validate_script_config(
178 &trigger_condition.script_path,
179 &trigger_condition.language,
180 &trigger_condition.timeout_ms,
181 )?;
182 }
183
184 self.validate_protocol();
186
187 Ok(())
188 }
189
190 fn validate_protocol(&self) {
194 #[cfg(unix)]
196 for condition in &self.trigger_conditions {
197 use std::os::unix::fs::PermissionsExt;
198 if let Ok(metadata) = std::fs::metadata(&condition.script_path) {
199 let permissions = metadata.permissions();
200 let mode = permissions.mode();
201 if mode & 0o022 != 0 {
202 tracing::warn!(
203 "Monitor '{}' trigger conditions script file has overly permissive write permissions: {}. The recommended permissions are `644` (`rw-r--r--`)",
204 self.name,
205 condition.script_path
206 );
207 }
208 }
209 }
210 }
211
212 fn validate_uniqueness(
213 instances: &[&Self],
214 current_instance: &Self,
215 file_path: &str,
216 ) -> Result<(), ConfigError> {
217 if instances.iter().any(|existing_monitor| {
219 normalize_string(&existing_monitor.name) == normalize_string(¤t_instance.name)
220 }) {
221 Err(ConfigError::validation_error(
222 format!("Duplicate monitor name found: '{}'", current_instance.name),
223 None,
224 Some(HashMap::from([
225 (
226 "monitor_name".to_string(),
227 current_instance.name.to_string(),
228 ),
229 ("path".to_string(), file_path.to_string()),
230 ])),
231 ))
232 } else {
233 Ok(())
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::{
242 models::core::{ScriptLanguage, TransactionStatus},
243 utils::tests::builders::evm::monitor::MonitorBuilder,
244 };
245 use std::collections::HashMap;
246 use tempfile::TempDir;
247 use tracing_test::traced_test;
248
249 #[tokio::test]
250 async fn test_load_valid_monitor() {
251 let temp_dir = TempDir::new().unwrap();
252 let file_path = temp_dir.path().join("valid_monitor.json");
253
254 let valid_config = r#"{
255 "name": "TestMonitor",
256 "networks": ["ethereum_mainnet"],
257 "paused": false,
258 "addresses": [
259 {
260 "address": "0x0000000000000000000000000000000000000000",
261 "contract_spec": null
262 }
263 ],
264 "match_conditions": {
265 "functions": [
266 {"signature": "transfer(address,uint256)"}
267 ],
268 "events": [
269 {"signature": "Transfer(address,address,uint256)"}
270 ],
271 "transactions": [
272 {
273 "status": "Success",
274 "expression": null
275 }
276 ]
277 },
278 "trigger_conditions": [],
279 "triggers": ["trigger1", "trigger2"]
280 }"#;
281
282 fs::write(&file_path, valid_config).unwrap();
283
284 let result = Monitor::load_from_path(&file_path).await;
285 assert!(result.is_ok());
286
287 let monitor = result.unwrap();
288 assert_eq!(monitor.name, "TestMonitor");
289 }
290
291 #[tokio::test]
292 async fn test_load_invalid_monitor() {
293 let temp_dir = TempDir::new().unwrap();
294 let file_path = temp_dir.path().join("invalid_monitor.json");
295
296 let invalid_config = r#"{
297 "name": "",
298 "description": "Invalid monitor configuration",
299 "match_conditions": {
300 "functions": [
301 {"signature": "invalid_signature"}
302 ],
303 "events": []
304 }
305 }"#;
306
307 fs::write(&file_path, invalid_config).unwrap();
308
309 let result = Monitor::load_from_path(&file_path).await;
310 assert!(result.is_err());
311 }
312
313 #[tokio::test]
314 async fn test_load_all_monitors() {
315 let temp_dir = TempDir::new().unwrap();
316
317 let valid_config_1 = r#"{
318 "name": "TestMonitor1",
319 "networks": ["ethereum_mainnet"],
320 "paused": false,
321 "addresses": [
322 {
323 "address": "0x0000000000000000000000000000000000000000",
324 "contract_spec": null
325 }
326 ],
327 "match_conditions": {
328 "functions": [
329 {"signature": "transfer(address,uint256)"}
330 ],
331 "events": [
332 {"signature": "Transfer(address,address,uint256)"}
333 ],
334 "transactions": [
335 {
336 "status": "Success",
337 "expression": null
338 }
339 ]
340 },
341 "trigger_conditions": [],
342 "triggers": ["trigger1", "trigger2"]
343 }"#;
344
345 let valid_config_2 = r#"{
346 "name": "TestMonitor2",
347 "networks": ["ethereum_mainnet"],
348 "paused": false,
349 "addresses": [
350 {
351 "address": "0x0000000000000000000000000000000000000000",
352 "contract_spec": null
353 }
354 ],
355 "match_conditions": {
356 "functions": [
357 {"signature": "transfer(address,uint256)"}
358 ],
359 "events": [
360 {"signature": "Transfer(address,address,uint256)"}
361 ],
362 "transactions": [
363 {
364 "status": "Success",
365 "expression": null
366 }
367 ]
368 },
369 "trigger_conditions": [],
370 "triggers": ["trigger1", "trigger2"]
371 }"#;
372
373 fs::write(temp_dir.path().join("monitor1.json"), valid_config_1).unwrap();
374 fs::write(temp_dir.path().join("monitor2.json"), valid_config_2).unwrap();
375
376 let result: Result<HashMap<String, Monitor>, _> =
377 Monitor::load_all(Some(temp_dir.path())).await;
378 assert!(result.is_ok());
379
380 let monitors = result.unwrap();
381 assert_eq!(monitors.len(), 2);
382 assert!(monitors.contains_key("monitor1"));
383 assert!(monitors.contains_key("monitor2"));
384 }
385
386 #[test]
387 fn test_validate_monitor() {
388 let valid_monitor = MonitorBuilder::new()
389 .name("TestMonitor")
390 .networks(vec!["ethereum_mainnet".to_string()])
391 .address("0x0000000000000000000000000000000000000000")
392 .function("transfer(address,uint256)", None)
393 .event("Transfer(address,address,uint256)", None)
394 .transaction(TransactionStatus::Success, None)
395 .triggers(vec!["trigger1".to_string()])
396 .build();
397
398 assert!(valid_monitor.validate().is_ok());
399
400 let invalid_monitor = MonitorBuilder::new().name("").build();
401
402 assert!(invalid_monitor.validate().is_err());
403 }
404
405 #[test]
406 fn test_validate_monitor_with_trigger_conditions() {
407 let temp_dir = TempDir::new().unwrap();
409 let script_path = temp_dir.path().join("test_script.py");
410 fs::write(&script_path, "print('test')").unwrap();
411
412 let original_dir = std::env::current_dir().unwrap();
414 std::env::set_current_dir(temp_dir.path()).unwrap();
415
416 let valid_monitor = MonitorBuilder::new()
418 .name("TestMonitor")
419 .networks(vec!["ethereum_mainnet".to_string()])
420 .address("0x0000000000000000000000000000000000000000")
421 .function("transfer(address,uint256)", None)
422 .event("Transfer(address,address,uint256)", None)
423 .transaction(TransactionStatus::Success, None)
424 .trigger_condition("test_script.py", 1000, ScriptLanguage::Python, None)
425 .build();
426
427 assert!(valid_monitor.validate().is_ok());
428
429 std::env::set_current_dir(original_dir).unwrap();
431 }
432
433 #[test]
434 fn test_validate_monitor_with_invalid_script_path() {
435 let invalid_monitor = MonitorBuilder::new()
436 .name("TestMonitor")
437 .networks(vec!["ethereum_mainnet".to_string()])
438 .trigger_condition("non_existent_script.py", 1000, ScriptLanguage::Python, None)
439 .build();
440
441 assert!(invalid_monitor.validate().is_err());
442 }
443
444 #[test]
445 fn test_validate_monitor_with_timeout_zero() {
446 let temp_dir = TempDir::new().unwrap();
448 let script_path = temp_dir.path().join("test_script.py");
449 fs::write(&script_path, "print('test')").unwrap();
450
451 let original_dir = std::env::current_dir().unwrap();
453 std::env::set_current_dir(temp_dir.path()).unwrap();
454
455 let invalid_monitor = MonitorBuilder::new()
456 .name("TestMonitor")
457 .networks(vec!["ethereum_mainnet".to_string()])
458 .trigger_condition("test_script.py", 0, ScriptLanguage::Python, None)
459 .build();
460
461 assert!(invalid_monitor.validate().is_err());
462
463 std::env::set_current_dir(original_dir).unwrap();
465 temp_dir.close().unwrap();
467 }
468
469 #[test]
470 fn test_validate_monitor_with_different_script_languages() {
471 let temp_dir = TempDir::new().unwrap();
473 let temp_path = temp_dir.path().to_owned();
474
475 let python_script = temp_path.join("test_script.py");
476 let js_script = temp_path.join("test_script.js");
477 let bash_script = temp_path.join("test_script.sh");
478
479 fs::write(&python_script, "print('test')").unwrap();
480 fs::write(&js_script, "console.log('test')").unwrap();
481 fs::write(&bash_script, "echo 'test'").unwrap();
482
483 let test_cases = vec![
485 (ScriptLanguage::Python, python_script),
486 (ScriptLanguage::JavaScript, js_script),
487 (ScriptLanguage::Bash, bash_script),
488 ];
489
490 for (language, script_path) in test_cases {
491 let language_clone = language.clone();
492 let script_path_clone = script_path.clone();
493
494 let monitor = MonitorBuilder::new()
495 .name("TestMonitor")
496 .networks(vec!["ethereum_mainnet".to_string()])
497 .trigger_condition(
498 &script_path_clone.to_string_lossy(),
499 1000,
500 language_clone,
501 None,
502 )
503 .build();
504
505 assert!(monitor.validate().is_ok());
506
507 let wrong_path = temp_path.join("test_script.wrong");
509 fs::write(&wrong_path, "test content").unwrap();
510
511 let monitor_wrong_ext = MonitorBuilder::new()
512 .name("TestMonitor")
513 .networks(vec!["ethereum_mainnet".to_string()])
514 .trigger_condition(
515 &wrong_path.to_string_lossy(),
516 monitor.trigger_conditions[0].timeout_ms,
517 language,
518 monitor.trigger_conditions[0].arguments.clone(),
519 )
520 .build();
521
522 assert!(monitor_wrong_ext.validate().is_err());
523 }
524
525 }
527 #[tokio::test]
528 async fn test_invalid_load_from_path() {
529 let path = Path::new("config/monitors/invalid.json");
530 assert!(matches!(
531 Monitor::load_from_path(path).await,
532 Err(ConfigError::FileError(_))
533 ));
534 }
535
536 #[tokio::test]
537 async fn test_invalid_config_from_load_from_path() {
538 use std::io::Write;
539 use tempfile::NamedTempFile;
540
541 let mut temp_file = NamedTempFile::new().unwrap();
542 write!(temp_file, "{{\"invalid\": \"json").unwrap();
543
544 let path = temp_file.path();
545
546 assert!(matches!(
547 Monitor::load_from_path(path).await,
548 Err(ConfigError::ParseError(_))
549 ));
550 }
551
552 #[tokio::test]
553 async fn test_load_all_directory_not_found() {
554 let non_existent_path = Path::new("non_existent_directory");
555
556 let result: Result<HashMap<String, Monitor>, ConfigError> =
558 Monitor::load_all(Some(non_existent_path)).await;
559 assert!(matches!(result, Err(ConfigError::FileError(_))));
560
561 if let Err(ConfigError::FileError(err)) = result {
562 assert!(err.message.contains("monitors directory not found"));
563 }
564 }
565
566 #[cfg(unix)]
567 #[test]
568 #[traced_test]
569 fn test_validate_protocol_script_permissions() {
570 use std::fs::File;
571 use std::os::unix::fs::PermissionsExt;
572 use tempfile::TempDir;
573
574 let temp_dir = TempDir::new().unwrap();
575 let script_path = temp_dir.path().join("test_script.sh");
576 File::create(&script_path).unwrap();
577
578 let metadata = std::fs::metadata(&script_path).unwrap();
580 let mut permissions = metadata.permissions();
581 permissions.set_mode(0o777);
582 std::fs::set_permissions(&script_path, permissions).unwrap();
583
584 let monitor = MonitorBuilder::new()
585 .name("TestMonitor")
586 .networks(vec!["ethereum_mainnet".to_string()])
587 .trigger_condition(
588 script_path.to_str().unwrap(),
589 1000,
590 ScriptLanguage::Bash,
591 None,
592 )
593 .build();
594
595 monitor.validate_protocol();
596 assert!(logs_contain(
597 "script file has overly permissive write permissions"
598 ));
599 }
600
601 #[tokio::test]
602 async fn test_load_all_monitors_duplicate_name() {
603 let temp_dir = TempDir::new().unwrap();
604
605 let valid_config_1 = r#"{
606 "name": "TestMonitor",
607 "networks": ["ethereum_mainnet"],
608 "paused": false,
609 "addresses": [
610 {
611 "address": "0x0000000000000000000000000000000000000000",
612 "contract_spec": null
613 }
614 ],
615 "match_conditions": {
616 "functions": [
617 {"signature": "transfer(address,uint256)"}
618 ],
619 "events": [
620 {"signature": "Transfer(address,address,uint256)"}
621 ],
622 "transactions": [
623 {
624 "status": "Success",
625 "expression": null
626 }
627 ]
628 },
629 "trigger_conditions": [],
630 "triggers": ["trigger1", "trigger2"]
631 }"#;
632
633 let valid_config_2 = r#"{
634 "name": "Testmonitor",
635 "networks": ["ethereum_mainnet"],
636 "paused": false,
637 "addresses": [
638 {
639 "address": "0x0000000000000000000000000000000000000000",
640 "contract_spec": null
641 }
642 ],
643 "match_conditions": {
644 "functions": [
645 {"signature": "transfer(address,uint256)"}
646 ],
647 "events": [
648 {"signature": "Transfer(address,address,uint256)"}
649 ],
650 "transactions": [
651 {
652 "status": "Success",
653 "expression": null
654 }
655 ]
656 },
657 "trigger_conditions": [],
658 "triggers": ["trigger1", "trigger2"]
659 }"#;
660
661 fs::write(temp_dir.path().join("monitor1.json"), valid_config_1).unwrap();
662 fs::write(temp_dir.path().join("monitor2.json"), valid_config_2).unwrap();
663
664 let result: Result<HashMap<String, Monitor>, _> =
665 Monitor::load_all(Some(temp_dir.path())).await;
666
667 assert!(result.is_err());
668 if let Err(ConfigError::ValidationError(err)) = result {
669 assert!(err.message.contains("Duplicate monitor name found"));
670 }
671 }
672}