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 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	/// Resolve all secrets in the monitor configuration
18	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			// Decrypt the chain configuration for midnight viewing keys
24			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	/// Load all monitor configurations from a directory
46	///
47	/// Reads and parses all JSON files in the specified directory (or default
48	/// config directory) as monitor configurations.
49	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			// Check monitor name uniqueness before pushing
104			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	/// Load a monitor configuration from a specific file
113	///
114	/// Reads and parses a single JSON file as a monitor configuration.
115	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		// Resolve secrets before validating
138		config = config.resolve_secrets().await?;
139
140		// Validate the config after loading
141		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	/// Validate the monitor configuration
156	fn validate(&self) -> Result<(), ConfigError> {
157		// Validate monitor name
158		if self.name.is_empty() {
159			return Err(ConfigError::validation_error(
160				"Monitor name is required",
161				None,
162				None,
163			));
164		}
165
166		// Validate networks
167		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		// Validate function signatures
176		for func in &self.match_conditions.functions {
177			if !func.signature.contains('(') || !func.signature.contains(')') {
178				return Err(ConfigError::validation_error(
179					format!("Invalid function signature format: {}", func.signature),
180					None,
181					None,
182				));
183			}
184		}
185
186		// Validate event signatures
187		for event in &self.match_conditions.events {
188			if !event.signature.contains('(') || !event.signature.contains(')') {
189				return Err(ConfigError::validation_error(
190					format!("Invalid event signature format: {}", event.signature),
191					None,
192					None,
193				));
194			}
195		}
196
197		// Validate trigger conditions (focus on script path, timeout, and language)
198		for trigger_condition in &self.trigger_conditions {
199			validate_script_config(
200				&trigger_condition.script_path,
201				&trigger_condition.language,
202				&trigger_condition.timeout_ms,
203			)?;
204		}
205
206		// Log a warning if the monitor uses an insecure protocol
207		self.validate_protocol();
208
209		Ok(())
210	}
211
212	/// Validate the safety of the protocols used in the monitor
213	///
214	/// Returns if safe, or logs a warning message if unsafe.
215	fn validate_protocol(&self) {
216		// Check script file permissions on Unix systems
217		#[cfg(unix)]
218		for condition in &self.trigger_conditions {
219			use std::os::unix::fs::PermissionsExt;
220			if let Ok(metadata) = std::fs::metadata(&condition.script_path) {
221				let permissions = metadata.permissions();
222				let mode = permissions.mode();
223				if mode & 0o022 != 0 {
224					tracing::warn!(
225						"Monitor '{}' trigger conditions script file has overly permissive write permissions: {}. The recommended permissions are `644` (`rw-r--r--`)",
226						self.name,
227						condition.script_path
228					);
229				}
230			}
231		}
232	}
233
234	fn validate_uniqueness(
235		instances: &[&Self],
236		current_instance: &Self,
237		file_path: &str,
238	) -> Result<(), ConfigError> {
239		// Check monitor name uniqueness before pushing
240		if instances.iter().any(|existing_monitor| {
241			normalize_string(&existing_monitor.name) == normalize_string(&current_instance.name)
242		}) {
243			Err(ConfigError::validation_error(
244				format!("Duplicate monitor name found: '{}'", current_instance.name),
245				None,
246				Some(HashMap::from([
247					(
248						"monitor_name".to_string(),
249						current_instance.name.to_string(),
250					),
251					("path".to_string(), file_path.to_string()),
252				])),
253			))
254		} else {
255			Ok(())
256		}
257	}
258}
259
260#[cfg(test)]
261mod tests {
262	use super::*;
263	use crate::{
264		models::core::{ScriptLanguage, TransactionStatus},
265		utils::tests::builders::evm::monitor::MonitorBuilder,
266	};
267	use std::collections::HashMap;
268	use tempfile::TempDir;
269	use tracing_test::traced_test;
270
271	#[tokio::test]
272	async fn test_load_valid_monitor() {
273		let temp_dir = TempDir::new().unwrap();
274		let file_path = temp_dir.path().join("valid_monitor.json");
275
276		let valid_config = r#"{
277            "name": "TestMonitor",
278			"networks": ["ethereum_mainnet"],
279			"paused": false,
280			"addresses": [
281				{
282					"address": "0x0000000000000000000000000000000000000000",
283					"contract_spec": null
284				}
285			],
286            "match_conditions": {
287                "functions": [
288                    {"signature": "transfer(address,uint256)"}
289                ],
290                "events": [
291                    {"signature": "Transfer(address,address,uint256)"}
292                ],
293                "transactions": [
294					{
295						"status": "Success",
296						"expression": null
297					}
298                ]
299            },
300			"trigger_conditions": [],
301			"triggers": ["trigger1", "trigger2"]
302        }"#;
303
304		fs::write(&file_path, valid_config).unwrap();
305
306		let result = Monitor::load_from_path(&file_path).await;
307		assert!(result.is_ok());
308
309		let monitor = result.unwrap();
310		assert_eq!(monitor.name, "TestMonitor");
311	}
312
313	#[tokio::test]
314	async fn test_load_invalid_monitor() {
315		let temp_dir = TempDir::new().unwrap();
316		let file_path = temp_dir.path().join("invalid_monitor.json");
317
318		let invalid_config = r#"{
319            "name": "",
320            "description": "Invalid monitor configuration",
321            "match_conditions": {
322                "functions": [
323                    {"signature": "invalid_signature"}
324                ],
325                "events": []
326            }
327        }"#;
328
329		fs::write(&file_path, invalid_config).unwrap();
330
331		let result = Monitor::load_from_path(&file_path).await;
332		assert!(result.is_err());
333	}
334
335	#[tokio::test]
336	async fn test_load_all_monitors() {
337		let temp_dir = TempDir::new().unwrap();
338
339		let valid_config_1 = r#"{
340            "name": "TestMonitor1",
341			"networks": ["ethereum_mainnet"],
342			"paused": false,
343			"addresses": [
344				{
345					"address": "0x0000000000000000000000000000000000000000",
346					"contract_spec": null
347				}
348			],
349            "match_conditions": {
350                "functions": [
351                    {"signature": "transfer(address,uint256)"}
352                ],
353                "events": [
354                    {"signature": "Transfer(address,address,uint256)"}
355                ],
356                "transactions": [
357					{
358						"status": "Success",
359						"expression": null
360					}
361                ]
362            },
363			"trigger_conditions": [],
364			"triggers": ["trigger1", "trigger2"]
365        }"#;
366
367		let valid_config_2 = r#"{
368            "name": "TestMonitor2",
369			"networks": ["ethereum_mainnet"],
370			"paused": false,
371			"addresses": [
372				{
373					"address": "0x0000000000000000000000000000000000000000",
374					"contract_spec": null
375				}
376			],
377            "match_conditions": {
378                "functions": [
379                    {"signature": "transfer(address,uint256)"}
380                ],
381                "events": [
382                    {"signature": "Transfer(address,address,uint256)"}
383                ],
384                "transactions": [
385					{
386						"status": "Success",
387						"expression": null
388					}
389                ]
390            },
391			"trigger_conditions": [],
392			"triggers": ["trigger1", "trigger2"]
393        }"#;
394
395		fs::write(temp_dir.path().join("monitor1.json"), valid_config_1).unwrap();
396		fs::write(temp_dir.path().join("monitor2.json"), valid_config_2).unwrap();
397
398		let result: Result<HashMap<String, Monitor>, _> =
399			Monitor::load_all(Some(temp_dir.path())).await;
400		assert!(result.is_ok());
401
402		let monitors = result.unwrap();
403		assert_eq!(monitors.len(), 2);
404		assert!(monitors.contains_key("monitor1"));
405		assert!(monitors.contains_key("monitor2"));
406	}
407
408	#[test]
409	fn test_validate_monitor() {
410		let valid_monitor = MonitorBuilder::new()
411			.name("TestMonitor")
412			.networks(vec!["ethereum_mainnet".to_string()])
413			.address("0x0000000000000000000000000000000000000000")
414			.function("transfer(address,uint256)", None)
415			.event("Transfer(address,address,uint256)", None)
416			.transaction(TransactionStatus::Success, None)
417			.triggers(vec!["trigger1".to_string()])
418			.build();
419
420		assert!(valid_monitor.validate().is_ok());
421
422		let invalid_monitor = MonitorBuilder::new().name("").build();
423
424		assert!(invalid_monitor.validate().is_err());
425	}
426
427	#[test]
428	fn test_validate_monitor_with_trigger_conditions() {
429		// Create a temporary directory and script file
430		let temp_dir = TempDir::new().unwrap();
431		let script_path = temp_dir.path().join("test_script.py");
432		fs::write(&script_path, "print('test')").unwrap();
433
434		// Set current directory to temp directory to make relative paths work
435		let original_dir = std::env::current_dir().unwrap();
436		std::env::set_current_dir(temp_dir.path()).unwrap();
437
438		// Test with valid script path
439		let valid_monitor = MonitorBuilder::new()
440			.name("TestMonitor")
441			.networks(vec!["ethereum_mainnet".to_string()])
442			.address("0x0000000000000000000000000000000000000000")
443			.function("transfer(address,uint256)", None)
444			.event("Transfer(address,address,uint256)", None)
445			.transaction(TransactionStatus::Success, None)
446			.trigger_condition("test_script.py", 1000, ScriptLanguage::Python, None)
447			.build();
448
449		assert!(valid_monitor.validate().is_ok());
450
451		// Restore original directory
452		std::env::set_current_dir(original_dir).unwrap();
453	}
454
455	#[test]
456	fn test_validate_monitor_with_invalid_script_path() {
457		let invalid_monitor = MonitorBuilder::new()
458			.name("TestMonitor")
459			.networks(vec!["ethereum_mainnet".to_string()])
460			.trigger_condition("non_existent_script.py", 1000, ScriptLanguage::Python, None)
461			.build();
462
463		assert!(invalid_monitor.validate().is_err());
464	}
465
466	#[test]
467	fn test_validate_monitor_with_timeout_zero() {
468		// Create a temporary directory and script file
469		let temp_dir = TempDir::new().unwrap();
470		let script_path = temp_dir.path().join("test_script.py");
471		fs::write(&script_path, "print('test')").unwrap();
472
473		// Set current directory to temp directory to make relative paths work
474		let original_dir = std::env::current_dir().unwrap();
475		std::env::set_current_dir(temp_dir.path()).unwrap();
476
477		let invalid_monitor = MonitorBuilder::new()
478			.name("TestMonitor")
479			.networks(vec!["ethereum_mainnet".to_string()])
480			.trigger_condition("test_script.py", 0, ScriptLanguage::Python, None)
481			.build();
482
483		assert!(invalid_monitor.validate().is_err());
484
485		// Restore original directory
486		std::env::set_current_dir(original_dir).unwrap();
487		// Clean up temp directory
488		temp_dir.close().unwrap();
489	}
490
491	#[test]
492	fn test_validate_monitor_with_different_script_languages() {
493		// Create a temporary directory and script files
494		let temp_dir = TempDir::new().unwrap();
495		let temp_path = temp_dir.path().to_owned();
496
497		let python_script = temp_path.join("test_script.py");
498		let js_script = temp_path.join("test_script.js");
499		let bash_script = temp_path.join("test_script.sh");
500
501		fs::write(&python_script, "print('test')").unwrap();
502		fs::write(&js_script, "console.log('test')").unwrap();
503		fs::write(&bash_script, "echo 'test'").unwrap();
504
505		// Test each script language
506		let test_cases = vec![
507			(ScriptLanguage::Python, python_script),
508			(ScriptLanguage::JavaScript, js_script),
509			(ScriptLanguage::Bash, bash_script),
510		];
511
512		for (language, script_path) in test_cases {
513			let language_clone = language.clone();
514			let script_path_clone = script_path.clone();
515
516			let monitor = MonitorBuilder::new()
517				.name("TestMonitor")
518				.networks(vec!["ethereum_mainnet".to_string()])
519				.trigger_condition(
520					&script_path_clone.to_string_lossy(),
521					1000,
522					language_clone,
523					None,
524				)
525				.build();
526
527			assert!(monitor.validate().is_ok());
528
529			// Test with mismatched extension
530			let wrong_path = temp_path.join("test_script.wrong");
531			fs::write(&wrong_path, "test content").unwrap();
532
533			let monitor_wrong_ext = MonitorBuilder::new()
534				.name("TestMonitor")
535				.networks(vec!["ethereum_mainnet".to_string()])
536				.trigger_condition(
537					&wrong_path.to_string_lossy(),
538					monitor.trigger_conditions[0].timeout_ms,
539					language,
540					monitor.trigger_conditions[0].arguments.clone(),
541				)
542				.build();
543
544			assert!(monitor_wrong_ext.validate().is_err());
545		}
546
547		// TempDir will automatically clean up when dropped
548	}
549	#[tokio::test]
550	async fn test_invalid_load_from_path() {
551		let path = Path::new("config/monitors/invalid.json");
552		assert!(matches!(
553			Monitor::load_from_path(path).await,
554			Err(ConfigError::FileError(_))
555		));
556	}
557
558	#[tokio::test]
559	async fn test_invalid_config_from_load_from_path() {
560		use std::io::Write;
561		use tempfile::NamedTempFile;
562
563		let mut temp_file = NamedTempFile::new().unwrap();
564		write!(temp_file, "{{\"invalid\": \"json").unwrap();
565
566		let path = temp_file.path();
567
568		assert!(matches!(
569			Monitor::load_from_path(path).await,
570			Err(ConfigError::ParseError(_))
571		));
572	}
573
574	#[tokio::test]
575	async fn test_load_all_directory_not_found() {
576		let non_existent_path = Path::new("non_existent_directory");
577
578		// Test that loading from this path results in a file error
579		let result: Result<HashMap<String, Monitor>, ConfigError> =
580			Monitor::load_all(Some(non_existent_path)).await;
581		assert!(matches!(result, Err(ConfigError::FileError(_))));
582
583		if let Err(ConfigError::FileError(err)) = result {
584			assert!(err.message.contains("monitors directory not found"));
585		}
586	}
587
588	#[cfg(unix)]
589	#[test]
590	#[traced_test]
591	fn test_validate_protocol_script_permissions() {
592		use std::fs::File;
593		use std::os::unix::fs::PermissionsExt;
594		use tempfile::TempDir;
595
596		let temp_dir = TempDir::new().unwrap();
597		let script_path = temp_dir.path().join("test_script.sh");
598		File::create(&script_path).unwrap();
599
600		// Set overly permissive permissions (777)
601		let metadata = std::fs::metadata(&script_path).unwrap();
602		let mut permissions = metadata.permissions();
603		permissions.set_mode(0o777);
604		std::fs::set_permissions(&script_path, permissions).unwrap();
605
606		let monitor = MonitorBuilder::new()
607			.name("TestMonitor")
608			.networks(vec!["ethereum_mainnet".to_string()])
609			.trigger_condition(
610				script_path.to_str().unwrap(),
611				1000,
612				ScriptLanguage::Bash,
613				None,
614			)
615			.build();
616
617		monitor.validate_protocol();
618		assert!(logs_contain(
619			"script file has overly permissive write permissions"
620		));
621	}
622
623	#[tokio::test]
624	async fn test_load_all_monitors_duplicate_name() {
625		let temp_dir = TempDir::new().unwrap();
626
627		let valid_config_1 = r#"{
628            "name": "TestMonitor",
629			"networks": ["ethereum_mainnet"],
630			"paused": false,
631			"addresses": [
632				{
633					"address": "0x0000000000000000000000000000000000000000",
634					"contract_spec": null
635				}
636			],
637            "match_conditions": {
638                "functions": [
639                    {"signature": "transfer(address,uint256)"}
640                ],
641                "events": [
642                    {"signature": "Transfer(address,address,uint256)"}
643                ],
644                "transactions": [
645					{
646						"status": "Success",
647						"expression": null
648					}
649                ]
650            },
651			"trigger_conditions": [],
652			"triggers": ["trigger1", "trigger2"]
653        }"#;
654
655		let valid_config_2 = r#"{
656            "name": "Testmonitor",
657			"networks": ["ethereum_mainnet"],
658			"paused": false,
659			"addresses": [
660				{
661					"address": "0x0000000000000000000000000000000000000000",
662					"contract_spec": null
663				}
664			],
665            "match_conditions": {
666                "functions": [
667                    {"signature": "transfer(address,uint256)"}
668                ],
669                "events": [
670                    {"signature": "Transfer(address,address,uint256)"}
671                ],
672                "transactions": [
673					{
674						"status": "Success",
675						"expression": null
676					}
677                ]
678            },
679			"trigger_conditions": [],
680			"triggers": ["trigger1", "trigger2"]
681        }"#;
682
683		fs::write(temp_dir.path().join("monitor1.json"), valid_config_1).unwrap();
684		fs::write(temp_dir.path().join("monitor2.json"), valid_config_2).unwrap();
685
686		let result: Result<HashMap<String, Monitor>, _> =
687			Monitor::load_all(Some(temp_dir.path())).await;
688
689		assert!(result.is_err());
690		if let Err(ConfigError::ValidationError(err)) = result {
691			assert!(err.message.contains("Duplicate monitor name found"));
692		}
693	}
694}