Skip to main content

vsra/commands/
env.rs

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
14/// Generate a secure random string for JWT secret
15fn 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
572/// Generate .env template file
573pub 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}