Skip to main content

cli/env/
workspace.rs

1use super::broker::{SourceSnapshot, WorkspaceSnapshot};
2use crate::commands::EnvWorkspaceExportFormat;
3use crate::persist::{atomic_write, atomic_write_private};
4use crate::secret::{BackendKind, EncryptRecipients};
5use crate::{config::Config, secret};
6use anyhow::{Context, Result, bail};
7use dialoguer::Password;
8use directories::BaseDirs;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    ffi::OsString,
14    path::{Path, PathBuf},
15};
16use tokio::process::Command;
17use toml_edit::{DocumentMut, value};
18use zeroize::Zeroize;
19
20const WORKSPACE_FILE: &str = "shine.workspace.toml";
21const WORKSPACE_FORMAT_VERSION: u32 = 2;
22const ENV_SOURCE_FORMAT_VERSION: u32 = 1;
23const SECRET_PAYLOAD_VERSION: u32 = 1;
24const CACHE_FORMAT_VERSION: u32 = 1;
25
26/// Initialize a workspace by copying conventional dotenv sources into Shine's
27/// explicit TOML source format. The original dotenv files are never modified.
28pub async fn handle_init_from_dotenv(
29    from_dotenv: bool,
30    requested_modes: &[String],
31    secrets: &[String],
32    force: bool,
33    dry_run: bool,
34) -> Result<()> {
35    if !from_dotenv {
36        bail!("pass --from-dotenv to initialize from conventional dotenv files");
37    }
38
39    let root = std::env::current_dir().context("resolving current directory")?;
40    init_from_dotenv_at(&root, requested_modes, secrets, force, dry_run).await
41}
42
43#[allow(clippy::too_many_arguments)]
44pub async fn handle_export(
45    config: &Config,
46    format: EnvWorkspaceExportFormat,
47    workspace_arg: Option<&Path>,
48    mode: &str,
49    output: &Path,
50    include_secrets: bool,
51    force: bool,
52    dry_run: bool,
53) -> Result<()> {
54    validate_mode(mode)?;
55    let workspace_path = find_workspace_optional(workspace_arg)
56        .await?
57        .context("shine.workspace.toml was not found; pass --workspace")?;
58    let workspace = load_workspace(&workspace_path).await?;
59    let sources = resolve_sources(&workspace_path, &workspace.env.files, mode)?;
60    let output = absolute_from_current(output)?;
61    if output.exists() && !force {
62        bail!(
63            "{} already exists; rerun with --force to replace it",
64            output.display()
65        );
66    }
67
68    let mut values = compile_export_sources(&sources, config, include_secrets).await?;
69    let mut contents = match format {
70        EnvWorkspaceExportFormat::Dotenv => render_dotenv(&values)?,
71    };
72    if dry_run {
73        println!(
74            "Would export {} variables for mode {mode} to {}{}",
75            values.len(),
76            output.display(),
77            if include_secrets {
78                " (including secrets)"
79            } else {
80                ""
81            }
82        );
83        for value in values.values_mut() {
84            value.zeroize();
85        }
86        contents.zeroize();
87    } else {
88        let write_result = if include_secrets {
89            atomic_write_private(&output, contents.as_bytes()).await
90        } else {
91            atomic_write(&output, contents.as_bytes()).await
92        };
93        for value in values.values_mut() {
94            value.zeroize();
95        }
96        contents.zeroize();
97        write_result?;
98        println!(
99            "Exported {} variables for mode {mode} to {}{}",
100            values.len(),
101            output.display(),
102            if include_secrets {
103                " (including secrets)"
104            } else {
105                ""
106            }
107        );
108        if include_secrets {
109            eprintln!("Warning: the exported file contains plaintext secrets; do not commit it.");
110        }
111    }
112    Ok(())
113}
114
115fn render_dotenv(values: &BTreeMap<String, String>) -> Result<String> {
116    let mut rendered = String::new();
117    for (key, value) in values {
118        if value.contains('\0') {
119            bail!("{key} contains a NUL byte and cannot be represented in dotenv format");
120        }
121        rendered.push_str(key);
122        rendered.push('=');
123        rendered.push('"');
124        for ch in value.chars() {
125            match ch {
126                '\\' => rendered.push_str("\\\\"),
127                '"' => rendered.push_str("\\\""),
128                '\n' => rendered.push_str("\\n"),
129                '\r' => rendered.push_str("\\r"),
130                _ => rendered.push(ch),
131            }
132        }
133        rendered.push_str("\"\n");
134    }
135    Ok(rendered)
136}
137
138async fn init_from_dotenv_at(
139    root: &Path,
140    requested_modes: &[String],
141    secrets: &[String],
142    force: bool,
143    dry_run: bool,
144) -> Result<()> {
145    let modes = dotenv_modes(root, requested_modes)?;
146    let sources = dotenv_sources(root, &modes);
147    let mut planned = Vec::new();
148    let requested_secrets: BTreeSet<_> = secrets.iter().cloned().collect();
149    for key in &requested_secrets {
150        super::validate_env_key(key)?;
151    }
152    let mut seen_keys = BTreeSet::new();
153
154    for (input, output) in sources {
155        if !input.is_file() {
156            continue;
157        }
158        let contents = tokio::fs::read_to_string(&input)
159            .await
160            .with_context(|| format!("reading {}", input.display()))?;
161        let values = parse_dotenv(&input, &contents)?;
162        seen_keys.extend(values.keys().cloned());
163        planned.push((output, render_source(&input, &values, &requested_secrets)));
164    }
165    if planned.is_empty() {
166        bail!("no dotenv files found; expected .env, .env.local, or .env.<mode>");
167    }
168    for key in &requested_secrets {
169        if !seen_keys.contains(key) {
170            bail!("--secret {key} was not found in an imported dotenv file");
171        }
172    }
173
174    let workspace = root.join(WORKSPACE_FILE);
175    planned.push((workspace, render_workspace(&modes)));
176    for (path, _) in &planned {
177        if path.exists() && !force {
178            bail!(
179                "{} already exists; rerun with --force to replace generated files",
180                path.display()
181            );
182        }
183    }
184
185    for (path, contents) in &planned {
186        let display = path.strip_prefix(root).unwrap_or(path).display();
187        if dry_run {
188            println!("Would create {display}");
189        } else {
190            atomic_write(path, contents.as_bytes()).await?;
191            println!("Created {display}");
192        }
193    }
194    if !requested_secrets.is_empty() {
195        println!("Run `shine env secret seal` after configuring an encryption recipient.");
196    }
197    Ok(())
198}
199
200fn dotenv_modes(root: &Path, requested: &[String]) -> Result<Vec<String>> {
201    let mut modes: BTreeSet<String> = requested.iter().cloned().collect();
202    for mode in &modes {
203        validate_mode(mode)?;
204    }
205    if modes.is_empty() {
206        for entry in
207            std::fs::read_dir(root).with_context(|| format!("reading {}", root.display()))?
208        {
209            let name = entry?.file_name();
210            let name = name.to_string_lossy();
211            let Some(suffix) = name.strip_prefix(".env.") else {
212                continue;
213            };
214            if suffix == "local" || suffix.ends_with(".shine.toml") {
215                continue;
216            }
217            let mode = suffix.strip_suffix(".local").unwrap_or(suffix);
218            if mode.is_empty() || mode.contains('.') {
219                continue;
220            }
221            validate_mode(mode)?;
222            modes.insert(mode.to_owned());
223        }
224    }
225    if modes.is_empty() {
226        modes.insert("development".to_owned());
227    }
228    Ok(modes.into_iter().collect())
229}
230
231fn dotenv_sources(root: &Path, modes: &[String]) -> Vec<(PathBuf, PathBuf)> {
232    let mut files = vec![
233        (root.join(".env"), root.join(".env.shine.toml")),
234        (root.join(".env.local"), root.join(".env.local.shine.toml")),
235    ];
236    for mode in modes {
237        files.push((
238            root.join(format!(".env.{mode}")),
239            root.join(format!(".env.{mode}.shine.toml")),
240        ));
241        files.push((
242            root.join(format!(".env.{mode}.local")),
243            root.join(format!(".env.{mode}.local.shine.toml")),
244        ));
245    }
246    files
247}
248
249fn render_workspace(modes: &[String]) -> String {
250    let default_mode = &modes[0];
251    let rendered_modes = modes
252        .iter()
253        .map(|mode| format!("\"{mode}\""))
254        .collect::<Vec<_>>()
255        .join(", ");
256    format!(
257        "# Managed by `shine env workspace init --from-dotenv`.\n\
258         # Edit the source files below; later files override earlier ones.\n\
259         version = {WORKSPACE_FORMAT_VERSION}\n\n\
260         [env]\n\
261         # Run with: shine env run --mode {default_mode} -- <command>\n\
262         modes = [{modes}]\n\
263         default_mode = \"{default_mode}\"\n\
264         files = [\n\
265           \".env.shine.toml\", # shared defaults\n\
266           \".env.local.shine.toml\", # local-only overrides; do not commit\n\
267           \".env.{{mode}}.shine.toml\", # mode-specific values\n\
268           \".env.{{mode}}.local.shine.toml\", # local mode overrides; do not commit\n\
269         ]\n\n\
270         # Add GPG recipients before sealing values in [secret].\n\
271         # [env.encryption]\n\
272         # gpg_recipients = [\"alice@example.com\", \"bob@example.com\"]\n",
273        modes = rendered_modes,
274    )
275}
276
277fn render_source(
278    input: &Path,
279    values: &BTreeMap<String, String>,
280    secrets: &BTreeSet<String>,
281) -> String {
282    let mut document = DocumentMut::new();
283    document["version"] = value(ENV_SOURCE_FORMAT_VERSION as i64);
284    let mut plain = toml_edit::Table::new();
285    let mut secret = toml_edit::Table::new();
286    for (key, value_text) in values {
287        if secrets.contains(key) {
288            secret[key] = value(value_text);
289        } else {
290            plain[key] = value(value_text);
291        }
292    }
293    if !plain.is_empty() {
294        document["plain"] = toml_edit::Item::Table(plain);
295    }
296    if !secret.is_empty() {
297        document["secret"] = toml_edit::Item::Table(secret);
298    }
299    let source_name = input
300        .file_name()
301        .and_then(|name| name.to_str())
302        .unwrap_or("dotenv file");
303    let mut contents = format!(
304        "# Imported from {source_name}. Keep non-sensitive values in [plain].\n\
305         # Move sensitive values to [secret], then run `shine env secret seal`.\n"
306    );
307    contents.push_str(&document.to_string());
308    if secrets.is_empty() {
309        contents.push_str(
310            "\n# Optional: move sensitive values here, then run `shine env secret seal`.\n[secret]\n",
311        );
312    }
313    contents
314}
315
316fn parse_dotenv(path: &Path, contents: &str) -> Result<BTreeMap<String, String>> {
317    let mut values = BTreeMap::new();
318    for (index, line) in contents.lines().enumerate() {
319        let line = line.trim();
320        if line.is_empty() || line.starts_with('#') {
321            continue;
322        }
323        let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
324        let Some((key, raw_value)) = line.split_once('=') else {
325            bail!(
326                "{}:{} is not a KEY=VALUE dotenv entry",
327                path.display(),
328                index + 1
329            );
330        };
331        let key = key.trim();
332        super::validate_env_key(key)
333            .with_context(|| format!("{}:{}", path.display(), index + 1))?;
334        let value_text = parse_dotenv_value(path, index + 1, raw_value)?;
335        values.insert(key.to_owned(), value_text);
336    }
337    Ok(values)
338}
339
340fn parse_dotenv_value(path: &Path, line: usize, raw: &str) -> Result<String> {
341    let raw = raw.trim();
342    let value = if let Some(value) = raw.strip_prefix('\'') {
343        parse_quoted_dotenv_value(value, '\'', "single")?
344    } else if let Some(value) = raw.strip_prefix('"') {
345        let value = parse_quoted_dotenv_value(value, '"', "double")?;
346        if value.contains('\\') {
347            bail!(
348                "{}:{line} uses escaped double-quoted dotenv content; resolve it before importing",
349                path.display()
350            );
351        }
352        value
353    } else {
354        raw.split_once(" #")
355            .map(|(value, _)| value)
356            .unwrap_or(raw)
357            .trim_end()
358    };
359    if value.contains("${") {
360        bail!(
361            "{}:{line} uses dotenv interpolation; resolve it before importing",
362            path.display()
363        );
364    }
365    Ok(value.to_owned())
366}
367
368fn parse_quoted_dotenv_value<'a>(raw: &'a str, quote: char, style: &str) -> Result<&'a str> {
369    let closing = raw
370        .find(quote)
371        .with_context(|| format!("unterminated {style}-quoted dotenv value"))?;
372    let trailing = raw[closing + quote.len_utf8()..].trim_start();
373    if !trailing.is_empty() && !trailing.starts_with('#') {
374        bail!("unexpected content after {style}-quoted dotenv value");
375    }
376    Ok(&raw[..closing])
377}
378
379#[derive(Clone, Debug, Deserialize)]
380pub struct Workspace {
381    #[serde(default = "workspace_format_version")]
382    version: u32,
383    pub env: WorkspaceEnv,
384}
385
386#[derive(Clone, Debug, Deserialize)]
387pub struct WorkspaceEnv {
388    #[serde(default)]
389    default_mode: Option<String>,
390    #[serde(default)]
391    modes: Vec<String>,
392    files: Vec<String>,
393    #[serde(default)]
394    override_process_env: bool,
395    #[serde(default)]
396    encryption: Encryption,
397}
398
399#[derive(Clone, Debug, Default, Deserialize)]
400struct Encryption {
401    #[serde(rename = "recipient")]
402    legacy_recipient: Option<String>,
403    #[serde(default)]
404    gpg_recipients: Vec<String>,
405    #[serde(default)]
406    backend: Option<String>,
407    #[serde(default)]
408    age_recipients: Vec<String>,
409}
410
411#[derive(Clone, Debug, Deserialize)]
412struct SourceFile {
413    #[serde(default = "env_source_format_version")]
414    version: u32,
415    #[serde(default)]
416    plain: BTreeMap<String, String>,
417    #[serde(default)]
418    secret: BTreeMap<String, SecretState>,
419    #[serde(default)]
420    payload: PayloadField,
421}
422
423#[derive(Clone, Debug, Deserialize)]
424#[serde(untagged)]
425enum SecretState {
426    Sealed(bool),
427    Plain(String),
428}
429
430#[derive(Clone, Debug, Default, Deserialize)]
431struct PayloadField {
432    #[serde(default)]
433    data: String,
434}
435
436#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
437struct SecretPayload {
438    version: u32,
439    values: BTreeMap<String, String>,
440}
441
442#[derive(Debug, Serialize, Deserialize)]
443struct CacheFile {
444    version: u32,
445    project_root: String,
446    modes: BTreeMap<String, CachedMode>,
447}
448
449#[derive(Debug, Serialize, Deserialize)]
450struct CachedMode {
451    input_hash: String,
452    keys: Vec<String>,
453    data: String,
454}
455
456fn workspace_format_version() -> u32 {
457    WORKSPACE_FORMAT_VERSION
458}
459
460fn env_source_format_version() -> u32 {
461    ENV_SOURCE_FORMAT_VERSION
462}
463
464pub async fn handle_seal(
465    config: &Config,
466    workspace_arg: Option<&Path>,
467    file: Option<&Path>,
468    backend_arg: Option<&str>,
469    recipients_arg: &[String],
470) -> Result<()> {
471    let workspace_path = find_workspace_optional(workspace_arg).await?;
472    let workspace = match &workspace_path {
473        Some(path) => Some(load_workspace(path).await?),
474        None => None,
475    };
476    let encryption = resolve_seal_encryption(
477        backend_arg,
478        recipients_arg,
479        workspace
480            .as_ref()
481            .map(|workspace| &workspace.env.encryption),
482        config,
483    )?;
484
485    let files = if let Some(file) = file {
486        vec![absolute_from_current(file)?]
487    } else {
488        let workspace_path = workspace_path
489            .as_deref()
490            .context("shine.workspace.toml was not found; pass FILE or --workspace")?;
491        let workspace = workspace.as_ref().expect("workspace path has workspace");
492        existing_workspace_sources(workspace_path, workspace).await?
493    };
494    if files.is_empty() {
495        bail!("no workspace environment source files were found");
496    }
497
498    for path in &files {
499        seal_file(path, config, encryption.as_ref()).await?;
500        println!("sealed {}", path.display());
501    }
502    Ok(())
503}
504
505#[allow(clippy::too_many_arguments)] // Command handler keeps independent Clap options explicit.
506pub async fn handle_run(
507    config: &Config,
508    workspace_arg: Option<&Path>,
509    mode_arg: Option<&str>,
510    no_workspace: bool,
511    with: &[String],
512    secret_broker: bool,
513    broker_secrets: &[String],
514    command: &[OsString],
515) -> Result<()> {
516    let explicit = resolve_explicit_values(config, with).await?;
517    if !secret_broker && !broker_secrets.is_empty() {
518        bail!("--secret requires --secret-broker");
519    }
520    if secret_broker {
521        let argv = broker_command_argv(command)?;
522        if no_workspace {
523            if broker_secrets.is_empty() {
524                bail!("--no-workspace --secret-broker requires at least one --secret");
525            }
526            let values = crate::ssh::request_direct_secrets(broker_secrets, &argv).await?;
527            return run_broker_command(
528                command,
529                BTreeMap::new(),
530                false,
531                merge_explicit(explicit, values)?,
532            )
533            .await;
534        }
535        if !broker_secrets.is_empty() {
536            bail!(
537                "workspace broker requests derive release keys from policy; do not pass --secret"
538            );
539        }
540        let mode = mode_arg.context("workspace --secret-broker requires --mode")?;
541        let snapshot = snapshot_for_broker(workspace_arg, mode).await?;
542        let mut values = plain_values_from_broker_snapshot(&snapshot)?;
543        let secrets = crate::ssh::request_workspace_secrets(snapshot.clone(), &argv).await?;
544        values.extend(secrets);
545        return run_broker_command(command, values, snapshot.override_process_env, explicit).await;
546    }
547    // `--no-workspace` disables discovery entirely: only explicit `--with` values
548    // and the inherited process environment reach the command. Generated Bun
549    // launchers rely on this so a nearby shine.workspace.toml can never hijack them.
550    let workspace_path = if no_workspace {
551        None
552    } else {
553        find_workspace_optional(workspace_arg).await?
554    };
555    let (values, override_process_env) = if let Some(workspace_path) = workspace_path {
556        let workspace = load_workspace(&workspace_path).await?;
557        let mode = mode_arg
558            .or(workspace.env.default_mode.as_deref())
559            .context("environment mode is required; pass --mode or set env.default_mode")?;
560        validate_mode(mode)?;
561        let sources = resolve_sources(&workspace_path, &workspace.env.files, mode)?;
562        let input_hash = calculate_input_hash(&workspace_path, mode, &sources).await?;
563        let encryption =
564            resolve_seal_encryption(None, &[], Some(&workspace.env.encryption), config)?;
565        let cache_path = cache_path(&workspace_path, mode)?;
566        let values = match read_valid_cache(&cache_path, mode, &input_hash, config).await {
567            Ok(Some(values)) => values,
568            Ok(None) => {
569                let values = compile_sources(&sources, config).await?;
570                if let Some(encryption) = &encryption
571                    && let Err(error) = write_cache(
572                        &cache_path,
573                        &workspace_path,
574                        mode,
575                        &input_hash,
576                        &values,
577                        encryption,
578                    )
579                    .await
580                {
581                    eprintln!("Warning: could not update environment cache: {error:#}");
582                }
583                values
584            }
585            Err(error) => {
586                eprintln!("Warning: ignoring unreadable environment cache: {error:#}");
587                compile_sources(&sources, config).await?
588            }
589        };
590        (values, workspace.env.override_process_env)
591    } else {
592        if !no_workspace && explicit.is_empty() {
593            bail!("shine.workspace.toml was not found; pass --workspace or --no-workspace");
594        }
595        if mode_arg.is_some() {
596            bail!("--mode requires a shine.workspace.toml");
597        }
598        (BTreeMap::new(), false)
599    };
600
601    run_command(command, &values, override_process_env, &explicit).await
602}
603
604fn broker_command_argv(command: &[OsString]) -> Result<Vec<String>> {
605    command
606        .iter()
607        .map(|arg| {
608            arg.to_str()
609                .map(str::to_string)
610                .context("secret broker command arguments must be valid UTF-8")
611        })
612        .collect()
613}
614
615fn merge_explicit(
616    mut explicit: BTreeMap<String, String>,
617    broker: BTreeMap<String, String>,
618) -> Result<BTreeMap<String, String>> {
619    for (key, value) in broker {
620        if explicit.insert(key.clone(), value).is_some() {
621            bail!("broker target {key} conflicts with an explicit --with target");
622        }
623    }
624    Ok(explicit)
625}
626
627async fn resolve_explicit_values(
628    config: &Config,
629    specs: &[String],
630) -> Result<BTreeMap<String, String>> {
631    let parsed = super::parse_env_specs(specs)?;
632
633    let env = super::EnvConfig::load_or_init(config).await?;
634    let mut values = BTreeMap::new();
635    for spec in parsed {
636        let value = match super::resolve_stored_value(&env, &spec.source)? {
637            super::StoredValue::Secret {
638                key: secret_key,
639                value: ciphertext,
640            } => secret::decrypt_secret(ciphertext, &config.age_identities())
641                .await
642                .with_context(|| format!("decrypting {secret_key}"))?,
643            super::StoredValue::Plaintext(value) => value.to_string(),
644        };
645        values.insert(spec.target, value);
646    }
647    Ok(values)
648}
649
650async fn find_workspace_optional(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
651    if let Some(path) = explicit {
652        return Ok(Some(absolute_from_current(path)?));
653    }
654    let current = std::env::current_dir().context("reading current directory")?;
655    Ok(current
656        .ancestors()
657        .map(|directory| directory.join(WORKSPACE_FILE))
658        .find(|path| path.is_file()))
659}
660
661async fn load_workspace(path: &Path) -> Result<Workspace> {
662    let contents = tokio::fs::read_to_string(path)
663        .await
664        .with_context(|| format!("reading {}", path.display()))?;
665    parse_workspace(path, &contents)
666}
667
668fn parse_workspace(path: &Path, contents: &str) -> Result<Workspace> {
669    let workspace: Workspace =
670        toml::from_str(contents).with_context(|| format!("parsing {}", path.display()))?;
671    if workspace.version < WORKSPACE_FORMAT_VERSION {
672        bail!(
673            "workspace version {} in {} is retired; run `shine state migrate`",
674            workspace.version,
675            path.display()
676        );
677    }
678    if workspace.version != WORKSPACE_FORMAT_VERSION {
679        bail!(
680            "unsupported workspace version {} in {}",
681            workspace.version,
682            path.display()
683        );
684    }
685    if workspace.env.encryption.legacy_recipient.is_some() {
686        bail!(
687            "{} uses retired env.encryption.recipient; run `shine state migrate` to convert it to gpg_recipients",
688            path.display()
689        );
690    }
691    if workspace.env.files.is_empty() {
692        bail!("env.files must contain at least one source path");
693    }
694    Ok(workspace)
695}
696
697/// Resolve the backend + recipients to encrypt with for `seal`/`run`, in
698/// CLI > workspace `env.encryption` > config precedence. Returns `None` when
699/// nothing is configured anywhere, so sealing secretless files never
700/// requires a recipient.
701fn resolve_seal_encryption(
702    cli_backend: Option<&str>,
703    cli_recipients: &[String],
704    workspace_encryption: Option<&Encryption>,
705    config: &Config,
706) -> Result<Option<EncryptRecipients>> {
707    let backend = resolve_backend(
708        cli_backend,
709        workspace_encryption.and_then(|encryption| encryption.backend.as_deref()),
710        config.secret_backend.as_deref(),
711    )?;
712
713    let cli_recipients = clean_recipients(cli_recipients);
714    if !cli_recipients.is_empty() {
715        return Ok(Some(match backend {
716            BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),
717            BackendKind::Age => EncryptRecipients::Age(cli_recipients),
718        }));
719    }
720
721    match backend {
722        BackendKind::Gpg => {
723            let workspace_recipients = workspace_encryption
724                .map(|encryption| clean_recipients(&encryption.gpg_recipients))
725                .unwrap_or_default();
726            let recipients = if !workspace_recipients.is_empty() {
727                workspace_recipients
728            } else {
729                clean_recipients(&config.gpg_recipients)
730            };
731            Ok((!recipients.is_empty()).then_some(EncryptRecipients::Gpg(recipients)))
732        }
733        BackendKind::Age => {
734            let workspace_recipients = workspace_encryption
735                .map(|encryption| clean_recipients(&encryption.age_recipients))
736                .unwrap_or_default();
737            let recipients = if !workspace_recipients.is_empty() {
738                workspace_recipients
739            } else {
740                clean_recipients(&config.age_recipients)
741            };
742            Ok((!recipients.is_empty()).then_some(EncryptRecipients::Age(recipients)))
743        }
744    }
745}
746
747fn resolve_backend(
748    cli_backend: Option<&str>,
749    workspace_backend: Option<&str>,
750    config_backend: Option<&str>,
751) -> Result<BackendKind> {
752    for candidate in [cli_backend, workspace_backend, config_backend] {
753        if let Some(value) = candidate.map(str::trim).filter(|value| !value.is_empty()) {
754            return value.parse();
755        }
756    }
757    Ok(BackendKind::default())
758}
759
760fn clean_recipients(recipients: &[String]) -> Vec<String> {
761    recipients
762        .iter()
763        .map(|value| value.trim().to_string())
764        .filter(|value| !value.is_empty())
765        .collect()
766}
767
768async fn existing_workspace_sources(path: &Path, workspace: &Workspace) -> Result<Vec<PathBuf>> {
769    let mut modes = workspace.env.modes.clone();
770    if let Some(default_mode) = &workspace.env.default_mode
771        && !modes.contains(default_mode)
772    {
773        modes.push(default_mode.clone());
774    }
775    if modes.is_empty()
776        && workspace
777            .env
778            .files
779            .iter()
780            .any(|file| file.contains("{mode}"))
781    {
782        bail!("env.modes or env.default_mode is required to seal mode-specific files");
783    }
784    if modes.is_empty() {
785        modes.push(String::new());
786    }
787
788    let mut unique = BTreeSet::new();
789    for mode in modes {
790        for source in resolve_sources(path, &workspace.env.files, &mode)? {
791            if source.is_file() {
792                unique.insert(source);
793            }
794        }
795    }
796    Ok(unique.into_iter().collect())
797}
798
799fn resolve_sources(workspace_path: &Path, files: &[String], mode: &str) -> Result<Vec<PathBuf>> {
800    let root = workspace_path
801        .parent()
802        .context("workspace path has no parent directory")?;
803    files
804        .iter()
805        .map(|file| {
806            if file.contains("{mode}") && mode.is_empty() {
807                bail!("cannot expand {file} without a mode");
808            }
809            let expanded = file.replace("{mode}", mode);
810            let path = PathBuf::from(expanded);
811            Ok(if path.is_absolute() {
812                path
813            } else {
814                root.join(path)
815            })
816        })
817        .collect()
818}
819
820fn validate_mode(mode: &str) -> Result<()> {
821    if mode.is_empty()
822        || !mode
823            .chars()
824            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
825    {
826        bail!("mode must contain only letters, digits, hyphens, and underscores");
827    }
828    Ok(())
829}
830
831pub(crate) fn validate_broker_mode(mode: &str) -> Result<()> {
832    validate_mode(mode)
833}
834
835/// Reads one workspace/mode exactly once for SSH broker hashing and execution.
836/// The returned bytes are retained by the remote runner until the child starts,
837/// so a successful authorization never re-reads mutable files.
838pub async fn snapshot_for_broker(
839    workspace_arg: Option<&Path>,
840    mode: &str,
841) -> Result<WorkspaceSnapshot> {
842    validate_mode(mode)?;
843    let workspace_path = find_workspace_optional(workspace_arg)
844        .await?
845        .context("shine.workspace.toml was not found; pass --workspace")?;
846    let workspace_contents = tokio::fs::read_to_string(&workspace_path)
847        .await
848        .with_context(|| format!("reading {}", workspace_path.display()))?;
849    let workspace = parse_workspace(&workspace_path, &workspace_contents)?;
850    let source_paths = resolve_sources(&workspace_path, &workspace.env.files, mode)?;
851    let root = workspace_path
852        .parent()
853        .context("workspace path has no parent directory")?;
854    let mut sources = Vec::new();
855    for source_path in source_paths {
856        if !source_path.is_file() {
857            continue;
858        }
859        let contents = tokio::fs::read_to_string(&source_path)
860            .await
861            .with_context(|| format!("reading {}", source_path.display()))?;
862        // Parse now so malformed/unsealed metadata never reaches the broker.
863        let source = parse_source(&source_path, &contents)?;
864        for (key, state) in &source.secret {
865            if !matches!(state, SecretState::Sealed(true)) {
866                bail!(
867                    "{key} in {} is not sealed; run `shine env secret seal`",
868                    source_path.display()
869                );
870            }
871        }
872        let display_path = source_path
873            .strip_prefix(root)
874            .map(Path::to_path_buf)
875            .unwrap_or_else(|_| source_path.clone())
876            .to_string_lossy()
877            .into_owned();
878        sources.push(SourceSnapshot {
879            path: display_path,
880            contents,
881        });
882    }
883    if sources.is_empty() {
884        bail!("none of the configured environment source files exist");
885    }
886    Ok(WorkspaceSnapshot {
887        workspace_path: workspace_path.to_string_lossy().into_owned(),
888        workspace_contents,
889        mode: mode.to_string(),
890        override_process_env: workspace.env.override_process_env,
891        sources,
892    })
893}
894
895pub(crate) fn declared_secrets_from_source(path: &str, contents: &str) -> Result<Vec<String>> {
896    let source = parse_source(Path::new(path), contents)?;
897    let mut keys = source.secret.keys().cloned().collect::<Vec<_>>();
898    keys.sort();
899    Ok(keys)
900}
901
902pub fn plain_values_from_broker_snapshot(
903    snapshot: &WorkspaceSnapshot,
904) -> Result<BTreeMap<String, String>> {
905    let mut values = BTreeMap::new();
906    for source in &snapshot.sources {
907        let parsed = parse_source(Path::new(&source.path), &source.contents)?;
908        values.extend(parsed.plain);
909    }
910    Ok(values)
911}
912
913pub async fn decrypt_broker_snapshot(
914    config: &Config,
915    snapshot: &WorkspaceSnapshot,
916    release: &[String],
917) -> Result<BTreeMap<String, String>> {
918    let release = release.iter().cloned().collect::<BTreeSet<_>>();
919    let mut values = BTreeMap::new();
920    for source in &snapshot.sources {
921        let path = Path::new(&source.path);
922        let parsed = parse_source(path, &source.contents)?;
923        for (key, state) in &parsed.secret {
924            if !matches!(state, SecretState::Sealed(true)) {
925                bail!("{key} in {} is not sealed", source.path);
926            }
927        }
928        let secrets = decrypt_source_payload(path, &parsed, config).await?;
929        let expected = parsed.secret.keys().cloned().collect::<BTreeSet<_>>();
930        let actual = secrets.keys().cloned().collect::<BTreeSet<_>>();
931        if expected != actual {
932            bail!(
933                "secret key list does not match encrypted payload in {}",
934                source.path
935            );
936        }
937        values.extend(secrets.into_iter().filter(|(key, _)| release.contains(key)));
938    }
939    if values.keys().cloned().collect::<BTreeSet<_>>() != release {
940        bail!("broker response does not contain every released secret key");
941    }
942    Ok(values)
943}
944
945async fn seal_file(
946    path: &Path,
947    config: &Config,
948    encryption: Option<&EncryptRecipients>,
949) -> Result<()> {
950    let contents = tokio::fs::read_to_string(path)
951        .await
952        .with_context(|| format!("reading {}", path.display()))?;
953    let source = parse_source(path, &contents)?;
954    let mut old_values = decrypt_source_payload(path, &source, config).await?;
955    let mut new_values = BTreeMap::new();
956
957    for (key, state) in &source.secret {
958        super::validate_env_key(key)?;
959        let secret = match state {
960            SecretState::Sealed(true) => old_values
961                .remove(key)
962                .with_context(|| format!("{key} is marked sealed but is missing from payload"))?,
963            SecretState::Sealed(false) => Password::new()
964                .with_prompt(format!("Enter {key}"))
965                .with_confirmation("Confirm value", "Values did not match")
966                .interact()
967                .with_context(|| format!("reading {key}"))?,
968            SecretState::Plain(value) => value.clone(),
969        };
970        new_values.insert(key.clone(), secret);
971    }
972
973    let encoded = if new_values.is_empty() {
974        String::new()
975    } else {
976        let encryption = encryption.context(
977            "recipients are required; pass --recipient/--backend, set env.encryption in shine.workspace.toml, or set gpg_recipients/age_recipients",
978        )?;
979        let plaintext = toml::to_string(&SecretPayload {
980            version: SECRET_PAYLOAD_VERSION,
981            values: new_values,
982        })?;
983        secret::encrypt_secret(plaintext.as_bytes(), encryption).await?
984    };
985
986    let mut document = contents
987        .parse::<DocumentMut>()
988        .with_context(|| format!("parsing {} for update", path.display()))?;
989    for key in source.secret.keys() {
990        let item = &mut document["secret"][key];
991        let decor = item.as_value().map(|value| value.decor().clone());
992        *item = value(true);
993        if let (Some(decor), Some(value)) = (decor, item.as_value_mut()) {
994            *value.decor_mut() = decor;
995        }
996    }
997    if !document.contains_key("payload") {
998        document["payload"] = toml_edit::table();
999    }
1000    document["payload"]["data"] = value(encoded);
1001    atomic_write(path, document.to_string().as_bytes()).await
1002}
1003
1004fn parse_source(path: &Path, contents: &str) -> Result<SourceFile> {
1005    let source: SourceFile = toml::from_str(contents)
1006        .with_context(|| format!("parsing environment source {}", path.display()))?;
1007    if source.version != ENV_SOURCE_FORMAT_VERSION {
1008        bail!(
1009            "unsupported environment source version {} in {}",
1010            source.version,
1011            path.display()
1012        );
1013    }
1014    for key in source.plain.keys().chain(source.secret.keys()) {
1015        super::validate_env_key(key)?;
1016    }
1017    if let Some(key) = source
1018        .plain
1019        .keys()
1020        .find(|key| source.secret.contains_key(*key))
1021    {
1022        bail!(
1023            "{key} appears in both [plain] and [secret] in {}",
1024            path.display()
1025        );
1026    }
1027    Ok(source)
1028}
1029
1030async fn decrypt_source_payload(
1031    path: &Path,
1032    source: &SourceFile,
1033    config: &Config,
1034) -> Result<BTreeMap<String, String>> {
1035    if source.payload.data.trim().is_empty() {
1036        return Ok(BTreeMap::new());
1037    }
1038    let plaintext = secret::decrypt_secret(&source.payload.data, &config.age_identities())
1039        .await
1040        .with_context(|| format!("decrypting {}", path.display()))?;
1041    let payload: SecretPayload = toml::from_str(&plaintext)
1042        .with_context(|| format!("parsing decrypted payload from {}", path.display()))?;
1043    if payload.version != SECRET_PAYLOAD_VERSION {
1044        bail!("unsupported encrypted payload version {}", payload.version);
1045    }
1046    Ok(payload.values)
1047}
1048
1049async fn load_sealed_source(path: &Path, config: &Config) -> Result<BTreeMap<String, String>> {
1050    let contents = tokio::fs::read_to_string(path)
1051        .await
1052        .with_context(|| format!("reading {}", path.display()))?;
1053    let source = parse_source(path, &contents)?;
1054    for (key, state) in &source.secret {
1055        if !matches!(state, SecretState::Sealed(true)) {
1056            bail!(
1057                "{key} in {} is not sealed; run `shine env secret seal`",
1058                path.display()
1059            );
1060        }
1061    }
1062    let secrets = decrypt_source_payload(path, &source, config).await?;
1063    let expected: BTreeSet<_> = source.secret.keys().cloned().collect();
1064    let actual: BTreeSet<_> = secrets.keys().cloned().collect();
1065    if expected != actual {
1066        bail!(
1067            "secret key list does not match encrypted payload in {}",
1068            path.display()
1069        );
1070    }
1071    let mut values = source.plain;
1072    values.extend(secrets);
1073    Ok(values)
1074}
1075
1076async fn compile_sources(sources: &[PathBuf], config: &Config) -> Result<BTreeMap<String, String>> {
1077    let mut merged = BTreeMap::new();
1078    let mut loaded = 0usize;
1079    for path in sources {
1080        if !path.is_file() {
1081            continue;
1082        }
1083        merged.extend(load_sealed_source(path, config).await?);
1084        loaded += 1;
1085    }
1086    if loaded == 0 {
1087        bail!("none of the configured environment source files exist");
1088    }
1089    Ok(merged)
1090}
1091
1092async fn compile_export_sources(
1093    sources: &[PathBuf],
1094    config: &Config,
1095    include_secrets: bool,
1096) -> Result<BTreeMap<String, String>> {
1097    if include_secrets {
1098        return compile_sources(sources, config).await;
1099    }
1100
1101    let mut merged = BTreeMap::new();
1102    let mut loaded = 0usize;
1103    for path in sources {
1104        if !path.is_file() {
1105            continue;
1106        }
1107        let contents = tokio::fs::read_to_string(path)
1108            .await
1109            .with_context(|| format!("reading {}", path.display()))?;
1110        let source = parse_source(path, &contents)?;
1111        for key in source.secret.keys() {
1112            // A later secret declaration shadows an earlier plain value even
1113            // when secrets are intentionally omitted from the export.
1114            merged.remove(key);
1115        }
1116        merged.extend(source.plain);
1117        loaded += 1;
1118    }
1119    if loaded == 0 {
1120        bail!("none of the configured environment source files exist");
1121    }
1122    Ok(merged)
1123}
1124
1125async fn calculate_input_hash(
1126    workspace_path: &Path,
1127    mode: &str,
1128    sources: &[PathBuf],
1129) -> Result<String> {
1130    let mut hash = Sha256::new();
1131    hash.update(CACHE_FORMAT_VERSION.to_le_bytes());
1132    hash.update(mode.as_bytes());
1133    hash.update(
1134        tokio::fs::read(workspace_path)
1135            .await
1136            .with_context(|| format!("reading {}", workspace_path.display()))?,
1137    );
1138    for path in sources {
1139        hash.update(path.to_string_lossy().as_bytes());
1140        match tokio::fs::read(path).await {
1141            Ok(contents) => hash.update(contents),
1142            Err(error) if error.kind() == std::io::ErrorKind::NotFound => hash.update(b"<missing>"),
1143            Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
1144        }
1145    }
1146    hash.update(workspace_path.to_string_lossy().as_bytes());
1147    Ok(format!("sha256:{:x}", hash.finalize()))
1148}
1149
1150fn cache_path(workspace_path: &Path, mode: &str) -> Result<PathBuf> {
1151    let root = workspace_path
1152        .parent()
1153        .context("workspace path has no parent directory")?;
1154    let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1155    let project_id = format!(
1156        "{:x}",
1157        Sha256::digest(canonical.to_string_lossy().as_bytes())
1158    );
1159    let base = BaseDirs::new().context("resolving system cache directory")?;
1160    Ok(base
1161        .cache_dir()
1162        .join("shine")
1163        .join("projects")
1164        .join(project_id)
1165        .join(format!("env-{mode}.toml")))
1166}
1167
1168async fn read_valid_cache(
1169    path: &Path,
1170    mode: &str,
1171    input_hash: &str,
1172    config: &Config,
1173) -> Result<Option<BTreeMap<String, String>>> {
1174    let contents = match tokio::fs::read_to_string(path).await {
1175        Ok(contents) => contents,
1176        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1177        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
1178    };
1179    let cache: CacheFile =
1180        toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
1181    let Some(cached) = cache.modes.get(mode) else {
1182        return Ok(None);
1183    };
1184    if cache.version != CACHE_FORMAT_VERSION || cached.input_hash != input_hash {
1185        return Ok(None);
1186    }
1187    let plaintext = secret::decrypt_secret(&cached.data, &config.age_identities()).await?;
1188    let payload: SecretPayload = toml::from_str(&plaintext)?;
1189    let keys: Vec<_> = payload.values.keys().cloned().collect();
1190    if payload.version != SECRET_PAYLOAD_VERSION || keys != cached.keys {
1191        bail!("compiled environment cache failed integrity validation");
1192    }
1193    Ok(Some(payload.values))
1194}
1195
1196async fn write_cache(
1197    path: &Path,
1198    workspace_path: &Path,
1199    mode: &str,
1200    input_hash: &str,
1201    values: &BTreeMap<String, String>,
1202    recipients: &EncryptRecipients,
1203) -> Result<()> {
1204    let plaintext = toml::to_string(&SecretPayload {
1205        version: SECRET_PAYLOAD_VERSION,
1206        values: values.clone(),
1207    })?;
1208    let data = secret::encrypt_secret(plaintext.as_bytes(), recipients).await?;
1209    let mut modes = BTreeMap::new();
1210    modes.insert(
1211        mode.to_string(),
1212        CachedMode {
1213            input_hash: input_hash.to_string(),
1214            keys: values.keys().cloned().collect(),
1215            data,
1216        },
1217    );
1218    let cache = CacheFile {
1219        version: CACHE_FORMAT_VERSION,
1220        project_root: workspace_path
1221            .parent()
1222            .unwrap_or_else(|| Path::new("."))
1223            .to_string_lossy()
1224            .into_owned(),
1225        modes,
1226    };
1227    let contents = toml::to_string(&cache)?;
1228    if let Some(parent) = path.parent() {
1229        tokio::fs::create_dir_all(parent)
1230            .await
1231            .with_context(|| format!("creating {}", parent.display()))?;
1232    }
1233    atomic_write(path, contents.as_bytes()).await
1234}
1235
1236async fn run_command(
1237    command: &[OsString],
1238    values: &BTreeMap<String, String>,
1239    override_process_env: bool,
1240    explicit: &BTreeMap<String, String>,
1241) -> Result<()> {
1242    let status = command_status(command, values, override_process_env, explicit).await?;
1243    finish_command_status(status)
1244}
1245
1246async fn run_broker_command(
1247    command: &[OsString],
1248    mut values: BTreeMap<String, String>,
1249    override_process_env: bool,
1250    mut explicit: BTreeMap<String, String>,
1251) -> Result<()> {
1252    let status = command_status(command, &values, override_process_env, &explicit).await;
1253    for value in values.values_mut().chain(explicit.values_mut()) {
1254        value.zeroize();
1255    }
1256    finish_command_status(status?)
1257}
1258
1259async fn command_status(
1260    command: &[OsString],
1261    values: &BTreeMap<String, String>,
1262    override_process_env: bool,
1263    explicit: &BTreeMap<String, String>,
1264) -> Result<std::process::ExitStatus> {
1265    let (program, args) = command
1266        .split_first()
1267        .context("a command is required after --")?;
1268    let mut child = Command::new(program);
1269    child.args(args);
1270    for (key, value) in values {
1271        if override_process_env || std::env::var_os(key).is_none() {
1272            child.env(key, value);
1273        }
1274    }
1275    child.envs(explicit);
1276    let status = child
1277        .status()
1278        .await
1279        .with_context(|| format!("running {}", program.to_string_lossy()))?;
1280    Ok(status)
1281}
1282
1283fn finish_command_status(status: std::process::ExitStatus) -> Result<()> {
1284    if status.success() {
1285        return Ok(());
1286    }
1287    if let Some(code) = status.code() {
1288        std::process::exit(code);
1289    }
1290    #[cfg(unix)]
1291    {
1292        use std::os::unix::process::ExitStatusExt;
1293        std::process::exit(128 + status.signal().unwrap_or(1));
1294    }
1295    #[cfg(not(unix))]
1296    std::process::exit(1);
1297}
1298
1299fn absolute_from_current(path: &Path) -> Result<PathBuf> {
1300    if path.is_absolute() {
1301        Ok(path.to_path_buf())
1302    } else {
1303        Ok(std::env::current_dir()
1304            .context("reading current directory")?
1305            .join(path))
1306    }
1307}
1308
1309#[cfg(test)]
1310mod tests {
1311    use super::*;
1312
1313    #[test]
1314    fn dotenv_import_parses_common_frontend_entries() {
1315        let values = parse_dotenv(
1316            Path::new(".env"),
1317            "# base\nexport VITE_NAME = \"Shine\" # display name\nVITE_OWNER='Shine team' # owner\nVITE_URL=https://example.test # note\nEMPTY=\n",
1318        )
1319        .unwrap();
1320
1321        assert_eq!(values.get("VITE_NAME").map(String::as_str), Some("Shine"));
1322        assert_eq!(
1323            values.get("VITE_OWNER").map(String::as_str),
1324            Some("Shine team")
1325        );
1326        assert_eq!(
1327            values.get("VITE_URL").map(String::as_str),
1328            Some("https://example.test")
1329        );
1330        assert_eq!(values.get("EMPTY").map(String::as_str), Some(""));
1331    }
1332
1333    #[test]
1334    fn dotenv_import_rejects_interpolation() {
1335        let error = parse_dotenv(Path::new(".env"), "VITE_URL=${BASE_URL}/api\n").unwrap_err();
1336        assert!(error.to_string().contains("dotenv interpolation"));
1337    }
1338
1339    #[test]
1340    fn dotenv_export_is_stable_and_escapes_multiline_values() {
1341        let rendered = render_dotenv(&BTreeMap::from([
1342            ("ALPHA".to_owned(), "plain".to_owned()),
1343            (
1344                "COMPLEX".to_owned(),
1345                "quote\" slash\\ first\nsecond".to_owned(),
1346            ),
1347        ]))
1348        .unwrap();
1349
1350        assert_eq!(
1351            rendered,
1352            "ALPHA=\"plain\"\nCOMPLEX=\"quote\\\" slash\\\\ first\\nsecond\"\n"
1353        );
1354    }
1355
1356    #[test]
1357    fn dotenv_export_rejects_nul_values() {
1358        let error = render_dotenv(&BTreeMap::from([(
1359            "BROKEN".to_owned(),
1360            "before\0after".to_owned(),
1361        )]))
1362        .unwrap_err();
1363        assert!(error.to_string().contains("NUL byte"));
1364    }
1365
1366    #[test]
1367    fn dotenv_mode_discovery_ignores_generated_sources() {
1368        let directory = std::env::temp_dir().join(format!("shine-dotenv-{}", uuid::Uuid::new_v4()));
1369        std::fs::create_dir_all(&directory).unwrap();
1370        std::fs::write(directory.join(".env.development"), "VITE_A=1\n").unwrap();
1371        std::fs::write(directory.join(".env.production.local"), "VITE_A=2\n").unwrap();
1372        std::fs::write(directory.join(".env.development.shine.toml"), "version=1\n").unwrap();
1373
1374        assert_eq!(
1375            dotenv_modes(&directory, &[]).unwrap(),
1376            vec!["development", "production"]
1377        );
1378        std::fs::remove_dir_all(directory).unwrap();
1379    }
1380
1381    #[test]
1382    fn rendered_source_marks_only_requested_keys_secret() {
1383        let source = render_source(
1384            Path::new(".env"),
1385            &BTreeMap::from([
1386                ("PUBLIC".to_owned(), "yes".to_owned()),
1387                ("TOKEN".to_owned(), "secret".to_owned()),
1388            ]),
1389            &BTreeSet::from(["TOKEN".to_owned()]),
1390        );
1391        let parsed: SourceFile = toml::from_str(&source).unwrap();
1392        assert!(source.contains("Imported from .env"));
1393        assert_eq!(parsed.plain.get("PUBLIC").map(String::as_str), Some("yes"));
1394        assert!(
1395            matches!(parsed.secret.get("TOKEN"), Some(SecretState::Plain(value)) if value == "secret")
1396        );
1397    }
1398
1399    #[test]
1400    fn rendered_source_includes_an_empty_secret_template() {
1401        let source = render_source(
1402            Path::new(".env"),
1403            &BTreeMap::from([("PUBLIC".to_owned(), "yes".to_owned())]),
1404            &BTreeSet::new(),
1405        );
1406        assert!(source.contains("Optional: move sensitive values"));
1407        let parsed: SourceFile = toml::from_str(&source).unwrap();
1408        assert!(parsed.secret.is_empty());
1409    }
1410
1411    #[tokio::test]
1412    async fn dotenv_init_creates_vite_ordered_workspace_without_touching_sources() {
1413        let directory =
1414            std::env::temp_dir().join(format!("shine-dotenv-init-{}", uuid::Uuid::new_v4()));
1415        tokio::fs::create_dir_all(&directory).await.unwrap();
1416        tokio::fs::write(
1417            directory.join(".env"),
1418            "VITE_API=https://api.example.test\nTOKEN=unsealed\n",
1419        )
1420        .await
1421        .unwrap();
1422        tokio::fs::write(
1423            directory.join(".env.development"),
1424            "VITE_API=http://localhost:3000\n",
1425        )
1426        .await
1427        .unwrap();
1428
1429        init_from_dotenv_at(&directory, &[], &["TOKEN".to_owned()], false, false)
1430            .await
1431            .unwrap();
1432
1433        let workspace = tokio::fs::read_to_string(directory.join(WORKSPACE_FILE))
1434            .await
1435            .unwrap();
1436        assert!(
1437            workspace.find(".env.local.shine.toml").unwrap()
1438                < workspace.find(".env.{mode}.shine.toml").unwrap()
1439        );
1440        assert!(workspace.contains("Managed by `shine env workspace init --from-dotenv`"));
1441        assert!(workspace.contains("Add GPG recipients"));
1442        let base = tokio::fs::read_to_string(directory.join(".env.shine.toml"))
1443            .await
1444            .unwrap();
1445        assert!(base.contains("[secret]"));
1446        assert!(base.contains("TOKEN = \"unsealed\""));
1447        assert_eq!(
1448            tokio::fs::read_to_string(directory.join(".env"))
1449                .await
1450                .unwrap(),
1451            "VITE_API=https://api.example.test\nTOKEN=unsealed\n"
1452        );
1453        assert!(
1454            init_from_dotenv_at(&directory, &[], &[], false, false)
1455                .await
1456                .is_err()
1457        );
1458        tokio::fs::remove_dir_all(directory).await.unwrap();
1459    }
1460
1461    #[test]
1462    fn resolves_vite_style_layers_in_declared_order() {
1463        let workspace = Path::new("/tmp/project/shine.workspace.toml");
1464        let files = vec![
1465            ".env.shine.toml".into(),
1466            ".env.local.shine.toml".into(),
1467            ".env.{mode}.shine.toml".into(),
1468            ".env.{mode}.local.shine.toml".into(),
1469        ];
1470        assert_eq!(
1471            resolve_sources(workspace, &files, "production").unwrap(),
1472            vec![
1473                PathBuf::from("/tmp/project/.env.shine.toml"),
1474                PathBuf::from("/tmp/project/.env.local.shine.toml"),
1475                PathBuf::from("/tmp/project/.env.production.shine.toml"),
1476                PathBuf::from("/tmp/project/.env.production.local.shine.toml"),
1477            ]
1478        );
1479    }
1480
1481    #[test]
1482    fn source_rejects_duplicate_plain_and_secret_keys() {
1483        let error = parse_source(
1484            Path::new(".env.shine.toml"),
1485            "version = 1\n[plain]\nTOKEN = \"plain\"\n[secret]\nTOKEN = true\n",
1486        )
1487        .unwrap_err();
1488        assert!(error.to_string().contains("both [plain] and [secret]"));
1489    }
1490
1491    #[test]
1492    fn seal_encryption_gpg_recipients_priority_is_cli_workspace_config() {
1493        let dir = std::env::temp_dir().join(format!("shine-seal-enc-{}", uuid::Uuid::new_v4()));
1494        let mut config = Config::new_for_test(&dir);
1495        config.gpg_recipients = vec!["global-one".to_string(), "global-two".to_string()];
1496        let workspace_encryption = Encryption {
1497            legacy_recipient: None,
1498            gpg_recipients: vec!["workspace-one".to_string(), "workspace-two".to_string()],
1499            backend: None,
1500            age_recipients: Vec::new(),
1501        };
1502
1503        let cli = resolve_seal_encryption(
1504            None,
1505            &["cli".to_string()],
1506            Some(&workspace_encryption),
1507            &config,
1508        )
1509        .unwrap();
1510        assert!(matches!(cli, Some(EncryptRecipients::Gpg(values)) if values == ["cli"]));
1511
1512        let workspace =
1513            resolve_seal_encryption(None, &[], Some(&workspace_encryption), &config).unwrap();
1514        assert!(
1515            matches!(workspace, Some(EncryptRecipients::Gpg(values)) if values == ["workspace-one", "workspace-two"])
1516        );
1517
1518        let global = resolve_seal_encryption(None, &[], None, &config).unwrap();
1519        assert!(
1520            matches!(global, Some(EncryptRecipients::Gpg(values)) if values == ["global-one", "global-two"])
1521        );
1522    }
1523
1524    #[test]
1525    fn seal_encryption_returns_none_when_nothing_configured() {
1526        let dir = std::env::temp_dir().join(format!("shine-seal-enc-{}", uuid::Uuid::new_v4()));
1527        let config = Config::new_for_test(&dir);
1528
1529        assert!(
1530            resolve_seal_encryption(None, &[], None, &config)
1531                .unwrap()
1532                .is_none()
1533        );
1534    }
1535
1536    #[test]
1537    fn seal_encryption_age_recipients_prefer_workspace_over_config() {
1538        let dir = std::env::temp_dir().join(format!("shine-seal-enc-{}", uuid::Uuid::new_v4()));
1539        let mut config = Config::new_for_test(&dir);
1540        config.secret_backend = Some("age".to_string());
1541        config.age_recipients = vec!["age1config".to_string()];
1542        let workspace_encryption = Encryption {
1543            legacy_recipient: None,
1544            gpg_recipients: Vec::new(),
1545            backend: None,
1546            age_recipients: vec!["age1workspace".to_string()],
1547        };
1548
1549        let resolved =
1550            resolve_seal_encryption(None, &[], Some(&workspace_encryption), &config).unwrap();
1551        assert!(
1552            matches!(resolved, Some(EncryptRecipients::Age(values)) if values == ["age1workspace"])
1553        );
1554
1555        let fallback = resolve_seal_encryption(
1556            None,
1557            &[],
1558            Some(&Encryption {
1559                legacy_recipient: None,
1560                gpg_recipients: Vec::new(),
1561                backend: None,
1562                age_recipients: Vec::new(),
1563            }),
1564            &config,
1565        )
1566        .unwrap();
1567        assert!(
1568            matches!(fallback, Some(EncryptRecipients::Age(values)) if values == ["age1config"])
1569        );
1570    }
1571
1572    #[test]
1573    fn seal_encryption_backend_priority_is_cli_workspace_config() {
1574        let dir = std::env::temp_dir().join(format!("shine-seal-enc-{}", uuid::Uuid::new_v4()));
1575        let mut config = Config::new_for_test(&dir);
1576        config.secret_backend = Some("age".to_string());
1577        config.gpg_recipients = vec!["global".to_string()];
1578        let workspace_encryption = Encryption {
1579            legacy_recipient: None,
1580            gpg_recipients: vec!["workspace".to_string()],
1581            backend: Some("gpg".to_string()),
1582            age_recipients: Vec::new(),
1583        };
1584
1585        let resolved =
1586            resolve_seal_encryption(None, &[], Some(&workspace_encryption), &config).unwrap();
1587        assert!(matches!(resolved, Some(EncryptRecipients::Gpg(_))));
1588
1589        let resolved_age = resolve_seal_encryption(None, &[], None, &config).unwrap();
1590        assert!(
1591            resolved_age.is_none(),
1592            "age backend with no age_recipients should be lazily None: {resolved_age:?}"
1593        );
1594    }
1595
1596    #[tokio::test]
1597    async fn plain_sources_merge_in_declared_order() {
1598        let directory =
1599            std::env::temp_dir().join(format!("shine-workspace-{}", uuid::Uuid::new_v4()));
1600        tokio::fs::create_dir_all(&directory).await.unwrap();
1601        let base = directory.join("base.toml");
1602        let local = directory.join("local.toml");
1603        tokio::fs::write(&base, "version = 1\n[plain]\nA = \"base\"\nB = \"base\"\n")
1604            .await
1605            .unwrap();
1606        tokio::fs::write(&local, "version = 1\n[plain]\nB = \"local\"\n")
1607            .await
1608            .unwrap();
1609
1610        let config = Config::new_for_test(&directory);
1611        let values = compile_sources(&[base, local], &config).await.unwrap();
1612        assert_eq!(values.get("A").map(String::as_str), Some("base"));
1613        assert_eq!(values.get("B").map(String::as_str), Some("local"));
1614        tokio::fs::remove_dir_all(directory).await.unwrap();
1615    }
1616
1617    #[tokio::test]
1618    async fn workspace_export_plain_is_standalone_and_respects_secret_shadowing() {
1619        let directory =
1620            std::env::temp_dir().join(format!("shine-workspace-export-{}", uuid::Uuid::new_v4()));
1621        tokio::fs::create_dir_all(&directory).await.unwrap();
1622        let workspace_path = directory.join(WORKSPACE_FILE);
1623        tokio::fs::write(
1624            &workspace_path,
1625            "version = 2\n[env]\nmodes = [\"production\"]\nfiles = [\"base.toml\", \"production.toml\"]\n",
1626        )
1627        .await
1628        .unwrap();
1629        tokio::fs::write(
1630            directory.join("base.toml"),
1631            "version = 1\n[plain]\nPUBLIC = \"base\"\nSHADOWED = \"old\"\n[secret]\nTOKEN = \"pending\"\n",
1632        )
1633        .await
1634        .unwrap();
1635        tokio::fs::write(
1636            directory.join("production.toml"),
1637            "version = 1\n[plain]\nPUBLIC = \"production\"\n[secret]\nSHADOWED = true\n",
1638        )
1639        .await
1640        .unwrap();
1641        let output = directory.join(".env.production.local");
1642        let config = Config::new_for_test(&directory);
1643
1644        handle_export(
1645            &config,
1646            EnvWorkspaceExportFormat::Dotenv,
1647            Some(&workspace_path),
1648            "production",
1649            &output,
1650            false,
1651            false,
1652            true,
1653        )
1654        .await
1655        .unwrap();
1656        assert!(!output.exists());
1657
1658        handle_export(
1659            &config,
1660            EnvWorkspaceExportFormat::Dotenv,
1661            Some(&workspace_path),
1662            "production",
1663            &output,
1664            false,
1665            false,
1666            false,
1667        )
1668        .await
1669        .unwrap();
1670
1671        assert_eq!(
1672            tokio::fs::read_to_string(&output).await.unwrap(),
1673            "PUBLIC=\"production\"\n"
1674        );
1675        assert!(
1676            handle_export(
1677                &config,
1678                EnvWorkspaceExportFormat::Dotenv,
1679                Some(&workspace_path),
1680                "production",
1681                &output,
1682                false,
1683                false,
1684                false,
1685            )
1686            .await
1687            .unwrap_err()
1688            .to_string()
1689            .contains("--force")
1690        );
1691        tokio::fs::remove_dir_all(directory).await.unwrap();
1692    }
1693
1694    #[tokio::test]
1695    async fn workspace_export_requires_explicit_secret_inclusion() {
1696        let directory =
1697            std::env::temp_dir().join(format!("shine-workspace-export-{}", uuid::Uuid::new_v4()));
1698        tokio::fs::create_dir_all(&directory).await.unwrap();
1699        let source = directory.join("source.toml");
1700        tokio::fs::write(
1701            &source,
1702            "version = 1\n[plain]\nPUBLIC = \"safe\"\n[secret]\nTOKEN = \"pending\"\n",
1703        )
1704        .await
1705        .unwrap();
1706        let config = Config::new_for_test(&directory);
1707
1708        let plain = compile_export_sources(std::slice::from_ref(&source), &config, false)
1709            .await
1710            .unwrap();
1711        assert_eq!(plain, BTreeMap::from([("PUBLIC".into(), "safe".into())]));
1712
1713        let error = compile_export_sources(&[source], &config, true)
1714            .await
1715            .unwrap_err();
1716        assert!(error.to_string().contains("is not sealed"));
1717        tokio::fs::remove_dir_all(directory).await.unwrap();
1718    }
1719
1720    #[tokio::test]
1721    async fn plain_only_source_can_be_sealed_without_recipient() {
1722        let directory = std::env::temp_dir().join(format!("shine-seal-{}", uuid::Uuid::new_v4()));
1723        tokio::fs::create_dir_all(&directory).await.unwrap();
1724        let path = directory.join("env.toml");
1725        tokio::fs::write(&path, "version = 1\n[plain]\nNAME = \"shine\"\n")
1726            .await
1727            .unwrap();
1728
1729        let config = Config::new_for_test(&directory);
1730        seal_file(&path, &config, None).await.unwrap();
1731        let source = tokio::fs::read_to_string(&path).await.unwrap();
1732        assert!(source.contains("[payload]"));
1733        tokio::fs::remove_dir_all(directory).await.unwrap();
1734    }
1735
1736    #[cfg(unix)]
1737    #[tokio::test]
1738    async fn run_command_injects_workspace_values() {
1739        let values = BTreeMap::from([("SHINE_RUN_TEST".to_string(), "injected".to_string())]);
1740        run_command(
1741            &[
1742                OsString::from("sh"),
1743                OsString::from("-c"),
1744                OsString::from("test \"$SHINE_RUN_TEST\" = injected"),
1745            ],
1746            &values,
1747            true,
1748            &BTreeMap::new(),
1749        )
1750        .await
1751        .unwrap();
1752    }
1753
1754    #[tokio::test]
1755    async fn explicit_values_support_aliases_and_multiple_keys() {
1756        let directory = std::env::temp_dir().join(format!("shine-with-{}", uuid::Uuid::new_v4()));
1757        let mut config = Config::new_for_test(&directory);
1758        config.env.insert("TOKEN_A".into(), "alpha".into());
1759        config.env.insert("TOKEN_B".into(), "beta".into());
1760
1761        let values =
1762            resolve_explicit_values(&config, &["TOKEN_A".into(), "TOKEN_B=OTHER_TOKEN".into()])
1763                .await
1764                .unwrap();
1765
1766        assert_eq!(values.get("TOKEN_A").map(String::as_str), Some("alpha"));
1767        assert_eq!(values.get("OTHER_TOKEN").map(String::as_str), Some("beta"));
1768    }
1769
1770    #[tokio::test]
1771    async fn explicit_values_reject_duplicate_targets_before_resolution() {
1772        let directory = std::env::temp_dir().join(format!("shine-with-{}", uuid::Uuid::new_v4()));
1773        let config = Config::new_for_test(&directory);
1774
1775        let error =
1776            resolve_explicit_values(&config, &["TOKEN_A=TOKEN".into(), "TOKEN_B=TOKEN".into()])
1777                .await
1778                .unwrap_err();
1779
1780        assert!(error.to_string().contains("duplicate target variable"));
1781    }
1782
1783    #[cfg(unix)]
1784    #[tokio::test]
1785    async fn no_workspace_injects_explicit_without_discovery() {
1786        let directory = std::env::temp_dir().join(format!("shine-nows-{}", uuid::Uuid::new_v4()));
1787        let mut config = Config::new_for_test(&directory);
1788        config.env.insert("SHINE_NOWS_TOKEN".into(), "alpha".into());
1789
1790        // no_workspace = true must skip discovery entirely and inject only --with.
1791        handle_run(
1792            &config,
1793            None,
1794            None,
1795            true,
1796            &["SHINE_NOWS_TOKEN".into()],
1797            false,
1798            &[],
1799            &[
1800                OsString::from("sh"),
1801                OsString::from("-c"),
1802                OsString::from("test \"$SHINE_NOWS_TOKEN\" = alpha"),
1803            ],
1804        )
1805        .await
1806        .unwrap();
1807    }
1808
1809    #[cfg(unix)]
1810    #[tokio::test]
1811    async fn no_workspace_allows_empty_with() {
1812        let directory =
1813            std::env::temp_dir().join(format!("shine-nows-empty-{}", uuid::Uuid::new_v4()));
1814        let config = Config::new_for_test(&directory);
1815
1816        handle_run(
1817            &config,
1818            None,
1819            None,
1820            true,
1821            &[],
1822            false,
1823            &[],
1824            &[
1825                OsString::from("sh"),
1826                OsString::from("-c"),
1827                OsString::from("true"),
1828            ],
1829        )
1830        .await
1831        .unwrap();
1832    }
1833
1834    #[tokio::test]
1835    async fn explicit_values_reject_invalid_or_missing_keys() {
1836        let directory = std::env::temp_dir().join(format!("shine-with-{}", uuid::Uuid::new_v4()));
1837        let config = Config::new_for_test(&directory);
1838
1839        let invalid = resolve_explicit_values(&config, &["BAD-KEY".into()])
1840            .await
1841            .unwrap_err();
1842        assert!(
1843            invalid
1844                .to_string()
1845                .contains("invalid environment variable name")
1846        );
1847
1848        let missing = resolve_explicit_values(&config, &["MISSING".into()])
1849            .await
1850            .unwrap_err();
1851        assert!(
1852            missing
1853                .to_string()
1854                .contains("MISSING_SECRET or MISSING is not set")
1855        );
1856    }
1857
1858    #[cfg(unix)]
1859    #[tokio::test]
1860    #[allow(clippy::await_holding_lock)]
1861    async fn explicit_values_override_workspace_and_process_values() {
1862        let _guard = crate::test_support::env_lock();
1863        // SAFETY: the shared test env lock serializes process environment mutation.
1864        unsafe { std::env::set_var("SHINE_RUN_OVERRIDE_TEST", "process") };
1865        let workspace = BTreeMap::from([(
1866            "SHINE_RUN_OVERRIDE_TEST".to_string(),
1867            "workspace".to_string(),
1868        )]);
1869        let explicit = BTreeMap::from([(
1870            "SHINE_RUN_OVERRIDE_TEST".to_string(),
1871            "explicit".to_string(),
1872        )]);
1873
1874        run_command(
1875            &[
1876                OsString::from("sh"),
1877                OsString::from("-c"),
1878                OsString::from("test \"$SHINE_RUN_OVERRIDE_TEST\" = explicit"),
1879            ],
1880            &workspace,
1881            false,
1882            &explicit,
1883        )
1884        .await
1885        .unwrap();
1886
1887        assert_eq!(
1888            std::env::var("SHINE_RUN_OVERRIDE_TEST").as_deref(),
1889            Ok("process")
1890        );
1891        // SAFETY: the shared test env lock serializes process environment mutation.
1892        unsafe { std::env::remove_var("SHINE_RUN_OVERRIDE_TEST") };
1893    }
1894}