1use async_trait::async_trait;
7use std::{collections::HashMap, fs, path::Path};
8
9use crate::{
10 models::{config::error::ConfigError, ConfigLoader, Monitor},
11 services::trigger::validate_script_config,
12 utils::normalize_string,
13};
14
15#[async_trait]
16impl ConfigLoader for Monitor {
17 async fn resolve_secrets(&self) -> Result<Self, ConfigError> {
19 dotenvy::dotenv().ok();
20 Ok(self.clone())
21 }
22
23 async fn load_all<T>(path: Option<&Path>) -> Result<T, ConfigError>
28 where
29 T: FromIterator<(String, Self)>,
30 {
31 let monitor_dir = path.unwrap_or(Path::new("config/monitors"));
32 let mut pairs = Vec::new();
33
34 if !monitor_dir.exists() {
35 return Err(ConfigError::file_error(
36 "monitors directory not found",
37 None,
38 Some(HashMap::from([(
39 "path".to_string(),
40 monitor_dir.display().to_string(),
41 )])),
42 ));
43 }
44
45 for entry in fs::read_dir(monitor_dir).map_err(|e| {
46 ConfigError::file_error(
47 format!("failed to read monitors directory: {}", e),
48 Some(Box::new(e)),
49 Some(HashMap::from([(
50 "path".to_string(),
51 monitor_dir.display().to_string(),
52 )])),
53 )
54 })? {
55 let entry = entry.map_err(|e| {
56 ConfigError::file_error(
57 format!("failed to read directory entry: {}", e),
58 Some(Box::new(e)),
59 Some(HashMap::from([(
60 "path".to_string(),
61 monitor_dir.display().to_string(),
62 )])),
63 )
64 })?;
65 let path = entry.path();
66
67 if !Self::is_json_file(&path) {
68 continue;
69 }
70
71 let name = path
72 .file_stem()
73 .and_then(|s| s.to_str())
74 .unwrap_or("unknown")
75 .to_string();
76
77 let monitor = Self::load_from_path(&path).await?;
78
79 let existing_monitors: Vec<&Monitor> =
80 pairs.iter().map(|(_, monitor)| monitor).collect();
81 Self::validate_uniqueness(&existing_monitors, &monitor, &path.display().to_string())?;
83
84 pairs.push((name, monitor));
85 }
86
87 Ok(T::from_iter(pairs))
88 }
89
90 async fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
94 let file = std::fs::File::open(path).map_err(|e| {
95 ConfigError::file_error(
96 format!("failed to open monitor config file: {}", e),
97 Some(Box::new(e)),
98 Some(HashMap::from([(
99 "path".to_string(),
100 path.display().to_string(),
101 )])),
102 )
103 })?;
104 let mut config: Monitor = serde_json::from_reader(file).map_err(|e| {
105 ConfigError::parse_error(
106 format!("failed to parse monitor config: {}", e),
107 Some(Box::new(e)),
108 Some(HashMap::from([(
109 "path".to_string(),
110 path.display().to_string(),
111 )])),
112 )
113 })?;
114
115 config = config.resolve_secrets().await?;
117
118 config.validate().map_err(|e| {
120 ConfigError::validation_error(
121 format!("monitor validation failed: {}", e),
122 Some(Box::new(e)),
123 Some(HashMap::from([
124 ("path".to_string(), path.display().to_string()),
125 ("monitor_name".to_string(), config.name.clone()),
126 ])),
127 )
128 })?;
129
130 Ok(config)
131 }
132
133 fn validate(&self) -> Result<(), ConfigError> {
135 if self.name.is_empty() {
137 return Err(ConfigError::validation_error(
138 "Monitor name is required",
139 None,
140 None,
141 ));
142 }
143
144 if self.networks.is_empty() {
146 return Err(ConfigError::validation_error(
147 "At least one network must be specified",
148 None,
149 None,
150 ));
151 }
152
153 for func in &self.match_conditions.functions {
155 if !func.signature.contains('(') || !func.signature.contains(')') {
156 return Err(ConfigError::validation_error(
157 format!("Invalid function signature format: {}", func.signature),
158 None,
159 None,
160 ));
161 }
162 }
163
164 for event in &self.match_conditions.events {
166 if !event.signature.contains('(') || !event.signature.contains(')') {
167 return Err(ConfigError::validation_error(
168 format!("Invalid event signature format: {}", event.signature),
169 None,
170 None,
171 ));
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 use crate::models::{MatchConditions, TriggerConditions};
575
576 let temp_dir = TempDir::new().unwrap();
577 let script_path = temp_dir.path().join("test_script.sh");
578 File::create(&script_path).unwrap();
579
580 let metadata = std::fs::metadata(&script_path).unwrap();
582 let mut permissions = metadata.permissions();
583 permissions.set_mode(0o777);
584 std::fs::set_permissions(&script_path, permissions).unwrap();
585
586 let monitor = Monitor {
587 name: "TestMonitor".to_string(),
588 networks: vec!["ethereum_mainnet".to_string()],
589 paused: false,
590 addresses: vec![],
591 match_conditions: MatchConditions {
592 functions: vec![],
593 events: vec![],
594 transactions: vec![],
595 },
596 trigger_conditions: vec![TriggerConditions {
597 script_path: script_path.to_str().unwrap().to_string(),
598 timeout_ms: 1000,
599 arguments: None,
600 language: ScriptLanguage::Bash,
601 }],
602 triggers: vec![],
603 };
604
605 monitor.validate_protocol();
606 assert!(logs_contain(
607 "script file has overly permissive write permissions"
608 ));
609 }
610
611 #[tokio::test]
612 async fn test_load_all_monitors_duplicate_name() {
613 let temp_dir = TempDir::new().unwrap();
614
615 let valid_config_1 = r#"{
616 "name": "TestMonitor",
617 "networks": ["ethereum_mainnet"],
618 "paused": false,
619 "addresses": [
620 {
621 "address": "0x0000000000000000000000000000000000000000",
622 "contract_spec": null
623 }
624 ],
625 "match_conditions": {
626 "functions": [
627 {"signature": "transfer(address,uint256)"}
628 ],
629 "events": [
630 {"signature": "Transfer(address,address,uint256)"}
631 ],
632 "transactions": [
633 {
634 "status": "Success",
635 "expression": null
636 }
637 ]
638 },
639 "trigger_conditions": [],
640 "triggers": ["trigger1", "trigger2"]
641 }"#;
642
643 let valid_config_2 = r#"{
644 "name": "Testmonitor",
645 "networks": ["ethereum_mainnet"],
646 "paused": false,
647 "addresses": [
648 {
649 "address": "0x0000000000000000000000000000000000000000",
650 "contract_spec": null
651 }
652 ],
653 "match_conditions": {
654 "functions": [
655 {"signature": "transfer(address,uint256)"}
656 ],
657 "events": [
658 {"signature": "Transfer(address,address,uint256)"}
659 ],
660 "transactions": [
661 {
662 "status": "Success",
663 "expression": null
664 }
665 ]
666 },
667 "trigger_conditions": [],
668 "triggers": ["trigger1", "trigger2"]
669 }"#;
670
671 fs::write(temp_dir.path().join("monitor1.json"), valid_config_1).unwrap();
672 fs::write(temp_dir.path().join("monitor2.json"), valid_config_2).unwrap();
673
674 let result: Result<HashMap<String, Monitor>, _> =
675 Monitor::load_all(Some(temp_dir.path())).await;
676
677 assert!(result.is_err());
678 if let Err(ConfigError::ValidationError(err)) = result {
679 assert!(err.message.contains("Duplicate monitor name found"));
680 }
681 }
682}