1#[derive(Debug, Clone)]
2pub struct FunctionConfig {
3 pub default_timeout_ms: u64,
4 pub health_interval: std::time::Duration,
5 pub boot_timeout: std::time::Duration,
6 pub egress_allowlist: Vec<String>,
16}
17
18impl Default for FunctionConfig {
19 fn default() -> Self {
20 Self {
21 default_timeout_ms: 5000,
22 health_interval: std::time::Duration::from_secs(5),
23 boot_timeout: std::time::Duration::from_secs(10),
24 egress_allowlist: Vec::new(),
25 }
26 }
27}
28
29impl FunctionConfig {
30 pub fn validate(&self) -> Result<(), camel_api::CamelError> {
31 if self.default_timeout_ms == 0 {
32 return Err(camel_api::CamelError::Config(
33 "default_timeout_ms must be > 0".to_string(),
34 ));
35 }
36 if self.health_interval == std::time::Duration::ZERO {
37 return Err(camel_api::CamelError::Config(
38 "health_interval must be > 0".to_string(),
39 ));
40 }
41 if self.boot_timeout == std::time::Duration::ZERO {
42 return Err(camel_api::CamelError::Config(
43 "boot_timeout must be > 0".to_string(),
44 ));
45 }
46 for entry in &self.egress_allowlist {
47 validate_egress_allowlist_entry(entry)?;
48 }
49 Ok(())
50 }
51}
52
53pub fn validate_egress_allowlist_entry(entry: &str) -> Result<(), camel_api::CamelError> {
60 let err = |reason: &str| {
61 camel_api::CamelError::Config(format!("egress_allowlist entry '{entry}': {reason}"))
62 };
63 if entry.is_empty() {
64 return Err(err("must not be empty"));
65 }
66 if entry.chars().any(char::is_whitespace) {
67 return Err(err("must not contain whitespace"));
68 }
69
70 let (host, port) = if let Some(rest) = entry.strip_prefix('[') {
71 let Some(close) = rest.find(']') else {
73 return Err(err("unterminated IPv6 bracket"));
74 };
75 let inner = &rest[..close];
76 let tail = &rest[close + 1..];
77 if inner.is_empty() {
78 return Err(err("empty IPv6 address"));
79 }
80 if !inner.chars().all(|c| c.is_ascii_hexdigit() || c == ':') {
81 return Err(err("IPv6 literal may contain only hex digits and colons"));
82 }
83 let port = match tail {
84 "" => None,
85 t => {
86 let Some(p) = t.strip_prefix(':') else {
87 return Err(err("unexpected characters after IPv6 bracket"));
88 };
89 Some(p)
90 }
91 };
92 (None, port)
93 } else {
94 match entry.rsplit_once(':') {
95 Some((h, p)) => {
96 if h.is_empty() {
97 return Err(err("empty host"));
98 }
99 if p.is_empty() {
100 return Err(err("empty port"));
101 }
102 (Some(h), Some(p))
103 }
104 None => (Some(entry), None),
105 }
106 };
107
108 if let Some(h) = host {
109 if h.is_empty() {
110 return Err(err("empty host"));
111 }
112 if !h
113 .chars()
114 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
115 {
116 return Err(err(
117 "host may contain only letters, digits, dots, and hyphens \
118 (bracket IPv6 literals for IPv6 addresses)",
119 ));
120 }
121 if h.starts_with('.') || h.ends_with('.') || h.contains("..") {
122 return Err(err("malformed host"));
123 }
124 }
125 if let Some(p) = port {
126 if !p.chars().all(|c| c.is_ascii_digit()) {
130 return Err(err("port must be an integer in the range 1..=65535"));
131 }
132 match p.parse::<u16>() {
133 Ok(n) if n != 0 => {}
134 _ => return Err(err("port must be an integer in the range 1..=65535")),
135 }
136 }
137 Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn test_config_valid_default() {
146 let config = FunctionConfig::default();
147 assert!(config.validate().is_ok());
148 }
149
150 #[test]
151 fn test_config_zero_timeout_rejected() {
152 let config = FunctionConfig {
153 default_timeout_ms: 0,
154 ..Default::default()
155 };
156 assert!(config.validate().is_err());
157 }
158
159 #[test]
160 fn test_config_zero_health_interval_rejected() {
161 let config = FunctionConfig {
162 health_interval: std::time::Duration::ZERO,
163 ..Default::default()
164 };
165 assert!(config.validate().is_err());
166 }
167
168 #[test]
169 fn test_config_zero_boot_timeout_rejected() {
170 let config = FunctionConfig {
171 boot_timeout: std::time::Duration::ZERO,
172 ..Default::default()
173 };
174 assert!(config.validate().is_err());
175 }
176
177 #[test]
178 fn test_config_empty_allowlist_is_default_deny() {
179 let config = FunctionConfig::default();
182 assert!(config.egress_allowlist.is_empty());
183 assert!(config.validate().is_ok());
184 }
185
186 #[test]
187 fn test_egress_entry_valid_forms() {
188 for entry in [
189 "api.example.com",
190 "api.example.com:443",
191 "internal",
192 "sub.domain-2.io:8080",
193 "10.0.0.5",
194 "10.0.0.5:5432",
195 "[::1]",
196 "[::1]:443",
197 "[2001:db8::1]:8443",
198 ] {
199 validate_egress_allowlist_entry(entry)
200 .unwrap_or_else(|e| panic!("'{entry}' should be valid: {e}"));
201 }
202 }
203
204 #[test]
205 fn test_egress_entry_malformed_rejected() {
206 for entry in [
207 "",
208 " ",
209 "host:",
210 ":443",
211 "bad host",
212 "http://api.example.com",
213 "api.example.com/path",
214 "user@api.example.com",
215 "api.example.com,evil.com",
216 "*",
217 "*.example.com",
218 "host:0",
219 "host:65536",
220 "host:abc",
221 "host:+443",
222 "[::1",
223 "[zz::1]",
224 "[]",
225 "[::1]junk",
226 "..malformed..host",
227 ] {
228 let err = validate_egress_allowlist_entry(entry)
229 .err()
230 .unwrap_or_else(|| panic!("'{entry}' should be rejected"));
231 assert!(
232 err.to_string().contains("egress_allowlist"),
233 "error for '{entry}' must name egress_allowlist, got: {err}"
234 );
235 }
236 }
237
238 #[test]
239 fn test_config_validate_rejects_malformed_allowlist_entry() {
240 let config = FunctionConfig {
241 egress_allowlist: vec!["api.example.com:443".to_string(), "bad host".to_string()],
242 ..Default::default()
243 };
244 let err = config.validate().expect_err("must be rejected"); assert!(err.to_string().contains("egress_allowlist"));
249 }
250}