openzeppelin_monitor/models/config/
monitor_config.rs

1//! Monitor configuration loading and validation.
2//!
3//! This module implements the ConfigLoader trait for Monitor configurations,
4//! allowing monitors to be loaded from JSON files.
5
6use 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	/// Resolve all secrets in the monitor configuration
18	async fn resolve_secrets(&self) -> Result<Self, ConfigError> {
19		dotenvy::dotenv().ok();
20		Ok(self.clone())
21	}
22
23	/// Load all monitor configurations from a directory
24	///
25	/// Reads and parses all JSON files in the specified directory (or default
26	/// config directory) as monitor configurations.
27	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			// Check monitor name uniqueness before pushing
82			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	/// Load a monitor configuration from a specific file
91	///
92	/// Reads and parses a single JSON file as a monitor configuration.
93	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		// Resolve secrets before validating
116		config = config.resolve_secrets().await?;
117
118		// Validate the config after loading
119		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	/// Validate the monitor configuration
134	fn validate(&self) -> Result<(), ConfigError> {
135		// Validate monitor name
136		if self.name.is_empty() {
137			return Err(ConfigError::validation_error(
138				"Monitor name is required",
139				None,
140				None,
141			));
142		}
143
144		// Validate networks
145		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		// Validate function signatures
154		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		// Validate event signatures
165		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		// Validate trigger conditions (focus on script path, timeout, and language)
176		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		// Log a warning if the monitor uses an insecure protocol
185		self.validate_protocol();
186
187		Ok(())
188	}
189
190	/// Validate the safety of the protocols used in the monitor
191	///
192	/// Returns if safe, or logs a warning message if unsafe.
193	fn validate_protocol(&self) {
194		// Check script file permissions on Unix systems
195		#[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		// Check monitor name uniqueness before pushing
218		if instances.iter().any(|existing_monitor| {
219			normalize_string(&existing_monitor.name) == normalize_string(&current_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		// Create a temporary directory and script file
408		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		// Set current directory to temp directory to make relative paths work
413		let original_dir = std::env::current_dir().unwrap();
414		std::env::set_current_dir(temp_dir.path()).unwrap();
415
416		// Test with valid script path
417		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		// Restore original directory
430		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		// Create a temporary directory and script file
447		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		// Set current directory to temp directory to make relative paths work
452		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		// Restore original directory
464		std::env::set_current_dir(original_dir).unwrap();
465		// Clean up temp directory
466		temp_dir.close().unwrap();
467	}
468
469	#[test]
470	fn test_validate_monitor_with_different_script_languages() {
471		// Create a temporary directory and script files
472		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		// Test each script language
484		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			// Test with mismatched extension
508			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		// TempDir will automatically clean up when dropped
526	}
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		// Test that loading from this path results in a file error
557		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		// Set overly permissive permissions (777)
581		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}