openzeppelin_monitor/models/config/
trigger_config.rs

1//! Trigger configuration loading and validation.
2//!
3//! This module implements the ConfigLoader trait for Trigger configurations,
4//! allowing triggers to be loaded from JSON files.
5
6use async_trait::async_trait;
7use email_address::EmailAddress;
8use serde::Deserialize;
9use std::{collections::HashMap, fs, path::Path};
10
11use crate::{
12	models::{
13		config::error::ConfigError, ConfigLoader, SecretValue, Trigger, TriggerType,
14		TriggerTypeConfig, WebhookPayloadMode,
15	},
16	services::trigger::validate_script_config,
17	utils::normalize_string,
18};
19
20const TELEGRAM_MAX_BODY_LENGTH: usize = 4096;
21const DISCORD_MAX_BODY_LENGTH: usize = 2000;
22
23/// File structure for trigger configuration files
24#[derive(Debug, Deserialize)]
25pub struct TriggerConfigFile {
26	/// Map of trigger names to their configurations
27	#[serde(flatten)]
28	pub triggers: HashMap<String, Trigger>,
29}
30
31#[async_trait]
32impl ConfigLoader for Trigger {
33	async fn resolve_secrets(&self) -> Result<Self, ConfigError> {
34		dotenvy::dotenv().ok();
35
36		let mut trigger = self.clone();
37
38		match &mut trigger.config {
39			TriggerTypeConfig::Slack { slack_url, .. } => {
40				let resolved_url = slack_url.resolve().await.map_err(|e| {
41					ConfigError::parse_error(
42						format!("failed to resolve Slack URL: {}", e),
43						Some(Box::new(e)),
44						None,
45					)
46				})?;
47				*slack_url = SecretValue::Plain(resolved_url);
48			}
49			TriggerTypeConfig::Email {
50				username, password, ..
51			} => {
52				let resolved_username = username.resolve().await.map_err(|e| {
53					ConfigError::parse_error(
54						format!("failed to resolve SMTP username: {}", e),
55						Some(Box::new(e)),
56						None,
57					)
58				})?;
59				*username = SecretValue::Plain(resolved_username);
60
61				let resolved_password = password.resolve().await.map_err(|e| {
62					ConfigError::parse_error(
63						format!("failed to resolve SMTP password: {}", e),
64						Some(Box::new(e)),
65						None,
66					)
67				})?;
68				*password = SecretValue::Plain(resolved_password);
69			}
70			TriggerTypeConfig::Webhook { url, secret, .. } => {
71				let resolved_url = url.resolve().await.map_err(|e| {
72					ConfigError::parse_error(
73						format!("failed to resolve webhook URL: {}", e),
74						Some(Box::new(e)),
75						None,
76					)
77				})?;
78				*url = SecretValue::Plain(resolved_url);
79
80				if let Some(secret) = secret {
81					let resolved_secret = secret.resolve().await.map_err(|e| {
82						ConfigError::parse_error(
83							format!("failed to resolve webhook secret: {}", e),
84							Some(Box::new(e)),
85							None,
86						)
87					})?;
88					*secret = SecretValue::Plain(resolved_secret);
89				}
90			}
91			TriggerTypeConfig::Telegram { token, .. } => {
92				let resolved_token = token.resolve().await.map_err(|e| {
93					ConfigError::parse_error(
94						format!("failed to resolve Telegram token: {}", e),
95						Some(Box::new(e)),
96						None,
97					)
98				})?;
99				*token = SecretValue::Plain(resolved_token);
100			}
101			TriggerTypeConfig::Discord { discord_url, .. } => {
102				let resolved_url = discord_url.resolve().await.map_err(|e| {
103					ConfigError::parse_error(
104						format!("failed to resolve Discord URL: {}", e),
105						Some(Box::new(e)),
106						None,
107					)
108				})?;
109				*discord_url = SecretValue::Plain(resolved_url);
110			}
111			_ => {}
112		}
113
114		Ok(trigger)
115	}
116
117	/// Load all trigger configurations from a directory
118	///
119	/// Reads and parses all JSON files in the specified directory (or default
120	/// config directory) as trigger configurations.
121	async fn load_all<T>(path: Option<&Path>) -> Result<T, ConfigError>
122	where
123		T: FromIterator<(String, Self)>,
124	{
125		let config_dir = path.unwrap_or(Path::new("config/triggers"));
126
127		if !config_dir.exists() {
128			return Err(ConfigError::file_error(
129				"triggers directory not found",
130				None,
131				Some(HashMap::from([(
132					"path".to_string(),
133					config_dir.display().to_string(),
134				)])),
135			));
136		}
137
138		let entries = fs::read_dir(config_dir).map_err(|e| {
139			ConfigError::file_error(
140				format!("failed to read triggers directory: {}", e),
141				Some(Box::new(e)),
142				Some(HashMap::from([(
143					"path".to_string(),
144					config_dir.display().to_string(),
145				)])),
146			)
147		})?;
148
149		let mut trigger_pairs = Vec::new();
150		for entry in entries {
151			let entry = entry.map_err(|e| {
152				ConfigError::file_error(
153					format!("failed to read directory entry: {}", e),
154					Some(Box::new(e)),
155					Some(HashMap::from([(
156						"path".to_string(),
157						config_dir.display().to_string(),
158					)])),
159				)
160			})?;
161			if Self::is_json_file(&entry.path()) {
162				let file_path = entry.path();
163				let content = fs::read_to_string(&file_path).map_err(|e| {
164					ConfigError::file_error(
165						format!("failed to read trigger config file: {}", e),
166						Some(Box::new(e)),
167						Some(HashMap::from([(
168							"path".to_string(),
169							file_path.display().to_string(),
170						)])),
171					)
172				})?;
173				let file_triggers: TriggerConfigFile =
174					serde_json::from_str(&content).map_err(|e| {
175						ConfigError::parse_error(
176							format!("failed to parse trigger config: {}", e),
177							Some(Box::new(e)),
178							Some(HashMap::from([(
179								"path".to_string(),
180								file_path.display().to_string(),
181							)])),
182						)
183					})?;
184
185				// Validate each trigger before adding it
186				for (name, mut trigger) in file_triggers.triggers {
187					// Resolve secrets before validating
188					trigger = trigger.resolve_secrets().await?;
189					if let Err(validation_error) = trigger.validate() {
190						return Err(ConfigError::validation_error(
191							format!(
192								"Validation failed for trigger '{}': {}",
193								name, validation_error
194							),
195							Some(Box::new(validation_error)),
196							Some(HashMap::from([
197								("path".to_string(), file_path.display().to_string()),
198								("trigger_name".to_string(), name.clone()),
199							])),
200						));
201					}
202
203					let existing_triggers: Vec<&Trigger> =
204						trigger_pairs.iter().map(|(_, trigger)| trigger).collect();
205					// Check trigger name uniqueness before pushing
206					Self::validate_uniqueness(
207						&existing_triggers,
208						&trigger,
209						&file_path.display().to_string(),
210					)?;
211
212					trigger_pairs.push((name, trigger));
213				}
214			}
215		}
216		Ok(T::from_iter(trigger_pairs))
217	}
218
219	/// Load a trigger configuration from a specific file
220	///
221	/// Reads and parses a single JSON file as a trigger configuration.
222	async fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
223		let file = std::fs::File::open(path)
224			.map_err(|e| ConfigError::file_error(e.to_string(), None, None))?;
225		let mut config: Trigger = serde_json::from_reader(file)
226			.map_err(|e| ConfigError::parse_error(e.to_string(), None, None))?;
227
228		// Resolve secrets before validating
229		config = config.resolve_secrets().await?;
230
231		// Validate the config after loading
232		config.validate()?;
233
234		Ok(config)
235	}
236
237	/// Validate the trigger configuration
238	///
239	/// Ensures that:
240	/// - The trigger has a valid name
241	/// - The trigger type is supported
242	/// - Required configuration fields for the trigger type are present
243	/// - URLs are valid for webhook and Slack triggers
244	/// - Script paths exist for script triggers
245	fn validate(&self) -> Result<(), ConfigError> {
246		// Validate trigger name
247		if self.name.is_empty() {
248			return Err(ConfigError::validation_error(
249				"Trigger cannot be empty",
250				None,
251				None,
252			));
253		}
254
255		match &self.trigger_type {
256			TriggerType::Slack => {
257				if let TriggerTypeConfig::Slack {
258					slack_url,
259					message,
260					retry_policy: _,
261				} = &self.config
262				{
263					// Validate webhook URL
264					if !slack_url.starts_with("https://hooks.slack.com/") {
265						return Err(ConfigError::validation_error(
266							"Invalid Slack webhook URL format",
267							None,
268							None,
269						));
270					}
271					// Validate message
272					if message.title.trim().is_empty() {
273						return Err(ConfigError::validation_error(
274							"Title cannot be empty",
275							None,
276							None,
277						));
278					}
279					// Validate template is not empty
280					if message.body.trim().is_empty() {
281						return Err(ConfigError::validation_error(
282							"Body cannot be empty",
283							None,
284							None,
285						));
286					}
287				}
288			}
289			TriggerType::Email => {
290				if let TriggerTypeConfig::Email {
291					host,
292					port: _,
293					username,
294					password,
295					message,
296					sender,
297					recipients,
298					retry_policy: _,
299				} = &self.config
300				{
301					// Validate host
302					if host.trim().is_empty() {
303						return Err(ConfigError::validation_error(
304							"Host cannot be empty",
305							None,
306							None,
307						));
308					}
309					// Validate host format
310					if !host.contains('.')
311						|| !host
312							.chars()
313							.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
314					{
315						return Err(ConfigError::validation_error(
316							"Invalid SMTP host format",
317							None,
318							None,
319						));
320					}
321
322					// Basic username validation
323					if username.is_empty() {
324						return Err(ConfigError::validation_error(
325							"SMTP username cannot be empty",
326							None,
327							None,
328						));
329					}
330					if username.as_str().chars().any(|c| c.is_control()) {
331						return Err(ConfigError::validation_error(
332							"SMTP username contains invalid control characters",
333							None,
334							None,
335						));
336					}
337					// Validate password
338					if password.trim().is_empty() {
339						return Err(ConfigError::validation_error(
340							"Password cannot be empty",
341							None,
342							None,
343						));
344					}
345					// Validate message
346					if message.title.trim().is_empty() {
347						return Err(ConfigError::validation_error(
348							"Title cannot be empty",
349							None,
350							None,
351						));
352					}
353					if message.body.trim().is_empty() {
354						return Err(ConfigError::validation_error(
355							"Body cannot be empty",
356							None,
357							None,
358						));
359					}
360					// Validate subject according to RFC 5322
361					// Max length of 998 characters, no control chars except whitespace
362					if message.title.len() > 998 {
363						return Err(ConfigError::validation_error(
364							"Subject exceeds maximum length of 998 characters",
365							None,
366							None,
367						));
368					}
369					if message
370						.title
371						.chars()
372						.any(|c| c.is_control() && !c.is_whitespace())
373					{
374						return Err(ConfigError::validation_error(
375							"Subject contains invalid control characters",
376							None,
377							None,
378						));
379					}
380					// Add minimum length check after trim
381					if message.title.trim().is_empty() {
382						return Err(ConfigError::validation_error(
383							"Subject must contain at least 1 character",
384							None,
385							None,
386						));
387					}
388
389					// Validate email body according to RFC 5322
390					// Check for control characters (except CR, LF, and whitespace)
391					if message
392						.body
393						.chars()
394						.any(|c| c.is_control() && !matches!(c, '\r' | '\n' | '\t' | ' '))
395					{
396						return Err(ConfigError::validation_error(
397							"Body contains invalid control characters",
398							None,
399							None,
400						));
401					}
402
403					// Validate sender
404					if !EmailAddress::is_valid(sender.as_str()) {
405						return Err(ConfigError::validation_error(
406							format!("Invalid sender email address: {}", sender),
407							None,
408							None,
409						));
410					}
411
412					// Validate recipients
413					if recipients.is_empty() {
414						return Err(ConfigError::validation_error(
415							"Recipients cannot be empty",
416							None,
417							None,
418						));
419					}
420					for recipient in recipients {
421						if !EmailAddress::is_valid(recipient.as_str()) {
422							return Err(ConfigError::validation_error(
423								format!("Invalid recipient email address: {}", recipient),
424								None,
425								None,
426							));
427						}
428					}
429				}
430			}
431			TriggerType::Webhook => {
432				if let TriggerTypeConfig::Webhook {
433					url,
434					method,
435					message,
436					payload_mode,
437					..
438				} = &self.config
439				{
440					// Validate URL format
441					if !url.starts_with("http://") && !url.starts_with("https://") {
442						return Err(ConfigError::validation_error(
443							"Invalid webhook URL format",
444							None,
445							None,
446						));
447					}
448					// Validate HTTP method
449					if let Some(method) = method {
450						match method.to_uppercase().as_str() {
451							"GET" | "POST" | "PUT" | "DELETE" => {}
452							_ => {
453								return Err(ConfigError::validation_error(
454									"Invalid HTTP method",
455									None,
456									None,
457								));
458							}
459						}
460					}
461					// Validate message only in template mode
462					// In raw mode, message is not used as the MonitorMatch is sent directly
463					if *payload_mode == WebhookPayloadMode::Template {
464						if message.title.trim().is_empty() {
465							return Err(ConfigError::validation_error(
466								"Title cannot be empty",
467								None,
468								None,
469							));
470						}
471						if message.body.trim().is_empty() {
472							return Err(ConfigError::validation_error(
473								"Body cannot be empty",
474								None,
475								None,
476							));
477						}
478					}
479				}
480			}
481			TriggerType::Telegram => {
482				if let TriggerTypeConfig::Telegram {
483					token,
484					chat_id,
485					message,
486					..
487				} = &self.config
488				{
489					// Validate token
490					// /^[0-9]{8,10}:[a-zA-Z0-9_-]{35}$/ regex
491					if token.trim().is_empty() {
492						return Err(ConfigError::validation_error(
493							"Token cannot be empty",
494							None,
495							None,
496						));
497					}
498
499					// Safely compile and use the regex
500					match regex::Regex::new(r"^[0-9]{8,10}:[a-zA-Z0-9_-]{35}$") {
501						Ok(re) => {
502							if !re.is_match(token.as_str()) {
503								return Err(ConfigError::validation_error(
504									"Invalid token format",
505									None,
506									None,
507								));
508							}
509						}
510						Err(e) => {
511							return Err(ConfigError::validation_error(
512								format!("Failed to validate token format: {}", e),
513								None,
514								None,
515							));
516						}
517					}
518
519					// Validate chat ID
520					if chat_id.trim().is_empty() {
521						return Err(ConfigError::validation_error(
522							"Chat ID cannot be empty",
523							None,
524							None,
525						));
526					}
527					// Validate message
528					if message.title.trim().is_empty() {
529						return Err(ConfigError::validation_error(
530							"Title cannot be empty",
531							None,
532							None,
533						));
534					}
535					if message.body.trim().is_empty() {
536						return Err(ConfigError::validation_error(
537							"Body cannot be empty",
538							None,
539							None,
540						));
541					}
542					// Validate template max length
543					if message.body.len() > TELEGRAM_MAX_BODY_LENGTH {
544						return Err(ConfigError::validation_error(
545							format!(
546								"Message body should not exceed {} characters",
547								TELEGRAM_MAX_BODY_LENGTH
548							),
549							None,
550							None,
551						));
552					}
553				}
554			}
555			TriggerType::Discord => {
556				if let TriggerTypeConfig::Discord {
557					discord_url,
558					message,
559					..
560				} = &self.config
561				{
562					// Validate webhook URL
563					if !discord_url.starts_with("https://discord.com/api/webhooks/") {
564						return Err(ConfigError::validation_error(
565							"Invalid Discord webhook URL format",
566							None,
567							None,
568						));
569					}
570					// Validate message
571					if message.title.trim().is_empty() {
572						return Err(ConfigError::validation_error(
573							"Title cannot be empty",
574							None,
575							None,
576						));
577					}
578					if message.body.trim().is_empty() {
579						return Err(ConfigError::validation_error(
580							"Body cannot be empty",
581							None,
582							None,
583						));
584					}
585					// Validate template max length
586					if message.body.len() > DISCORD_MAX_BODY_LENGTH {
587						return Err(ConfigError::validation_error(
588							format!(
589								"Message body should not exceed {} characters",
590								DISCORD_MAX_BODY_LENGTH
591							),
592							None,
593							None,
594						));
595					}
596				}
597			}
598			TriggerType::Script => {
599				if let TriggerTypeConfig::Script {
600					script_path,
601					language,
602					timeout_ms,
603					..
604				} = &self.config
605				{
606					validate_script_config(script_path, language, timeout_ms)?;
607				}
608			}
609		}
610
611		// Log a warning if the trigger uses an insecure protocol
612		self.validate_protocol();
613
614		Ok(())
615	}
616
617	/// Validate the safety of the protocols used in the trigger
618	///
619	/// Returns if safe, or logs a warning message if unsafe.
620	fn validate_protocol(&self) {
621		match &self.config {
622			TriggerTypeConfig::Slack { slack_url, .. } => {
623				if !slack_url.starts_with("https://") {
624					tracing::warn!("Slack URL uses an insecure protocol: {}", slack_url);
625				}
626			}
627			TriggerTypeConfig::Discord { discord_url, .. } => {
628				if !discord_url.starts_with("https://") {
629					tracing::warn!("Discord URL uses an insecure protocol: {}", discord_url);
630				}
631			}
632			TriggerTypeConfig::Telegram { .. } => {}
633			TriggerTypeConfig::Script { script_path, .. } => {
634				// Check script file permissions on Unix systems
635				#[cfg(unix)]
636				{
637					use std::os::unix::fs::PermissionsExt;
638					if let Ok(metadata) = std::fs::metadata(script_path) {
639						let permissions = metadata.permissions();
640						let mode = permissions.mode();
641						if mode & 0o022 != 0 {
642							tracing::warn!(
643								"Script file has overly permissive write permissions: {}.The recommended permissions are `644` (`rw-r--r--`)",
644								script_path
645							);
646						}
647					}
648				}
649			}
650			TriggerTypeConfig::Email { port, .. } => {
651				let secure_ports = [993, 587, 465];
652				if let Some(port) = port {
653					if !secure_ports.contains(port) {
654						tracing::warn!("Email port is not using a secure protocol: {}", port);
655					}
656				}
657			}
658			TriggerTypeConfig::Webhook { url, headers, .. } => {
659				if !url.starts_with("https://") {
660					tracing::warn!("Webhook URL uses an insecure protocol: {}", url);
661				}
662				// Check for security headers
663				match headers {
664					Some(headers) => {
665						if !headers.contains_key("X-API-Key")
666							&& !headers.contains_key("Authorization")
667						{
668							tracing::warn!("Webhook lacks authentication headers");
669						}
670					}
671					None => {
672						tracing::warn!("Webhook lacks authentication headers");
673					}
674				}
675			}
676		};
677	}
678
679	fn validate_uniqueness(
680		instances: &[&Self],
681		current_instance: &Self,
682		file_path: &str,
683	) -> Result<(), ConfigError> {
684		// Check trigger name uniqueness before pushing
685		if instances.iter().any(|existing_trigger| {
686			normalize_string(&existing_trigger.name) == normalize_string(&current_instance.name)
687		}) {
688			Err(ConfigError::validation_error(
689				format!("Duplicate trigger name found: '{}'", current_instance.name),
690				None,
691				Some(HashMap::from([
692					(
693						"trigger_name".to_string(),
694						current_instance.name.to_string(),
695					),
696					("path".to_string(), file_path.to_string()),
697				])),
698			))
699		} else {
700			Ok(())
701		}
702	}
703}
704
705#[cfg(test)]
706mod tests {
707	use super::*;
708	use crate::models::NotificationMessage;
709	use crate::models::{core::Trigger, ScriptLanguage, SecretString};
710	use crate::utils::tests::builders::trigger::TriggerBuilder;
711	use crate::utils::RetryConfig;
712	use std::{fs::File, io::Write, os::unix::fs::PermissionsExt};
713	use tempfile::TempDir;
714	use tracing_test::traced_test;
715
716	#[test]
717	fn test_slack_trigger_validation() {
718		// Valid trigger
719		let valid_trigger = TriggerBuilder::new()
720			.name("test_slack")
721			.slack("https://hooks.slack.com/services/xxx")
722			.message("Alert", "Test message")
723			.build();
724		assert!(valid_trigger.validate().is_ok());
725
726		// Invalid webhook URL
727		let invalid_webhook = TriggerBuilder::new()
728			.name("test_slack")
729			.slack("https://invalid-url.com")
730			.build();
731		assert!(invalid_webhook.validate().is_err());
732
733		// Empty title
734		let empty_title = TriggerBuilder::new()
735			.name("test_slack")
736			.slack("https://hooks.slack.com/services/xxx")
737			.message("", "Test message")
738			.build();
739		assert!(empty_title.validate().is_err());
740
741		// Empty body
742		let empty_body = TriggerBuilder::new()
743			.name("test_slack")
744			.slack("https://hooks.slack.com/services/xxx")
745			.message("Alert", "")
746			.build();
747		assert!(empty_body.validate().is_err());
748	}
749
750	#[test]
751	fn test_email_trigger_validation() {
752		// Valid trigger
753		let valid_trigger = TriggerBuilder::new()
754			.name("test_email")
755			.email(
756				"smtp.example.com",
757				"user",
758				"pass",
759				"sender@example.com",
760				vec!["recipient@example.com"],
761			)
762			.build();
763		assert!(valid_trigger.validate().is_ok());
764
765		// Test invalid host
766		let invalid_host = TriggerBuilder::new()
767			.name("test_email")
768			.email(
769				"invalid@host",
770				"user",
771				"pass",
772				"sender@example.com",
773				vec!["recipient@example.com"],
774			)
775			.build();
776		assert!(invalid_host.validate().is_err());
777
778		// Test empty host
779		let empty_host = TriggerBuilder::new()
780			.name("test_email")
781			.email(
782				"",
783				"user",
784				"pass",
785				"sender@example.com",
786				vec!["recipient@example.com"],
787			)
788			.build();
789		assert!(empty_host.validate().is_err());
790
791		// Test invalid email address
792		let invalid_email = TriggerBuilder::new()
793			.name("test_email")
794			.email(
795				"smtp.example.com",
796				"user",
797				"pass",
798				"invalid-email",
799				vec!["recipient@example.com"],
800			)
801			.build();
802		assert!(invalid_email.validate().is_err());
803
804		// Test empty password
805		let invalid_password = TriggerBuilder::new()
806			.name("test_email")
807			.email(
808				"smtp.example.com",
809				"user",
810				"", // Invalid password
811				"sender@example.com",
812				vec!["recipient@example.com"],
813			)
814			.build();
815		assert!(invalid_password.validate().is_err());
816
817		// Test subject too long
818		let invalid_subject = TriggerBuilder::new()
819			.name("test_email")
820			.email(
821				"smtp.example.com",
822				"user",
823				"pass",
824				"sender@example.com",
825				vec!["recipient@example.com"],
826			)
827			.message(&"A".repeat(999), "Test Body")  // Exceeds max length
828			.build();
829		assert!(invalid_subject.validate().is_err());
830
831		// Test empty username
832		let empty_username = TriggerBuilder::new()
833			.name("test_email")
834			.email(
835				"smtp.example.com",
836				"",
837				"pass",
838				"sender@example.com",
839				vec!["recipient@example.com"],
840			)
841			.build();
842		assert!(empty_username.validate().is_err());
843
844		// Test invalid control characters in username
845		let invalid_control_chars = TriggerBuilder::new()
846			.name("test_email")
847			.email(
848				"smtp.example.com",
849				"\0",
850				"pass",
851				"sender@example.com",
852				vec!["recipient@example.com"],
853			)
854			.build();
855		assert!(invalid_control_chars.validate().is_err());
856
857		// Test invalid recipient
858		let invalid_recipient = TriggerBuilder::new()
859			.name("test_email")
860			.email(
861				"smtp.example.com",
862				"user",
863				"pass",
864				"sender@example.com",
865				vec!["invalid-email"],
866			)
867			.build();
868		assert!(invalid_recipient.validate().is_err());
869
870		// Test empty body
871		let empty_body = TriggerBuilder::new()
872			.name("test_email")
873			.email(
874				"smtp.example.com",
875				"user",
876				"pass",
877				"sender@example.com",
878				vec!["recipient@example.com"],
879			)
880			.message("Test Subject", "")
881			.build();
882		assert!(empty_body.validate().is_err());
883
884		// Test control characters in subject
885		let control_chars_subject = TriggerBuilder::new()
886			.name("test_email")
887			.email(
888				"smtp.example.com",
889				"user",
890				"pass",
891				"sender@example.com",
892				vec!["recipient@example.com"],
893			)
894			.message("Test \0 Subject", "Test Body")
895			.build();
896		assert!(control_chars_subject.validate().is_err());
897
898		// Test control characters in body
899		let control_chars_body = TriggerBuilder::new()
900			.name("test_email")
901			.email(
902				"smtp.example.com",
903				"user",
904				"pass",
905				"sender@example.com",
906				vec!["recipient@example.com"],
907			)
908			.message("Test Subject", "Test \0 Body")
909			.build();
910		assert!(control_chars_body.validate().is_err());
911	}
912
913	#[test]
914	fn test_webhook_trigger_validation() {
915		// Valid trigger with template mode (default)
916		let valid_trigger = TriggerBuilder::new()
917			.name("test_webhook")
918			.webhook("https://api.example.com/webhook")
919			.message("Alert", "Test message")
920			.build();
921		assert!(valid_trigger.validate().is_ok());
922
923		// Invalid URL
924		let invalid_url = TriggerBuilder::new()
925			.name("test_webhook")
926			.webhook("invalid-url")
927			.build();
928		assert!(invalid_url.validate().is_err());
929
930		// Empty title in template mode - should fail
931		let invalid_title = TriggerBuilder::new()
932			.name("test_webhook")
933			.webhook("https://api.example.com/webhook")
934			.message("", "Test message")
935			.build();
936		assert!(invalid_title.validate().is_err());
937
938		// Empty body in template mode - should fail
939		let invalid_body = TriggerBuilder::new()
940			.name("test_webhook")
941			.webhook("https://api.example.com/webhook")
942			.message("Alert", "")
943			.build();
944		assert!(invalid_body.validate().is_err());
945	}
946
947	#[test]
948	fn test_webhook_trigger_validation_raw_mode() {
949		use crate::models::WebhookPayloadMode;
950
951		// Valid trigger with raw payload mode - empty message is OK
952		let valid_raw_trigger = TriggerBuilder::new()
953			.name("test_webhook_raw")
954			.webhook("https://api.example.com/webhook")
955			.webhook_payload_mode(WebhookPayloadMode::Raw)
956			.message("", "") // Empty message is valid in raw mode
957			.build();
958		assert!(valid_raw_trigger.validate().is_ok());
959
960		// Invalid URL in raw mode - should still fail
961		let invalid_url_raw = TriggerBuilder::new()
962			.name("test_webhook_raw")
963			.webhook("invalid-url")
964			.webhook_payload_mode(WebhookPayloadMode::Raw)
965			.build();
966		assert!(invalid_url_raw.validate().is_err());
967
968		// Valid URL with raw mode and non-empty message
969		let valid_raw_with_message = TriggerBuilder::new()
970			.name("test_webhook_raw")
971			.webhook("https://api.example.com/webhook")
972			.webhook_payload_mode(WebhookPayloadMode::Raw)
973			.message("Alert", "Test message")
974			.build();
975		assert!(valid_raw_with_message.validate().is_ok());
976	}
977
978	#[test]
979	fn test_webhook_payload_mode_serialization() {
980		use crate::models::WebhookPayloadMode;
981
982		// Test serialization
983		let template_mode = WebhookPayloadMode::Template;
984		let raw_mode = WebhookPayloadMode::Raw;
985
986		assert_eq!(
987			serde_json::to_string(&template_mode).unwrap(),
988			"\"template\""
989		);
990		assert_eq!(serde_json::to_string(&raw_mode).unwrap(), "\"raw\"");
991
992		// Test deserialization
993		let deserialized_template: WebhookPayloadMode =
994			serde_json::from_str("\"template\"").unwrap();
995		let deserialized_raw: WebhookPayloadMode = serde_json::from_str("\"raw\"").unwrap();
996
997		assert_eq!(deserialized_template, WebhookPayloadMode::Template);
998		assert_eq!(deserialized_raw, WebhookPayloadMode::Raw);
999
1000		// Test default
1001		assert_eq!(WebhookPayloadMode::default(), WebhookPayloadMode::Template);
1002	}
1003
1004	#[test]
1005	fn test_discord_trigger_validation() {
1006		// Valid trigger
1007		let valid_trigger = TriggerBuilder::new()
1008			.name("test_discord")
1009			.discord("https://discord.com/api/webhooks/xxx")
1010			.message("Alert", "Test message")
1011			.build();
1012		assert!(valid_trigger.validate().is_ok());
1013
1014		// Invalid webhook URL
1015		let invalid_webhook = TriggerBuilder::new()
1016			.name("test_discord")
1017			.discord("https://invalid-url.com")
1018			.build();
1019		assert!(invalid_webhook.validate().is_err());
1020
1021		// Empty title
1022		let invalid_title = TriggerBuilder::new()
1023			.name("test_discord")
1024			.discord("https://discord.com/api/webhooks/123")
1025			.message("", "Test message")
1026			.build();
1027		assert!(invalid_title.validate().is_err());
1028
1029		// Empty body
1030		let invalid_body = TriggerBuilder::new()
1031			.name("test_discord")
1032			.discord("https://discord.com/api/webhooks/123")
1033			.message("Alert", "")
1034			.build();
1035		assert!(invalid_body.validate().is_err());
1036	}
1037
1038	#[test]
1039	fn test_telegram_trigger_validation() {
1040		let valid_trigger = TriggerBuilder::new()
1041			.name("test_telegram")
1042			.telegram(
1043				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", // noboost
1044				"1730223038",
1045				true,
1046			)
1047			.build();
1048		assert!(valid_trigger.validate().is_ok());
1049
1050		// Test invalid token
1051		let invalid_token = TriggerBuilder::new()
1052			.name("test_telegram")
1053			.telegram("invalid-token", "1730223038", true)
1054			.build();
1055		assert!(invalid_token.validate().is_err());
1056
1057		// Test invalid chat ID
1058		let invalid_chat_id = TriggerBuilder::new()
1059			.name("test_telegram")
1060			.telegram(
1061				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", // noboost
1062				"",
1063				true,
1064			)
1065			.build();
1066		assert!(invalid_chat_id.validate().is_err());
1067
1068		// Test invalid message
1069		let invalid_title_message = TriggerBuilder::new()
1070			.name("test_telegram")
1071			.telegram(
1072				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", // noboost
1073				"1730223038",
1074				true,
1075			)
1076			.message("", "Test Message")
1077			.build();
1078		assert!(invalid_title_message.validate().is_err());
1079
1080		let invalid_body_message = TriggerBuilder::new()
1081			.name("test_telegram")
1082			.telegram(
1083				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", // noboost
1084				"1730223038",
1085				true,
1086			)
1087			.message("Test Subject", "")
1088			.build();
1089		assert!(invalid_body_message.validate().is_err());
1090	}
1091
1092	#[test]
1093	fn test_script_trigger_validation() {
1094		let temp_dir = std::env::temp_dir();
1095		let script_path = temp_dir.join("test_script.sh");
1096		std::fs::write(&script_path, "#!/bin/bash\necho 'test'").unwrap();
1097
1098		// Valid trigger
1099		let valid_trigger = TriggerBuilder::new()
1100			.name("test_script")
1101			.script(script_path.to_str().unwrap(), ScriptLanguage::Bash)
1102			.build();
1103		assert!(valid_trigger.validate().is_ok());
1104
1105		// Non-existent script
1106		let invalid_path = TriggerBuilder::new()
1107			.name("test_script")
1108			.script("/non/existent/path", ScriptLanguage::Python)
1109			.build();
1110		assert!(invalid_path.validate().is_err());
1111
1112		std::fs::remove_file(script_path).unwrap();
1113	}
1114
1115	#[tokio::test]
1116	async fn test_invalid_load_from_path() {
1117		let path = Path::new("config/triggers/invalid.json");
1118		assert!(matches!(
1119			Trigger::load_from_path(path).await,
1120			Err(ConfigError::FileError(_))
1121		));
1122	}
1123
1124	#[tokio::test]
1125	async fn test_invalid_config_from_load_from_path() {
1126		use std::io::Write;
1127		use tempfile::NamedTempFile;
1128
1129		let mut temp_file = NamedTempFile::new().unwrap();
1130		write!(temp_file, "{{\"invalid\": \"json").unwrap();
1131
1132		let path = temp_file.path();
1133
1134		assert!(matches!(
1135			Trigger::load_from_path(path).await,
1136			Err(ConfigError::ParseError(_))
1137		));
1138	}
1139
1140	#[tokio::test]
1141	async fn test_load_all_directory_not_found() {
1142		let non_existent_path = Path::new("non_existent_directory");
1143
1144		let result: Result<HashMap<String, Trigger>, ConfigError> =
1145			Trigger::load_all(Some(non_existent_path)).await;
1146		assert!(matches!(result, Err(ConfigError::FileError(_))));
1147
1148		if let Err(ConfigError::FileError(err)) = result {
1149			assert!(err.message.contains("triggers directory not found"));
1150		}
1151	}
1152
1153	#[tokio::test]
1154	#[cfg(unix)] // This test is Unix-specific due to permission handling
1155	async fn test_load_all_unreadable_file() {
1156		// Create a temporary directory for our test
1157		let temp_dir = TempDir::new().unwrap();
1158		let config_dir = temp_dir.path().join("triggers");
1159		std::fs::create_dir(&config_dir).unwrap();
1160
1161		// Create a JSON file with valid content but unreadable permissions
1162		let file_path = config_dir.join("unreadable.json");
1163		{
1164			let mut file = File::create(&file_path).unwrap();
1165			writeln!(file, r#"{{ "test_trigger": {{ "name": "test", "trigger_type": "Slack", "config": {{ "slack_url": "https://hooks.slack.com/services/xxx", "message": {{ "title": "Alert", "body": "Test message" }} }} }} }}"#).unwrap();
1166		}
1167
1168		// Change permissions to make the file unreadable
1169		let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
1170		perms.set_mode(0o000); // No permissions
1171		std::fs::set_permissions(&file_path, perms).unwrap();
1172
1173		// Try to load triggers from the directory
1174		let result: Result<HashMap<String, Trigger>, ConfigError> =
1175			Trigger::load_all(Some(&config_dir)).await;
1176
1177		// Verify we get the expected error
1178		assert!(matches!(result, Err(ConfigError::FileError(_))));
1179		if let Err(ConfigError::FileError(err)) = result {
1180			assert!(err.message.contains("failed to read trigger config file"));
1181		}
1182
1183		// Clean up by making the file deletable
1184		let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
1185		perms.set_mode(0o644);
1186		std::fs::set_permissions(&file_path, perms).unwrap();
1187	}
1188
1189	#[test]
1190	#[traced_test]
1191	fn test_validate_protocol_slack() {
1192		let insecure_trigger = TriggerBuilder::new()
1193			.name("test_slack")
1194			.slack("http://hooks.slack.com/services/xxx")
1195			.build();
1196
1197		insecure_trigger.validate_protocol();
1198		assert!(logs_contain("Slack URL uses an insecure protocol"));
1199	}
1200
1201	#[test]
1202	#[traced_test]
1203	fn test_validate_protocol_discord() {
1204		let insecure_trigger = TriggerBuilder::new()
1205			.name("test_discord")
1206			.discord("http://discord.com/api/webhooks/xxx")
1207			.build();
1208
1209		insecure_trigger.validate_protocol();
1210		assert!(logs_contain("Discord URL uses an insecure protocol"));
1211	}
1212
1213	#[test]
1214	#[traced_test]
1215	fn test_validate_protocol_webhook() {
1216		let insecure_trigger = TriggerBuilder::new()
1217			.name("test_webhook")
1218			.webhook("http://api.example.com/webhook")
1219			.build();
1220
1221		insecure_trigger.validate_protocol();
1222		assert!(logs_contain("Webhook URL uses an insecure protocol"));
1223		assert!(logs_contain("Webhook lacks authentication headers"));
1224	}
1225
1226	#[test]
1227	#[traced_test]
1228	fn test_validate_protocol_email() {
1229		let insecure_trigger = TriggerBuilder::new()
1230			.name("test_email")
1231			.email(
1232				"smtp.example.com",
1233				"user",
1234				"pass",
1235				"sender@example.com",
1236				vec!["recipient@example.com"],
1237			)
1238			.email_port(25) // Insecure port
1239			.build();
1240
1241		insecure_trigger.validate_protocol();
1242		assert!(logs_contain("Email port is not using a secure protocol"));
1243	}
1244
1245	#[cfg(unix)]
1246	#[test]
1247	#[traced_test]
1248	fn test_validate_protocol_script() {
1249		use std::fs::File;
1250		use std::os::unix::fs::PermissionsExt;
1251		use tempfile::TempDir;
1252
1253		let temp_dir = TempDir::new().unwrap();
1254		let script_path = temp_dir.path().join("test_script.sh");
1255		File::create(&script_path).unwrap();
1256
1257		// Set overly permissive permissions (777)
1258		let metadata = std::fs::metadata(&script_path).unwrap();
1259		let mut permissions = metadata.permissions();
1260		permissions.set_mode(0o777);
1261		std::fs::set_permissions(&script_path, permissions).unwrap();
1262
1263		let trigger = TriggerBuilder::new()
1264			.name("test_script")
1265			.script(script_path.to_str().unwrap(), ScriptLanguage::Bash)
1266			.build();
1267
1268		trigger.validate_protocol();
1269		assert!(logs_contain(
1270			"Script file has overly permissive write permissions"
1271		));
1272	}
1273
1274	#[test]
1275	#[traced_test]
1276	fn test_validate_protocol_webhook_with_headers() {
1277		let mut headers = HashMap::new();
1278		headers.insert("Content-Type".to_string(), "application/json".to_string());
1279
1280		let insecure_trigger = TriggerBuilder::new()
1281			.name("test_webhook")
1282			.webhook("http://api.example.com/webhook")
1283			.webhook_headers(headers)
1284			.build();
1285
1286		insecure_trigger.validate_protocol();
1287		assert!(logs_contain("Webhook URL uses an insecure protocol"));
1288		assert!(logs_contain("Webhook lacks authentication headers"));
1289	}
1290
1291	#[tokio::test]
1292	async fn test_resolve_secrets_slack() {
1293		let trigger = TriggerBuilder::new()
1294			.name("slack")
1295			.slack("https://hooks.slack.com/xxx")
1296			.build();
1297
1298		let resolved = trigger.resolve_secrets().await.unwrap();
1299		if let TriggerTypeConfig::Slack { slack_url, .. } = &resolved.config {
1300			assert!(matches!(slack_url, SecretValue::Plain(_)));
1301		}
1302	}
1303
1304	#[tokio::test]
1305	async fn test_resolve_secrets_email() {
1306		let trigger = TriggerBuilder::new()
1307			.name("email")
1308			.email(
1309				"smtp.example.com",
1310				"user",
1311				"pass",
1312				"sender@example.com",
1313				vec!["recipient@example.com"],
1314			)
1315			.build();
1316
1317		let resolved = trigger.resolve_secrets().await.unwrap();
1318		if let TriggerTypeConfig::Email {
1319			username, password, ..
1320		} = &resolved.config
1321		{
1322			assert!(matches!(username, SecretValue::Plain(_)));
1323			assert!(matches!(password, SecretValue::Plain(_)));
1324		}
1325	}
1326
1327	#[tokio::test]
1328	async fn test_resolve_secrets_webhook_with_secret() {
1329		let trigger = TriggerBuilder::new()
1330			.name("webhook")
1331			.webhook("https://api.example.com")
1332			.webhook_secret(SecretValue::Plain(SecretString::new("secret".to_string())))
1333			.build();
1334
1335		let resolved = trigger.resolve_secrets().await.unwrap();
1336		if let TriggerTypeConfig::Webhook { url, secret, .. } = &resolved.config {
1337			assert!(matches!(url, SecretValue::Plain(_)));
1338			assert!(matches!(secret, Some(SecretValue::Plain(_))));
1339		}
1340	}
1341
1342	#[tokio::test]
1343	async fn test_resolve_secrets_telegram() {
1344		let trigger = TriggerBuilder::new()
1345			.name("telegram")
1346			.telegram(
1347				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789",
1348				"1730223038",
1349				true,
1350			)
1351			.build();
1352
1353		let resolved = trigger.resolve_secrets().await.unwrap();
1354		if let TriggerTypeConfig::Telegram { token, .. } = &resolved.config {
1355			assert!(matches!(token, SecretValue::Plain(_)));
1356		}
1357	}
1358
1359	#[tokio::test]
1360	async fn test_resolve_secrets_discord() {
1361		let trigger = TriggerBuilder::new()
1362			.name("discord")
1363			.discord("https://discord.com/api/webhooks/xxx")
1364			.build();
1365
1366		let resolved = trigger.resolve_secrets().await.unwrap();
1367		if let TriggerTypeConfig::Discord { discord_url, .. } = &resolved.config {
1368			assert!(matches!(discord_url, SecretValue::Plain(_)));
1369		}
1370	}
1371
1372	#[tokio::test]
1373	async fn test_resolve_secrets_other_branch() {
1374		// For a config type not handled in the match (e.g., Script)
1375		let trigger = TriggerBuilder::new()
1376			.name("script")
1377			.script("/tmp/test.sh", ScriptLanguage::Bash)
1378			.build();
1379
1380		let resolved = trigger.resolve_secrets().await.unwrap();
1381		if let TriggerTypeConfig::Script { .. } = &resolved.config {
1382			// No secret resolution, just check it passes
1383		}
1384	}
1385
1386	#[tokio::test]
1387	async fn test_resolve_secrets_slack_env_error() {
1388		let trigger = TriggerBuilder::new()
1389			.name("slack")
1390			.slack("")
1391			.url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1392			.build();
1393
1394		let result = trigger.resolve_secrets().await;
1395		assert!(result.is_err());
1396		if let Err(e) = result {
1397			assert!(e.to_string().contains("failed to resolve Slack URL"));
1398		}
1399	}
1400
1401	#[tokio::test]
1402	async fn test_resolve_secrets_discord_env_error() {
1403		let trigger = TriggerBuilder::new()
1404			.name("discord")
1405			.discord("")
1406			.url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1407			.build();
1408
1409		let result = trigger.resolve_secrets().await;
1410		assert!(result.is_err());
1411		if let Err(e) = result {
1412			assert!(e.to_string().contains("failed to resolve Discord URL"));
1413		}
1414	}
1415
1416	#[tokio::test]
1417	async fn test_resolve_secrets_telegram_env_error() {
1418		let trigger = TriggerBuilder::new()
1419			.name("telegram")
1420			.telegram("", "1730223038", true)
1421			.telegram_token(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1422			.build();
1423
1424		let result = trigger.resolve_secrets().await;
1425		assert!(result.is_err());
1426		if let Err(e) = result {
1427			assert!(e.to_string().contains("failed to resolve Telegram token"));
1428		}
1429	}
1430
1431	#[tokio::test]
1432	async fn test_resolve_secrets_webhook_env_error() {
1433		let trigger = TriggerBuilder::new()
1434			.name("webhook")
1435			.webhook("")
1436			.url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1437			.build();
1438
1439		let result = trigger.resolve_secrets().await;
1440		assert!(result.is_err());
1441		if let Err(e) = result {
1442			assert!(e.to_string().contains("failed to resolve webhook URL"));
1443		}
1444
1445		let trigger = TriggerBuilder::new()
1446			.name("webhook")
1447			.webhook("https://api.example.com")
1448			.webhook_secret(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1449			.build();
1450
1451		let result = trigger.resolve_secrets().await;
1452		assert!(result.is_err());
1453		if let Err(e) = result {
1454			assert!(e.to_string().contains("failed to resolve webhook secret"));
1455		}
1456	}
1457
1458	#[tokio::test]
1459	async fn test_resolve_secrets_email_env_error() {
1460		let trigger = TriggerBuilder::new()
1461			.name("email")
1462			.email(
1463				"smtp.example.com",
1464				"",
1465				"pass",
1466				"sender@example.com",
1467				vec!["recipient@example.com"],
1468			)
1469			.email_username(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1470			.build();
1471
1472		let result = trigger.resolve_secrets().await;
1473		assert!(result.is_err());
1474		if let Err(e) = result {
1475			assert!(e.to_string().contains("failed to resolve SMTP username"));
1476		}
1477
1478		let trigger = TriggerBuilder::new()
1479			.name("email")
1480			.email(
1481				"smtp.example.com",
1482				"user",
1483				"",
1484				"sender@example.com",
1485				vec!["recipient@example.com"],
1486			)
1487			.email_password(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1488			.build();
1489
1490		let result = trigger.resolve_secrets().await;
1491		assert!(result.is_err());
1492		if let Err(e) = result {
1493			assert!(e.to_string().contains("failed to resolve SMTP password"));
1494		}
1495	}
1496	#[test]
1497	fn test_telegram_max_message_length() {
1498		let max_body_length = Trigger {
1499			name: "test_telegram".to_string(),
1500			trigger_type: TriggerType::Telegram,
1501			config: TriggerTypeConfig::Telegram {
1502				token: SecretValue::Plain(SecretString::new(
1503					"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789".to_string(),
1504				)),
1505				chat_id: "1730223038".to_string(),
1506				disable_web_preview: Some(true),
1507				message: NotificationMessage {
1508					title: "Test".to_string(),
1509					body: "x".repeat(TELEGRAM_MAX_BODY_LENGTH + 1), // Exceeds max length
1510				},
1511				retry_policy: RetryConfig::default(),
1512			},
1513		};
1514		assert!(max_body_length.validate().is_err());
1515	}
1516
1517	#[test]
1518	fn test_discord_max_message_length() {
1519		let max_body_length = Trigger {
1520			name: "test_discord".to_string(),
1521			trigger_type: TriggerType::Discord,
1522			config: TriggerTypeConfig::Discord {
1523				discord_url: SecretValue::Plain(SecretString::new(
1524					"https://discord.com/api/webhooks/xxx".to_string(),
1525				)),
1526				message: NotificationMessage {
1527					title: "Test".to_string(),
1528					body: "z".repeat(DISCORD_MAX_BODY_LENGTH + 1), // Exceeds max length
1529				},
1530				retry_policy: RetryConfig::default(),
1531			},
1532		};
1533		assert!(max_body_length.validate().is_err());
1534	}
1535
1536	#[tokio::test]
1537	async fn test_load_all_duplicate_trigger_name() {
1538		let temp_dir = TempDir::new().unwrap();
1539		let file_path_1 = temp_dir.path().join("duplicate_trigger.json");
1540		let file_path_2 = temp_dir.path().join("duplicate_trigger_2.json");
1541
1542		let trigger_config_1 = r#"{
1543			"test_trigger_1": {
1544				"name": "TestTrigger",
1545				"trigger_type": "slack",
1546				"config": {
1547					"slack_url": {
1548						"type": "plain",
1549						"value": "https://hooks.slack.com/services/xxx"
1550					},
1551					"message": {
1552						"title": "Test",
1553						"body": "Test"
1554					}
1555				}
1556			}
1557		}"#;
1558
1559		let trigger_config_2 = r#"{
1560			"test_trigger_2": {
1561				"name": "testTrigger",
1562				"trigger_type": "discord",
1563				"config": {
1564					"discord_url": {
1565						"type": "plain",
1566						"value": "https://discord.com/api/webhooks/xxx"
1567					},
1568					"message": {
1569						"title": "Test",
1570						"body": "Test"
1571					}
1572				}
1573			}
1574		}"#;
1575
1576		fs::write(&file_path_1, trigger_config_1).unwrap();
1577		fs::write(&file_path_2, trigger_config_2).unwrap();
1578
1579		let result: Result<HashMap<String, Trigger>, ConfigError> =
1580			Trigger::load_all(Some(temp_dir.path())).await;
1581
1582		assert!(result.is_err());
1583		if let Err(ConfigError::ValidationError(err)) = result {
1584			assert!(err.message.contains("Duplicate trigger name found"));
1585		}
1586	}
1587}