1use crate::error::{Error, Result};
2use rand::distr::{Alphanumeric, SampleString};
3use rand::{RngExt, rng};
4use rest_macro_core::auth::{
5 AuthClaimType, AuthEmailProvider, AuthJwtAlgorithm, auth_jwt_signing_secret_ref,
6};
7use rest_macro_core::compiler::{self, default_service_database_url};
8use rest_macro_core::database::{DEFAULT_TURSO_LOCAL_ENCRYPTION_KEY_ENV, DatabaseEngine};
9use rest_macro_core::secret::SecretRef;
10use std::collections::{BTreeMap, BTreeSet};
11use std::fmt::Write as _;
12use std::path::{Path, PathBuf};
13
14fn generate_random_secret(length: usize) -> String {
16 let mut random = rng();
17 Alphanumeric.sample_string(&mut random, length)
18}
19
20fn generate_random_hex(bytes_len: usize) -> String {
21 let mut random = rng();
22 let mut output = String::with_capacity(bytes_len * 2);
23 for _ in 0..(bytes_len * 2) {
24 let value = random.random_range(0_u8..16_u8);
25 write!(&mut output, "{value:x}").expect("hex write should succeed");
26 }
27 output
28}
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum EnvTemplateMode {
32 Development,
33 Production,
34}
35
36impl EnvTemplateMode {
37 pub fn writes_live_secrets(self) -> bool {
38 matches!(self, Self::Development)
39 }
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct EnvFileReport {
44 pub path: PathBuf,
45 pub backup_path: Option<PathBuf>,
46 pub generated_turso_encryption_var: Option<String>,
47 pub preserved_turso_encryption_var: Option<String>,
48 pub generated_jwt_secret: bool,
49 pub preserved_jwt_secret: bool,
50 pub mode: EnvTemplateMode,
51}
52
53struct EnvTemplateConfig {
54 database_url: String,
55 jwt_secret_var: Option<String>,
56 jwt_algorithm: Option<AuthJwtAlgorithm>,
57 turso_encryption_var: Option<String>,
58 auth_email_env_var: Option<String>,
59 auth_email_env_comment: Option<String>,
60 admin_claim_examples: Vec<AdminClaimEnvExample>,
61 cors_origins_var: Option<String>,
62 trusted_proxies_var: Option<String>,
63 log_filter_env: String,
64 log_default_filter: String,
65 bind_addr: String,
66 tls_cert_path_env: Option<String>,
67 tls_key_path_env: Option<String>,
68 tls_cert_path: Option<String>,
69 tls_key_path: Option<String>,
70}
71
72struct AdminClaimEnvExample {
73 env_var: String,
74 example_value: String,
75 comment: String,
76}
77
78fn env_template_config(config_path: Option<&Path>) -> Result<EnvTemplateConfig> {
79 let Some(path) = config_path else {
80 return Ok(EnvTemplateConfig {
81 database_url: "sqlite:var/data/app.db?mode=rwc".to_owned(),
82 jwt_secret_var: Some("JWT_SECRET".to_owned()),
83 jwt_algorithm: Some(AuthJwtAlgorithm::Hs256),
84 turso_encryption_var: None,
85 auth_email_env_var: None,
86 auth_email_env_comment: None,
87 admin_claim_examples: Vec::new(),
88 cors_origins_var: None,
89 trusted_proxies_var: None,
90 log_filter_env: "RUST_LOG".to_owned(),
91 log_default_filter: "info".to_owned(),
92 bind_addr: "127.0.0.1:8080".to_owned(),
93 tls_cert_path_env: None,
94 tls_key_path_env: None,
95 tls_cert_path: None,
96 tls_key_path: None,
97 });
98 };
99
100 let service = compiler::load_service_from_path(path)
101 .map_err(|error| crate::error::Error::Config(error.to_string()))?;
102 let jwt_secret_var = auth_jwt_signing_secret_ref(&service.security.auth)
103 .and_then(SecretRef::env_binding_name)
104 .map(str::to_owned);
105 let jwt_algorithm = service.security.auth.jwt.as_ref().map(|jwt| jwt.algorithm);
106 let turso_encryption_var = match &service.database.engine {
107 DatabaseEngine::TursoLocal(engine) => engine
108 .encryption_key
109 .as_ref()
110 .and_then(SecretRef::env_binding_name)
111 .map(str::to_owned),
112 DatabaseEngine::Sqlx => None,
113 };
114 let (auth_email_env_var, auth_email_env_comment) = match service.security.auth.email.as_ref() {
115 Some(email) => match &email.provider {
116 AuthEmailProvider::Resend { api_key, .. } => (
117 api_key.env_binding_name().map(str::to_owned),
118 Some("Built-in auth email delivery via Resend".to_owned()),
119 ),
120 AuthEmailProvider::Smtp { connection_url } => (
121 connection_url.env_binding_name().map(str::to_owned),
122 Some("Built-in auth email delivery via SMTP/lettre".to_owned()),
123 ),
124 },
125 None => (None, None),
126 };
127 let admin_claim_examples = configured_admin_claim_env_examples(&service.security.auth.claims);
128
129 Ok(EnvTemplateConfig {
130 database_url: default_service_database_url(&service),
131 jwt_secret_var,
132 jwt_algorithm,
133 turso_encryption_var,
134 auth_email_env_var,
135 auth_email_env_comment,
136 admin_claim_examples,
137 cors_origins_var: service.security.cors.origins_env.clone(),
138 trusted_proxies_var: service.security.trusted_proxies.proxies_env.clone(),
139 log_filter_env: service.logging.filter_env.clone(),
140 log_default_filter: service.logging.default_filter.clone(),
141 bind_addr: if service.tls.is_enabled() {
142 "127.0.0.1:8443".to_owned()
143 } else {
144 "127.0.0.1:8080".to_owned()
145 },
146 tls_cert_path_env: service.tls.cert_path_env.clone(),
147 tls_key_path_env: service.tls.key_path_env.clone(),
148 tls_cert_path: service.tls.cert_path.clone(),
149 tls_key_path: service.tls.key_path.clone(),
150 })
151}
152
153fn render_secret_binding_comment(output: &mut String, var_name: &str, example: &str) {
154 writeln!(output, "# {var_name}={example}").unwrap();
155 writeln!(
156 output,
157 "# Or mount a secret file and set {var_name}_FILE=/run/secrets/{var_name}"
158 )
159 .unwrap();
160}
161
162fn render_env_template_with_config(config: &EnvTemplateConfig, mode: EnvTemplateMode) -> String {
163 let jwt_secret = generate_random_secret(32);
164 let mut output = String::new();
165
166 writeln!(&mut output, "# very_simple_rest API Configuration").unwrap();
167 if mode == EnvTemplateMode::Production {
168 writeln!(
169 &mut output,
170 "# Production mode: this template does not write live secret values."
171 )
172 .unwrap();
173 writeln!(
174 &mut output,
175 "# Prefer workload identity or a secret manager. Mounted secret files are the next-best option."
176 )
177 .unwrap();
178 }
179 writeln!(&mut output).unwrap();
180 writeln!(&mut output, "# Database Configuration").unwrap();
181 writeln!(
182 &mut output,
183 "# Override DATABASE_URL only if you need a runtime target different from the service defaults."
184 )
185 .unwrap();
186 writeln!(&mut output, "DATABASE_URL={}", config.database_url).unwrap();
187 writeln!(
188 &mut output,
189 "# If the runtime database URL contains credentials, prefer DATABASE_URL_FILE=/run/secrets/DATABASE_URL"
190 )
191 .unwrap();
192 writeln!(&mut output).unwrap();
193
194 if let Some(var_name) = &config.turso_encryption_var {
195 let heading = if var_name == DEFAULT_TURSO_LOCAL_ENCRYPTION_KEY_ENV {
196 "# Local Turso encryption used by the compiled database engine"
197 } else {
198 "# Local Turso encryption"
199 };
200 writeln!(&mut output, "{heading}").unwrap();
201 if mode.writes_live_secrets() {
202 let turso_key = generate_random_hex(32);
203 writeln!(&mut output, "{var_name}={turso_key}").unwrap();
204 writeln!(
205 &mut output,
206 "# Or mount a secret file and set {var_name}_FILE=/run/secrets/{var_name}"
207 )
208 .unwrap();
209 } else {
210 render_secret_binding_comment(&mut output, var_name, "change-me-64-hex-characters");
211 }
212 writeln!(&mut output).unwrap();
213 }
214
215 if let (Some(cert_env), Some(key_env), Some(cert_path), Some(key_path)) = (
216 config.tls_cert_path_env.as_deref(),
217 config.tls_key_path_env.as_deref(),
218 config.tls_cert_path.as_deref(),
219 config.tls_key_path.as_deref(),
220 ) {
221 writeln!(&mut output, "# TLS (Rustls)").unwrap();
222 writeln!(
223 &mut output,
224 "# This service defaults to HTTPS + HTTP/2. Generate local certs with `vsr tls self-signed`."
225 )
226 .unwrap();
227 writeln!(&mut output, "# {cert_env}={cert_path}").unwrap();
228 writeln!(&mut output, "# {key_env}={key_path}").unwrap();
229 writeln!(&mut output).unwrap();
230 }
231
232 writeln!(&mut output, "# Authentication").unwrap();
233 writeln!(
234 &mut output,
235 "# Required secret key used for JWT token generation and verification"
236 )
237 .unwrap();
238 writeln!(
239 &mut output,
240 "# IMPORTANT: Changing this will invalidate all existing user tokens"
241 )
242 .unwrap();
243 let jwt_secret_var = config.jwt_secret_var.as_deref().unwrap_or("JWT_SECRET");
244 if mode.writes_live_secrets()
245 && config
246 .jwt_algorithm
247 .unwrap_or(AuthJwtAlgorithm::Hs256)
248 .is_symmetric()
249 {
250 writeln!(&mut output, "{jwt_secret_var}={jwt_secret}").unwrap();
251 writeln!(
252 &mut output,
253 "# Or mount a secret file and set {jwt_secret_var}_FILE=/run/secrets/{jwt_secret_var}"
254 )
255 .unwrap();
256 } else {
257 if config
258 .jwt_algorithm
259 .unwrap_or(AuthJwtAlgorithm::Hs256)
260 .is_symmetric()
261 {
262 render_secret_binding_comment(&mut output, jwt_secret_var, "change-me");
263 } else {
264 writeln!(
265 &mut output,
266 "# Prefer a PEM file and set {jwt_secret_var}_FILE=/run/secrets/{jwt_secret_var}"
267 )
268 .unwrap();
269 writeln!(
270 &mut output,
271 "# {jwt_secret_var}=-----BEGIN PRIVATE KEY-----..."
272 )
273 .unwrap();
274 }
275 }
276 writeln!(&mut output).unwrap();
277 if let Some(var_name) = &config.auth_email_env_var {
278 writeln!(
279 &mut output,
280 "# {}",
281 config
282 .auth_email_env_comment
283 .as_deref()
284 .unwrap_or("Built-in auth email delivery")
285 )
286 .unwrap();
287 if config
288 .auth_email_env_comment
289 .as_deref()
290 .unwrap_or_default()
291 .contains("SMTP")
292 {
293 render_secret_binding_comment(
294 &mut output,
295 var_name,
296 "smtp://user:password@smtp.example.com:587",
297 );
298 } else {
299 render_secret_binding_comment(&mut output, var_name, "change-me");
300 }
301 writeln!(&mut output).unwrap();
302 }
303 writeln!(&mut output, "# Admin User (optional)").unwrap();
304 if mode == EnvTemplateMode::Production {
305 writeln!(
306 &mut output,
307 "# Avoid storing bootstrap credentials in persisted env files for production."
308 )
309 .unwrap();
310 writeln!(
311 &mut output,
312 "# Prefer interactive bootstrap or one-shot CI / secret-manager injection during setup."
313 )
314 .unwrap();
315 } else {
316 writeln!(
317 &mut output,
318 "# If set, these will be used when creating the admin user"
319 )
320 .unwrap();
321 }
322 writeln!(&mut output, "# ADMIN_EMAIL=admin@example.com").unwrap();
323 writeln!(&mut output, "# ADMIN_PASSWORD=securepassword").unwrap();
324 if config.admin_claim_examples.is_empty() {
325 writeln!(
326 &mut output,
327 "# Optional auth claim columns use ADMIN_<COLUMN_NAME>, for example:"
328 )
329 .unwrap();
330 writeln!(&mut output, "# ADMIN_TENANT_ID=1").unwrap();
331 } else {
332 writeln!(
333 &mut output,
334 "# Explicit security.auth.claims values are supplied with ADMIN_<COLUMN_NAME>:"
335 )
336 .unwrap();
337 for example in &config.admin_claim_examples {
338 writeln!(
339 &mut output,
340 "# {}={} ({})",
341 example.env_var, example.example_value, example.comment
342 )
343 .unwrap();
344 }
345 }
346 writeln!(&mut output).unwrap();
347 writeln!(&mut output, "# Server Configuration").unwrap();
348 writeln!(&mut output, "BIND_ADDR={}", config.bind_addr).unwrap();
349 writeln!(&mut output).unwrap();
350
351 if let Some(var_name) = &config.cors_origins_var {
352 writeln!(&mut output, "# Security Overrides").unwrap();
353 writeln!(
354 &mut output,
355 "# {var_name}=http://localhost:3000,http://127.0.0.1:3000"
356 )
357 .unwrap();
358 if let Some(proxy_var) = &config.trusted_proxies_var {
359 writeln!(&mut output, "# {proxy_var}=127.0.0.1,::1").unwrap();
360 }
361 writeln!(&mut output).unwrap();
362 } else if let Some(proxy_var) = &config.trusted_proxies_var {
363 writeln!(&mut output, "# Security Overrides").unwrap();
364 writeln!(&mut output, "# {proxy_var}=127.0.0.1,::1").unwrap();
365 writeln!(&mut output).unwrap();
366 }
367
368 writeln!(&mut output, "# Logging").unwrap();
369 writeln!(
370 &mut output,
371 "# Possible values: error, warn, info, debug, trace"
372 )
373 .unwrap();
374 writeln!(
375 &mut output,
376 "{}={}",
377 config.log_filter_env, config.log_default_filter
378 )
379 .unwrap();
380
381 output
382}
383
384pub fn render_env_template_for_mode(
385 config_path: Option<&Path>,
386 mode: EnvTemplateMode,
387) -> Result<String> {
388 let config = env_template_config(config_path)?;
389 Ok(render_env_template_with_config(&config, mode))
390}
391
392pub fn render_env_template(config_path: Option<&Path>) -> Result<String> {
393 render_env_template_for_mode(config_path, EnvTemplateMode::Development)
394}
395
396fn configured_admin_claim_env_examples(
397 claims: &std::collections::BTreeMap<String, rest_macro_core::auth::AuthClaimMapping>,
398) -> Vec<AdminClaimEnvExample> {
399 let mut seen_columns = BTreeSet::new();
400 let mut examples = Vec::new();
401
402 for (claim_name, mapping) in claims {
403 if !seen_columns.insert(mapping.column.clone()) {
404 continue;
405 }
406
407 let example_value = match mapping.ty {
408 AuthClaimType::I64 => "1",
409 AuthClaimType::String => "pro",
410 AuthClaimType::Bool => "true",
411 };
412 let comment = if claim_name == &mapping.column {
413 format!(
414 "claim.{claim_name} ({})",
415 admin_claim_type_label(mapping.ty)
416 )
417 } else {
418 format!(
419 "claim.{claim_name} from user.{} ({})",
420 mapping.column,
421 admin_claim_type_label(mapping.ty)
422 )
423 };
424 examples.push(AdminClaimEnvExample {
425 env_var: admin_claim_env_var(&mapping.column),
426 example_value: example_value.to_owned(),
427 comment,
428 });
429 }
430
431 examples
432}
433
434fn admin_claim_env_var(column_name: &str) -> String {
435 let mut env_var = String::from("ADMIN_");
436 for ch in column_name.chars() {
437 if ch.is_ascii_alphanumeric() {
438 env_var.push(ch.to_ascii_uppercase());
439 } else {
440 env_var.push('_');
441 }
442 }
443 env_var
444}
445
446fn admin_claim_type_label(ty: AuthClaimType) -> &'static str {
447 match ty {
448 AuthClaimType::I64 => "I64",
449 AuthClaimType::String => "String",
450 AuthClaimType::Bool => "Bool",
451 }
452}
453
454fn absolutize_path(path: &Path) -> Result<PathBuf> {
455 if path.is_absolute() {
456 Ok(path.to_path_buf())
457 } else {
458 Ok(std::env::current_dir()?.join(path))
459 }
460}
461
462pub fn default_env_path(config_path: Option<&Path>) -> Result<PathBuf> {
463 let relative = match config_path {
464 Some(config_path) => config_path
465 .parent()
466 .map(|parent| parent.join(".env"))
467 .unwrap_or_else(|| PathBuf::from(".env")),
468 None => PathBuf::from(".env"),
469 };
470 absolutize_path(&relative)
471}
472
473pub fn load_env_file(path: &Path) -> Result<()> {
474 dotenv::from_path(path).map_err(|error| {
475 Error::Config(format!(
476 "failed to load environment file `{}`: {error}",
477 path.display()
478 ))
479 })
480}
481
482pub fn write_env_file(
483 output_path: Option<&Path>,
484 config_path: Option<&Path>,
485 backup_existing: bool,
486 refuse_existing: bool,
487 mode: EnvTemplateMode,
488) -> Result<EnvFileReport> {
489 let env_path = match output_path {
490 Some(path) => absolutize_path(path)?,
491 None => default_env_path(config_path)?,
492 };
493
494 if let Some(parent) = env_path.parent() {
495 std::fs::create_dir_all(parent)?;
496 }
497
498 let existing_assignments = if env_path.exists() {
499 parse_env_assignments(&std::fs::read_to_string(&env_path)?)
500 } else {
501 BTreeMap::new()
502 };
503
504 let backup_path = if env_path.exists() {
505 if refuse_existing {
506 return Err(Error::Config(format!(
507 "Environment file already exists at {}. Use --force to overwrite.",
508 env_path.display()
509 )));
510 }
511
512 if backup_existing {
513 let backup_name = format!(
514 "{}.backup",
515 env_path
516 .file_name()
517 .and_then(|value| value.to_str())
518 .unwrap_or(".env")
519 );
520 let backup_path = env_path
521 .parent()
522 .map(|parent| parent.join(&backup_name))
523 .unwrap_or_else(|| PathBuf::from(backup_name));
524 std::fs::copy(&env_path, &backup_path)?;
525 Some(backup_path)
526 } else {
527 None
528 }
529 } else {
530 None
531 };
532
533 let config = env_template_config(config_path)?;
534 let preserved_assignments =
535 merge_preserved_assignments_with_current_env(&config, &existing_assignments);
536 let jwt_secret_var = config.jwt_secret_var.as_deref().unwrap_or("JWT_SECRET");
537 let preserved_jwt_secret = existing_env_value(&preserved_assignments, jwt_secret_var).is_some();
538 let preserved_turso_encryption_var = config
539 .turso_encryption_var
540 .as_ref()
541 .filter(|var_name| existing_env_value(&preserved_assignments, var_name.as_str()).is_some())
542 .cloned();
543 let content = merge_env_template_with_existing(
544 &render_env_template_with_config(&config, mode),
545 &preserved_assignments,
546 );
547 std::fs::write(&env_path, content)?;
548
549 Ok(EnvFileReport {
550 path: env_path,
551 backup_path,
552 generated_turso_encryption_var: if mode.writes_live_secrets()
553 && config.turso_encryption_var.is_some()
554 && preserved_turso_encryption_var.is_none()
555 {
556 config.turso_encryption_var
557 } else {
558 None
559 },
560 preserved_turso_encryption_var,
561 generated_jwt_secret: mode.writes_live_secrets()
562 && config
563 .jwt_algorithm
564 .unwrap_or(AuthJwtAlgorithm::Hs256)
565 .is_symmetric()
566 && !preserved_jwt_secret,
567 preserved_jwt_secret,
568 mode,
569 })
570}
571
572pub fn generate_env_template(
574 config_path: Option<&Path>,
575 mode: EnvTemplateMode,
576) -> Result<EnvFileReport> {
577 write_env_file(None, config_path, true, false, mode)
578}
579
580fn parse_env_assignments(content: &str) -> BTreeMap<String, String> {
581 content
582 .lines()
583 .filter_map(|line| {
584 let trimmed = line.trim();
585 if trimmed.is_empty() || trimmed.starts_with('#') {
586 return None;
587 }
588 let (key, value) = trimmed.split_once('=')?;
589 let key = key.trim();
590 if key.is_empty() {
591 return None;
592 }
593 Some((key.to_owned(), value.to_owned()))
594 })
595 .collect()
596}
597
598fn existing_env_value<'a>(
599 existing_assignments: &'a BTreeMap<String, String>,
600 key: &str,
601) -> Option<&'a String> {
602 existing_assignments
603 .get(key)
604 .filter(|value| !value.trim().is_empty())
605}
606
607fn merge_preserved_assignments_with_current_env(
608 config: &EnvTemplateConfig,
609 existing_assignments: &BTreeMap<String, String>,
610) -> BTreeMap<String, String> {
611 let mut merged = existing_assignments.clone();
612 let mut candidate_keys = BTreeSet::new();
613 candidate_keys.insert("DATABASE_URL".to_owned());
614 if let Some(var_name) = &config.jwt_secret_var {
615 candidate_keys.insert(var_name.clone());
616 }
617 if let Some(var_name) = &config.turso_encryption_var {
618 candidate_keys.insert(var_name.clone());
619 }
620 if let Some(var_name) = &config.auth_email_env_var {
621 candidate_keys.insert(var_name.clone());
622 }
623
624 for key in candidate_keys {
625 if existing_env_value(&merged, &key).is_some() {
626 continue;
627 }
628 if let Ok(value) = std::env::var(&key)
629 && !value.trim().is_empty()
630 {
631 merged.insert(key, value);
632 }
633 }
634
635 merged
636}
637
638fn merge_env_template_with_existing(
639 rendered: &str,
640 existing_assignments: &BTreeMap<String, String>,
641) -> String {
642 if existing_assignments.is_empty() {
643 return rendered.to_owned();
644 }
645
646 let mut merged = Vec::new();
647 let mut seen = BTreeSet::new();
648
649 for line in rendered.lines() {
650 if let Some((key, _)) = line.split_once('=')
651 && let Some(existing_value) = existing_env_value(existing_assignments, key.trim())
652 {
653 merged.push(format!("{}={}", key.trim(), existing_value));
654 seen.insert(key.trim().to_owned());
655 continue;
656 }
657 merged.push(line.to_owned());
658 }
659
660 let extra_assignments = existing_assignments
661 .iter()
662 .filter(|(key, value)| !seen.contains(key.as_str()) && !value.trim().is_empty())
663 .collect::<Vec<_>>();
664 if !extra_assignments.is_empty() {
665 merged.push(String::new());
666 merged.push("# Preserved existing variables".to_owned());
667 for (key, value) in extra_assignments {
668 merged.push(format!("{key}={value}"));
669 }
670 }
671
672 format!("{}\n", merged.join("\n"))
673}
674
675#[cfg(test)]
676mod tests {
677 use super::{
678 EnvTemplateMode, default_env_path, render_env_template, render_env_template_for_mode,
679 write_env_file,
680 };
681 use std::fs;
682 use std::path::PathBuf;
683 use std::sync::Mutex;
684 use std::time::{SystemTime, UNIX_EPOCH};
685
686 fn fixture_path(name: &str) -> PathBuf {
687 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
688 .join("../../tests/fixtures")
689 .join(name)
690 }
691
692 fn temp_root(prefix: &str) -> PathBuf {
693 let stamp = SystemTime::now()
694 .duration_since(UNIX_EPOCH)
695 .expect("time should move forward")
696 .as_nanos();
697 std::env::temp_dir().join(format!("vsr_env_{prefix}_{stamp}"))
698 }
699
700 fn env_lock() -> &'static Mutex<()> {
701 crate::test_support::env_lock()
702 }
703
704 fn env_line_value<'a>(content: &'a str, key: &str) -> Option<&'a str> {
705 content
706 .lines()
707 .find_map(|line| line.strip_prefix(&format!("{key}=")))
708 }
709
710 #[test]
711 fn rendered_env_template_reflects_service_security_and_turso_vars() {
712 let content = render_env_template(Some(&fixture_path("security_api.eon")))
713 .expect("security fixture should render");
714 assert!(content.contains("DATABASE_URL=sqlite:var/data/security_api.db?mode=rwc"));
715 let turso_key =
716 env_line_value(&content, "TURSO_ENCRYPTION_KEY").expect("turso key should exist");
717 assert_eq!(turso_key.len(), 64);
718 assert!(turso_key.chars().all(|ch| ch.is_ascii_hexdigit()));
719 assert!(content.contains("# CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000"));
720 assert!(content.contains("# TRUSTED_PROXIES=127.0.0.1,::1"));
721 assert!(content.contains("APP_LOG=debug,sqlx=warn"));
722 }
723
724 #[test]
725 fn rendered_env_template_reflects_turso_encryption_var() {
726 let content = render_env_template(Some(&fixture_path("turso_local_encrypted_api.eon")))
727 .expect("encrypted fixture should render");
728 assert!(content.contains("DATABASE_URL=sqlite:var/data/turso_encrypted.db?mode=rwc"));
729 let turso_key =
730 env_line_value(&content, "TURSO_ENCRYPTION_KEY").expect("turso key should exist");
731 assert_eq!(turso_key.len(), 64);
732 assert!(turso_key.chars().all(|ch| ch.is_ascii_hexdigit()));
733 }
734
735 #[test]
736 fn rendered_env_template_includes_auth_email_provider_env_hints() {
737 let content = render_env_template(Some(&fixture_path("auth_management_api.eon")))
738 .expect("auth management fixture should render");
739 assert!(content.contains("RESEND_API_KEY=change-me"));
740 assert!(content.contains(
741 "# Or mount a secret file and set RESEND_API_KEY_FILE=/run/secrets/RESEND_API_KEY"
742 ));
743 }
744
745 #[test]
746 fn rendered_env_template_includes_explicit_auth_claim_examples() {
747 let content = render_env_template(Some(&fixture_path("auth_claims_api.eon")))
748 .expect("auth claims fixture should render");
749 assert!(content.contains("# ADMIN_TENANT_SCOPE=1"));
750 assert!(content.contains("# ADMIN_CLAIM_WORKSPACE_ID=1"));
751 assert!(content.contains("# ADMIN_IS_STAFF=true"));
752 assert!(content.contains("# ADMIN_PLAN=pro"));
753 }
754
755 #[test]
756 fn production_env_template_does_not_write_live_secret_values() {
757 let content = render_env_template_for_mode(
758 Some(&fixture_path("auth_management_api.eon")),
759 EnvTemplateMode::Production,
760 )
761 .expect("production template should render");
762 assert!(
763 content.contains("# Production mode: this template does not write live secret values.")
764 );
765 assert!(content.contains("DATABASE_URL=sqlite:var/data/auth_management_api.db?mode=rwc"));
766 assert!(content.contains("# JWT_SECRET=change-me"));
767 assert!(!content.contains("\nJWT_SECRET="));
768 assert!(content.contains("# RESEND_API_KEY=change-me"));
769 assert!(!content.contains("\nRESEND_API_KEY="));
770 }
771
772 #[test]
773 fn default_env_path_uses_service_directory_when_config_is_present() {
774 let config = PathBuf::from("/tmp/example/service/api.eon");
775 assert_eq!(
776 default_env_path(Some(&config)).expect("env path should resolve"),
777 PathBuf::from("/tmp/example/service/.env")
778 );
779 }
780
781 #[test]
782 fn write_env_file_can_backup_existing_env_in_service_directory() {
783 let root = temp_root("write_env");
784 fs::create_dir_all(&root).expect("root should exist");
785 let config = root.join("api.eon");
786 fs::copy(fixture_path("security_api.eon"), &config).expect("fixture should copy");
787 let env_path = root.join(".env");
788 fs::write(&env_path, "DATABASE_URL=sqlite:old.db\n").expect("existing env should write");
789
790 let report = write_env_file(
791 None,
792 Some(&config),
793 true,
794 false,
795 EnvTemplateMode::Development,
796 )
797 .expect("env file should write with backup");
798 assert_eq!(report.path, env_path);
799 assert_eq!(report.backup_path, Some(root.join(".env.backup")));
800 assert!(
801 fs::read_to_string(root.join(".env.backup"))
802 .expect("backup should read")
803 .contains("DATABASE_URL=sqlite:old.db")
804 );
805 assert!(
806 fs::read_to_string(&report.path)
807 .expect("env should read")
808 .contains("JWT_SECRET=")
809 );
810
811 let _ = fs::remove_dir_all(root);
812 }
813
814 #[test]
815 fn write_env_file_preserves_existing_secret_values_and_custom_vars() {
816 let root = temp_root("preserve_env");
817 fs::create_dir_all(&root).expect("root should exist");
818 let config = root.join("api.eon");
819 fs::copy(fixture_path("turso_local_encrypted_api.eon"), &config)
820 .expect("fixture should copy");
821 let env_path = root.join(".env");
822 fs::write(
823 &env_path,
824 "DATABASE_URL=sqlite:custom.db?mode=rwc\nJWT_SECRET=preserve-jwt\nTURSO_ENCRYPTION_KEY=preserve-key\nEXTRA_TOKEN=keep-me\n",
825 )
826 .expect("existing env should write");
827
828 let report = write_env_file(
829 None,
830 Some(&config),
831 true,
832 false,
833 EnvTemplateMode::Development,
834 )
835 .expect("env file should preserve existing values");
836 let content = fs::read_to_string(&report.path).expect("env should read");
837
838 assert!(report.preserved_jwt_secret);
839 assert!(!report.generated_jwt_secret);
840 assert_eq!(
841 report.preserved_turso_encryption_var.as_deref(),
842 Some("TURSO_ENCRYPTION_KEY")
843 );
844 assert_eq!(report.generated_turso_encryption_var, None);
845 assert!(content.contains("DATABASE_URL=sqlite:custom.db?mode=rwc"));
846 assert!(content.contains("JWT_SECRET=preserve-jwt"));
847 assert!(content.contains("TURSO_ENCRYPTION_KEY=preserve-key"));
848 assert!(content.contains("EXTRA_TOKEN=keep-me"));
849
850 let _ = fs::remove_dir_all(root);
851 }
852
853 #[test]
854 fn write_env_file_preserves_current_env_secret_values_when_creating_new_file() {
855 let _guard = env_lock().lock().unwrap_or_else(|error| error.into_inner());
856 unsafe {
857 std::env::set_var("JWT_SECRET", "keep-current-jwt");
858 std::env::set_var("TURSO_ENCRYPTION_KEY", "keep-current-turso");
859 }
860
861 let root = temp_root("preserve_current_env");
862 fs::create_dir_all(&root).expect("root should exist");
863 let config = root.join("api.eon");
864 fs::copy(fixture_path("turso_local_encrypted_api.eon"), &config)
865 .expect("fixture should copy");
866
867 let report = write_env_file(
868 None,
869 Some(&config),
870 false,
871 false,
872 EnvTemplateMode::Development,
873 )
874 .expect("env file should write");
875 let content = fs::read_to_string(root.join(".env")).expect("env file should exist");
876
877 assert!(report.preserved_jwt_secret);
878 assert!(!report.generated_jwt_secret);
879 assert_eq!(
880 report.preserved_turso_encryption_var.as_deref(),
881 Some("TURSO_ENCRYPTION_KEY")
882 );
883 assert_eq!(report.generated_turso_encryption_var, None);
884 assert!(content.contains("JWT_SECRET=keep-current-jwt"));
885 assert!(content.contains("TURSO_ENCRYPTION_KEY=keep-current-turso"));
886
887 unsafe {
888 std::env::remove_var("JWT_SECRET");
889 std::env::remove_var("TURSO_ENCRYPTION_KEY");
890 }
891 let _ = fs::remove_dir_all(root);
892 }
893
894 #[test]
895 fn rendered_env_template_prefers_file_hints_for_asymmetric_jwt() {
896 let root = temp_root("jwt_asymmetric_env");
897 fs::create_dir_all(&root).expect("root should exist");
898 let config = root.join("api.eon");
899 fs::write(
900 &config,
901 r#"
902 module: "jwt_env_api"
903 security: {
904 auth: {
905 jwt: {
906 algorithm: EdDSA
907 active_kid: "current"
908 signing_key: { env_or_file: "JWT_SIGNING_KEY" }
909 verification_keys: [
910 { kid: "current", key: { env_or_file: "JWT_VERIFYING_KEY" } }
911 ]
912 }
913 }
914 }
915 resources: [
916 {
917 name: "Post"
918 fields: [{ name: "id", type: I64, id: true }]
919 }
920 ]
921 "#,
922 )
923 .expect("config should write");
924
925 let content = render_env_template(Some(&config)).expect("env template should render");
926 assert!(!content.contains("\nJWT_SIGNING_KEY="));
927 assert!(content.contains("# JWT_SIGNING_KEY=-----BEGIN PRIVATE KEY-----..."));
928 assert!(content.contains(
929 "# Prefer a PEM file and set JWT_SIGNING_KEY_FILE=/run/secrets/JWT_SIGNING_KEY"
930 ));
931
932 let _ = fs::remove_dir_all(root);
933 }
934}