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 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		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		// Set overly permissive permissions (777)
579		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}