Skip to main content

cli/env/
workspace.rs

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