1use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::error::{DeployError, Result};
8use crate::types::{ConfigFileMount, ServiceConfigView, ServiceDeployEnvView};
9
10#[derive(Debug, Clone, Default)]
12pub struct ComposedRuntime {
13 pub env: BTreeMap<String, String>,
14 pub config_mounts: Vec<ConfigFileMount>,
15 pub container_port: Option<u16>,
16 pub loaded_env_files: Vec<PathBuf>,
17 pub missing_required: Vec<String>,
18}
19
20pub fn compose_service_runtime(
32 project_root: &Path,
33 svc: &ServiceConfigView,
34 env_name: &str,
35 env_cfg: &ServiceDeployEnvView,
36) -> Result<ComposedRuntime> {
37 compose_service_runtime_with_opts(project_root, svc, env_name, env_cfg, true)
38}
39
40pub fn compose_service_runtime_with_opts(
42 project_root: &Path,
43 svc: &ServiceConfigView,
44 env_name: &str,
45 env_cfg: &ServiceDeployEnvView,
46 strict_env_files: bool,
47) -> Result<ComposedRuntime> {
48 let service_root = resolve_service_root(project_root, svc);
49 let mut file_env = BTreeMap::new();
50 let mut loaded = Vec::new();
51
52 let explicit = !env_cfg.env_files.is_empty();
53 let env_files = resolve_env_file_list(project_root, &service_root, env_name, &env_cfg.env_files);
54 for path in &env_files {
55 if !path.is_file() {
56 continue;
57 }
58 match parse_dotenv_file(path) {
59 Ok(parsed) => {
60 for (k, v) in parsed {
61 file_env.insert(k, v);
62 }
63 loaded.push(path.clone());
64 }
65 Err(e) => {
66 if explicit || strict_env_files {
69 return Err(DeployError::Validation(format!(
70 "compose env file {}: {e}",
71 path.display()
72 )));
73 }
74 }
76 }
77 }
78
79 let mut merged = BTreeMap::new();
82 for (k, v) in &svc.environment {
83 merged.insert(k.clone(), v.clone());
84 }
85 for (k, v) in file_env {
86 merged.insert(k, v);
87 }
88 for (k, v) in &env_cfg.env {
89 merged.insert(k.clone(), v.clone());
90 }
91
92 let resolved = resolve_placeholders_map(&merged);
94
95 let mut final_env = BTreeMap::new();
97 for (k, v) in resolved {
98 let value = if v.trim().is_empty() {
99 std::env::var(&k).unwrap_or(v)
100 } else {
101 v
102 };
103 if is_unresolved_placeholder(&value) {
105 continue;
106 }
107 if value.trim().is_empty() {
108 continue;
109 }
110 final_env.insert(k, value);
111 }
112
113 let missing_required: Vec<String> = env_cfg
114 .required_env
115 .iter()
116 .filter(|k| {
117 final_env
118 .get(k.as_str())
119 .map(|v| v.trim().is_empty())
120 .unwrap_or(true)
121 })
122 .cloned()
123 .collect();
124
125 let config_mounts = resolve_config_mounts(
126 project_root,
127 &service_root,
128 &env_cfg.config_files,
129 )?;
130
131 let container_port = env_cfg.container_port.or(svc.port);
132
133 Ok(ComposedRuntime {
134 env: final_env,
135 config_mounts,
136 container_port,
137 loaded_env_files: loaded,
138 missing_required,
139 })
140}
141
142fn resolve_service_root(project_root: &Path, svc: &ServiceConfigView) -> PathBuf {
143 match svc.root_directory.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
144 Some(root) => {
145 let p = PathBuf::from(root);
146 if p.is_absolute() {
147 p
148 } else {
149 project_root.join(p)
150 }
151 }
152 None => project_root.to_path_buf(),
153 }
154}
155
156fn resolve_env_file_list(
157 project_root: &Path,
158 service_root: &Path,
159 env_name: &str,
160 configured: &[String],
161) -> Vec<PathBuf> {
162 let mut out = Vec::new();
163 if !configured.is_empty() {
164 for raw in configured {
165 out.extend(candidate_paths(project_root, service_root, raw));
166 }
167 return out;
168 }
169 let auto = [
171 ".env".to_string(),
172 format!(".env.{env_name}"),
173 ".env.local".to_string(),
174 format!(".env.{env_name}.local"),
175 ];
176 for name in auto {
177 for path in candidate_paths(project_root, service_root, &name) {
178 if path.is_file() {
179 out.push(path);
180 }
181 }
182 }
183 out
184}
185
186fn candidate_paths(project_root: &Path, service_root: &Path, raw: &str) -> Vec<PathBuf> {
187 let p = PathBuf::from(raw.trim());
188 if p.is_absolute() {
189 return vec![p];
190 }
191 let mut v = vec![service_root.join(&p), project_root.join(&p)];
192 v.dedup();
193 v
194}
195
196fn resolve_config_mounts(
197 project_root: &Path,
198 service_root: &Path,
199 configured: &[String],
200) -> Result<Vec<ConfigFileMount>> {
201 let mut names: Vec<String> = if configured.is_empty() {
202 vec!["config.yaml".into(), "config.yml".into()]
203 } else {
204 configured.to_vec()
205 };
206
207 let mut mounts = Vec::new();
208 let mut seen_mount = std::collections::BTreeSet::new();
209
210 for raw in names.drain(..) {
211 let path = candidate_paths(project_root, service_root, &raw)
212 .into_iter()
213 .find(|p| p.is_file());
214 let Some(source) = path else {
215 if !configured.is_empty() {
216 return Err(DeployError::Validation(format!(
217 "compose config file not found: {raw}"
218 )));
219 }
220 continue;
221 };
222 let file_name = source
223 .file_name()
224 .and_then(|s| s.to_str())
225 .unwrap_or("config")
226 .to_string();
227 let key = sanitize_configmap_key(&file_name);
228 let mount_path = if raw.contains('/') || raw.contains('\\') {
229 format!("/app/{}", file_name)
231 } else {
232 format!("/app/{file_name}")
233 };
234 if !seen_mount.insert(mount_path.clone()) {
235 continue;
236 }
237 mounts.push(ConfigFileMount {
238 source,
239 mount_path,
240 key,
241 });
242 if configured.is_empty() {
244 break;
245 }
246 }
247 Ok(mounts)
248}
249
250fn sanitize_configmap_key(name: &str) -> String {
251 name.chars()
252 .map(|c| {
253 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
254 c
255 } else {
256 '-'
257 }
258 })
259 .collect()
260}
261
262fn strip_utf8_bom(content: &str) -> &str {
264 content.strip_prefix('\u{feff}').unwrap_or(content)
265}
266
267fn normalize_env_key(raw: &str) -> String {
269 strip_utf8_bom(raw.trim())
270 .chars()
271 .filter(|ch| {
272 !matches!(
273 ch,
274 '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
275 )
276 })
277 .collect::<String>()
278 .trim()
279 .to_string()
280}
281
282pub fn parse_dotenv_file(path: &Path) -> std::result::Result<BTreeMap<String, String>, String> {
288 let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
289 parse_dotenv_str(&text)
290}
291
292pub fn parse_dotenv_str(text: &str) -> std::result::Result<BTreeMap<String, String>, String> {
293 let text = strip_utf8_bom(text);
294 let mut map = BTreeMap::new();
295 for raw_line in text.lines() {
296 let line = strip_utf8_bom(raw_line.trim());
297 if line.is_empty() || line.starts_with('#') {
298 continue;
299 }
300 let line = line.strip_prefix("export ").unwrap_or(line).trim();
301 let line = strip_utf8_bom(line);
302 let Some((key, value)) = line.split_once('=') else {
303 continue;
305 };
306 let key = normalize_env_key(key);
307 if key.is_empty() {
308 continue;
309 }
310 let value = unquote_env_value(value.trim());
311 map.insert(key, value);
312 }
313 Ok(map)
314}
315
316fn unquote_env_value(value: &str) -> String {
317 let v = strip_utf8_bom(value.trim());
318 if v.len() >= 2 {
319 let bytes = v.as_bytes();
320 if (bytes[0] == b'"' && bytes[v.len() - 1] == b'"')
321 || (bytes[0] == b'\'' && bytes[v.len() - 1] == b'\'')
322 {
323 return v[1..v.len() - 1].to_string();
324 }
325 }
326 if let Some((left, _)) = v.split_once(" #") {
328 return left.trim().to_string();
329 }
330 v.to_string()
331}
332
333fn resolve_placeholders_map(input: &BTreeMap<String, String>) -> BTreeMap<String, String> {
334 let mut out = BTreeMap::new();
335 for (k, v) in input {
336 out.insert(k.clone(), resolve_placeholder_value(v, input));
337 }
338 out
339}
340
341fn resolve_placeholder_value(value: &str, map: &BTreeMap<String, String>) -> String {
342 let mut result = value.to_string();
343 for _ in 0..8 {
345 let mut changed = false;
346 let mut rebuilt = String::new();
347 let mut rest = result.as_str();
348 while let Some(start) = rest.find("${") {
349 rebuilt.push_str(&rest[..start]);
350 let after = &rest[start + 2..];
351 if let Some(end) = after.find('}') {
352 let name = &after[..end];
353 let replacement = map
354 .get(name)
355 .cloned()
356 .or_else(|| std::env::var(name).ok())
357 .unwrap_or_else(|| format!("${{{name}}}"));
358 if !replacement.starts_with("${") {
359 changed = true;
360 }
361 rebuilt.push_str(&replacement);
362 rest = &after[end + 1..];
363 } else {
364 rebuilt.push_str(&rest[start..]);
365 rest = "";
366 break;
367 }
368 }
369 rebuilt.push_str(rest);
370 result = rebuilt;
371 if !changed {
372 break;
373 }
374 }
375 result
376}
377
378fn is_unresolved_placeholder(value: &str) -> bool {
379 let t = value.trim();
380 t.starts_with("${") && t.ends_with('}') && !t[2..t.len().saturating_sub(1)].contains("${")
381}
382
383pub fn env_key_looks_secret(key: &str) -> bool {
385 let k = key.to_ascii_uppercase();
386 k.contains("SECRET")
387 || k.contains("PASSWORD")
388 || k.contains("TOKEN")
389 || k.contains("PRIVATE")
390 || k.contains("API_KEY")
391 || k.contains("WEBHOOK")
392 || k.ends_with("_KEY")
393 || k.contains("URI")
394 || k.contains("URL")
395 && (k.contains("POSTGRES")
396 || k.contains("DATABASE")
397 || k.contains("REDIS")
398 || k.contains("WEBHOOK")
399 || k.contains("DISCORD"))
400 || k.contains("POSTGRES")
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn parses_dotenv_quotes_and_export() {
409 let text = r#"
410# comment
411export FOO=bar
412BAZ="hello world"
413QUX='x'
414EMPTY=
415"#;
416 let m = parse_dotenv_str(text).unwrap();
417 assert_eq!(m.get("FOO").map(String::as_str), Some("bar"));
418 assert_eq!(m.get("BAZ").map(String::as_str), Some("hello world"));
419 assert_eq!(m.get("QUX").map(String::as_str), Some("x"));
420 }
421
422 #[test]
423 fn parses_dotenv_with_utf8_bom_and_skips_junk_lines() {
424 let text = "\u{feff}# Cloudflare\nFOO=bar\n-----BEGIN RSA PRIVATE KEY-----\nBAZ=qux\n";
427 let m = parse_dotenv_str(text).unwrap();
428 assert_eq!(m.get("FOO").map(String::as_str), Some("bar"));
429 assert_eq!(m.get("BAZ").map(String::as_str), Some("qux"));
430 assert!(!m.keys().any(|k| k.contains('\u{feff}')));
431 }
432
433 #[test]
434 fn resolves_placeholders() {
435 let mut m = BTreeMap::new();
436 m.insert("A".into(), "1".into());
437 m.insert("B".into(), "${A}/two".into());
438 let r = resolve_placeholders_map(&m);
439 assert_eq!(r.get("B").map(String::as_str), Some("1/two"));
440 }
441
442 #[test]
443 fn classifies_webhook_env_keys_as_secrets() {
444 assert!(env_key_looks_secret("DISCORD_WEBHOOK_URL"));
445 assert!(env_key_looks_secret("SLACK_WEBHOOK"));
446 assert!(env_key_looks_secret("MOLLIE_WEBHOOK_SINK_SECRET"));
447 assert!(!env_key_looks_secret("PORT"));
448 assert!(!env_key_looks_secret("NODE_ENV"));
449 assert!(!env_key_looks_secret("FILE_URL_CACHE_TTL_SECONDS"));
450 }
451}