1use std::collections::{BTreeMap, BTreeSet};
4
5use jiff::tz::TimeZone;
6use ravenrs_report::Criteria;
7pub use ravenrs_report_export::ExportFormat as Format;
8use serde::{Deserialize, Serialize};
9
10use crate::cron::Schedule;
11use crate::store::valid_name;
12
13const MAX_RECIPIENTS: usize = 500;
15const MAX_RETRIES: u32 = 10;
17
18#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct Job {
22 pub layout: String,
24 pub schedule: String,
26 #[serde(default = "utc")]
28 pub timezone: String,
29 #[serde(default)]
31 pub parameters: BTreeMap<String, ParameterValue>,
32 pub formats: Vec<Format>,
34 pub deliveries: Vec<Delivery>,
36 #[serde(default)]
38 pub skip_if_empty: bool,
39 #[serde(default = "default_retries")]
41 pub retries: u32,
42 #[serde(default)]
44 pub retention_days: u32,
45 #[serde(default = "enabled")]
47 pub enabled: bool,
48}
49
50fn utc() -> String {
51 "UTC".to_owned()
52}
53
54fn default_retries() -> u32 {
55 2
56}
57
58fn enabled() -> bool {
59 true
60}
61
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
64#[serde(untagged)]
65pub enum ParameterValue {
66 Expression {
68 expression: String,
70 },
71 Fixed(serde_json::Value),
73}
74
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase", deny_unknown_fields)]
78pub enum Delivery {
79 Email {
81 to: Vec<String>,
83 #[serde(default)]
85 separately: bool,
86 #[serde(default)]
88 subject: Option<String>,
89 #[serde(default)]
91 body: Option<String>,
92 },
93 Folder {
95 path: String,
97 },
98}
99
100impl Job {
101 pub fn validate(&self) -> Result<(), String> {
103 if !valid_name(&self.layout) {
104 return Err(format!("'{}' is not a valid layout name", self.layout));
105 }
106 Schedule::parse(&self.schedule)?;
107 TimeZone::get(&self.timezone).map_err(|_| format!("unknown time zone '{}'", self.timezone))?;
108 if self.formats.is_empty() {
109 return Err("a job needs at least one format".to_owned());
110 }
111 if self.formats.iter().collect::<BTreeSet<_>>().len() != self.formats.len() {
112 return Err("a format is listed twice".to_owned());
113 }
114 if self.deliveries.is_empty() {
115 return Err("a job needs at least one delivery".to_owned());
116 }
117 if self.retries > MAX_RETRIES {
118 return Err(format!("a job can retry at most {MAX_RETRIES} times"));
119 }
120 for (name, value) in &self.parameters {
121 if let ParameterValue::Expression { expression } = value {
122 Criteria::parse(expression).map_err(|e| format!("parameter {name}: {e}"))?;
123 }
124 }
125 for delivery in &self.deliveries {
126 match delivery {
127 Delivery::Email { to, .. } => {
128 if to.is_empty() || to.len() > MAX_RECIPIENTS {
129 return Err(format!("an email delivery needs 1 to {MAX_RECIPIENTS} recipients"));
130 }
131 for address in to {
132 address
133 .parse::<lettre::message::Mailbox>()
134 .map_err(|_| format!("'{address}' is not a valid email address"))?;
135 }
136 }
137 Delivery::Folder { path } => {
138 safe_relative_path(path)?;
139 }
140 }
141 }
142 Ok(())
143 }
144}
145
146pub(crate) fn safe_relative_path(path: &str) -> Result<Vec<&str>, String> {
148 let parts: Vec<&str> = path.split(['/', '\\']).filter(|p| !p.is_empty() && *p != ".").collect();
149 let bad = path.starts_with(['/', '\\'])
150 || path.contains('\0')
151 || path.contains(':')
152 || parts.is_empty()
153 || parts.iter().any(|p| *p == ".." || p.chars().any(char::is_control));
154 if bad {
155 return Err(format!("'{path}' must be a relative folder without '..'"));
156 }
157 Ok(parts)
158}
159
160#[cfg(test)]
161mod tests {
162 use serde_json::json;
163
164 use super::*;
165
166 fn job() -> serde_json::Value {
167 json!({
168 "layout": "sales",
169 "schedule": "0 8 * * MON",
170 "timezone": "America/Recife",
171 "parameters": { "Region": "Sul", "From": { "expression": "AddMonths(Today(), -1)" } },
172 "formats": ["pdf", "xlsx"],
173 "deliveries": [
174 { "type": "email", "to": ["Ana <ana@example.com>"], "separately": true, "subject": "{report} {date}" },
175 { "type": "folder", "path": "vendas/semanal" }
176 ],
177 "skipIfEmpty": true,
178 "retentionDays": 30
179 })
180 }
181
182 fn with(key: &str, value: serde_json::Value) -> Result<(), String> {
183 let mut json = job();
184 json[key] = value;
185 let job: Job = serde_json::from_value(json).map_err(|e| e.to_string())?;
186 job.validate()
187 }
188
189 #[test]
190 fn a_complete_job_reads_and_validates() -> Result<(), String> {
191 let parsed: Job = serde_json::from_value(job()).map_err(|e| e.to_string())?;
192 parsed.validate()?;
193 assert_eq!(parsed.retries, 2);
194 assert!(parsed.enabled);
195 assert_eq!(parsed.parameters["Region"], ParameterValue::Fixed(json!("Sul")));
196 assert!(matches!(parsed.parameters["From"], ParameterValue::Expression { .. }));
197 let again: Job = serde_json::from_str(&serde_json::to_string(&parsed).map_err(|e| e.to_string())?)
198 .map_err(|e| e.to_string())?;
199 assert_eq!(again, parsed);
200 Ok(())
201 }
202
203 #[test]
204 fn mistakes_are_refused() {
205 assert!(with("schedule", json!("every monday")).is_err());
206 assert!(with("timezone", json!("Mars/Olympus")).is_err());
207 assert!(with("layout", json!("../secret")).is_err());
208 assert!(with("formats", json!([])).is_err());
209 assert!(with("formats", json!(["pdf", "pdf"])).is_err());
210 assert!(with("formats", json!(["docx"])).is_err());
211 assert!(with("deliveries", json!([])).is_err());
212 assert!(with("deliveries", json!([{ "type": "email", "to": ["not an address"] }])).is_err());
213 assert!(with("deliveries", json!([{ "type": "email", "to": [] }])).is_err());
214 assert!(with("retries", json!(99)).is_err());
215 assert!(with("parameters", json!({ "A": { "expression": "1 +" } })).is_err());
216 assert!(with("typo", json!(1)).is_err(), "unknown fields are refused");
217 }
218
219 #[test]
220 fn folders_cannot_leave_the_output_folder() {
221 for bad in ["../x", "a/../../x", "/etc", "\\x", "C:\\x", "a\\..\\..\\x", "", ".", "a\0b"] {
222 assert!(safe_relative_path(bad).is_err(), "{bad}");
223 }
224 assert_eq!(safe_relative_path("a/./b/"), Ok(vec!["a", "b"]));
225 }
226}