Skip to main content

cli/env/
workspace.rs

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