Skip to main content

cli/env/
workspace.rs

1use crate::persist::atomic_write;
2use crate::secret::{BackendKind, EncryptRecipients};
3use crate::{config::Config, secret};
4use anyhow::{Context, Result, bail};
5use dialoguer::Password;
6use directories::BaseDirs;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    ffi::OsString,
12    path::{Path, PathBuf},
13};
14use tokio::process::Command;
15use toml_edit::{DocumentMut, value};
16
17const WORKSPACE_FILE: &str = "shine.workspace.toml";
18const FORMAT_VERSION: u32 = 1;
19
20#[derive(Clone, Debug, Deserialize)]
21pub struct Workspace {
22    #[serde(default = "format_version")]
23    version: u32,
24    pub env: WorkspaceEnv,
25}
26
27#[derive(Clone, Debug, Deserialize)]
28pub struct WorkspaceEnv {
29    #[serde(default)]
30    default_mode: Option<String>,
31    #[serde(default)]
32    modes: Vec<String>,
33    files: Vec<String>,
34    #[serde(default)]
35    override_process_env: bool,
36    #[serde(default)]
37    encryption: Encryption,
38}
39
40#[derive(Clone, Debug, Default, Deserialize)]
41struct Encryption {
42    recipient: Option<String>,
43    #[serde(default)]
44    backend: Option<String>,
45    #[serde(default)]
46    age_recipients: Vec<String>,
47}
48
49#[derive(Clone, Debug, Deserialize)]
50struct SourceFile {
51    #[serde(default = "format_version")]
52    version: u32,
53    #[serde(default)]
54    plain: BTreeMap<String, String>,
55    #[serde(default)]
56    secret: BTreeMap<String, SecretState>,
57    #[serde(default)]
58    payload: PayloadField,
59}
60
61#[derive(Clone, Debug, Deserialize)]
62#[serde(untagged)]
63enum SecretState {
64    Sealed(bool),
65    Plain(String),
66}
67
68#[derive(Clone, Debug, Default, Deserialize)]
69struct PayloadField {
70    #[serde(default)]
71    data: String,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
75struct SecretPayload {
76    version: u32,
77    values: BTreeMap<String, String>,
78}
79
80#[derive(Debug, Serialize, Deserialize)]
81struct CacheFile {
82    version: u32,
83    project_root: String,
84    modes: BTreeMap<String, CachedMode>,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88struct CachedMode {
89    input_hash: String,
90    keys: Vec<String>,
91    data: String,
92}
93
94fn format_version() -> u32 {
95    FORMAT_VERSION
96}
97
98pub async fn handle_seal(
99    config: &Config,
100    workspace_arg: Option<&Path>,
101    file: Option<&Path>,
102    backend_arg: Option<&str>,
103    recipients_arg: &[String],
104) -> Result<()> {
105    let workspace_path = find_workspace_optional(workspace_arg).await?;
106    let workspace = match &workspace_path {
107        Some(path) => Some(load_workspace(path).await?),
108        None => None,
109    };
110    let encryption = resolve_seal_encryption(
111        backend_arg,
112        recipients_arg,
113        workspace
114            .as_ref()
115            .map(|workspace| &workspace.env.encryption),
116        config,
117    )?;
118
119    let files = if let Some(file) = file {
120        vec![absolute_from_current(file)?]
121    } else {
122        let workspace_path = workspace_path
123            .as_deref()
124            .context("shine.workspace.toml was not found; pass FILE or --workspace")?;
125        let workspace = workspace.as_ref().expect("workspace path has workspace");
126        existing_workspace_sources(workspace_path, workspace).await?
127    };
128    if files.is_empty() {
129        bail!("no workspace environment source files were found");
130    }
131
132    for path in &files {
133        seal_file(path, config, encryption.as_ref()).await?;
134        println!("sealed {}", path.display());
135    }
136    Ok(())
137}
138
139pub async fn handle_run(
140    config: &Config,
141    workspace_arg: Option<&Path>,
142    mode_arg: Option<&str>,
143    no_workspace: bool,
144    with: &[String],
145    command: &[OsString],
146) -> Result<()> {
147    let explicit = resolve_explicit_values(config, with).await?;
148    // `--no-workspace` disables discovery entirely: only explicit `--with` values
149    // and the inherited process environment reach the command. Generated Bun
150    // launchers rely on this so a nearby shine.workspace.toml can never hijack them.
151    let workspace_path = if no_workspace {
152        None
153    } else {
154        find_workspace_optional(workspace_arg).await?
155    };
156    let (values, override_process_env) = if let Some(workspace_path) = workspace_path {
157        let workspace = load_workspace(&workspace_path).await?;
158        let mode = mode_arg
159            .or(workspace.env.default_mode.as_deref())
160            .context("environment mode is required; pass --mode or set env.default_mode")?;
161        validate_mode(mode)?;
162        let sources = resolve_sources(&workspace_path, &workspace.env.files, mode)?;
163        let input_hash = calculate_input_hash(&workspace_path, mode, &sources).await?;
164        let encryption =
165            resolve_seal_encryption(None, &[], Some(&workspace.env.encryption), config)?;
166        let cache_path = cache_path(&workspace_path, mode)?;
167        let values = match read_valid_cache(&cache_path, mode, &input_hash, config).await {
168            Ok(Some(values)) => values,
169            Ok(None) => {
170                let values = compile_sources(&sources, config).await?;
171                if let Some(encryption) = &encryption
172                    && let Err(error) = write_cache(
173                        &cache_path,
174                        &workspace_path,
175                        mode,
176                        &input_hash,
177                        &values,
178                        encryption,
179                    )
180                    .await
181                {
182                    eprintln!("Warning: could not update environment cache: {error:#}");
183                }
184                values
185            }
186            Err(error) => {
187                eprintln!("Warning: ignoring unreadable environment cache: {error:#}");
188                compile_sources(&sources, config).await?
189            }
190        };
191        (values, workspace.env.override_process_env)
192    } else {
193        if !no_workspace && explicit.is_empty() {
194            bail!("shine.workspace.toml was not found; pass --workspace or --no-workspace");
195        }
196        if mode_arg.is_some() {
197            bail!("--mode requires a shine.workspace.toml");
198        }
199        (BTreeMap::new(), false)
200    };
201
202    run_command(command, &values, override_process_env, &explicit).await
203}
204
205async fn resolve_explicit_values(
206    config: &Config,
207    specs: &[String],
208) -> Result<BTreeMap<String, String>> {
209    let parsed = super::parse_env_specs(specs)?;
210
211    let env = super::EnvConfig::load_or_init(config).await?;
212    let mut values = BTreeMap::new();
213    for spec in parsed {
214        let value = match super::resolve_stored_value(&env, &spec.source)? {
215            super::StoredValue::Secret {
216                key: secret_key,
217                value: ciphertext,
218            } => secret::decrypt_secret(ciphertext, &config.age_identities())
219                .await
220                .with_context(|| format!("decrypting {secret_key}"))?,
221            super::StoredValue::Plaintext(value) => value.to_string(),
222        };
223        values.insert(spec.target, value);
224    }
225    Ok(values)
226}
227
228async fn find_workspace_optional(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
229    if let Some(path) = explicit {
230        return Ok(Some(absolute_from_current(path)?));
231    }
232    let current = std::env::current_dir().context("reading current directory")?;
233    Ok(current
234        .ancestors()
235        .map(|directory| directory.join(WORKSPACE_FILE))
236        .find(|path| path.is_file()))
237}
238
239async fn load_workspace(path: &Path) -> Result<Workspace> {
240    let contents = tokio::fs::read_to_string(path)
241        .await
242        .with_context(|| format!("reading {}", path.display()))?;
243    let workspace: Workspace =
244        toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
245    if workspace.version != FORMAT_VERSION {
246        bail!(
247            "unsupported workspace version {} in {}",
248            workspace.version,
249            path.display()
250        );
251    }
252    if workspace.env.files.is_empty() {
253        bail!("env.files must contain at least one source path");
254    }
255    Ok(workspace)
256}
257
258/// Resolve the backend + recipients to encrypt with for `seal`/`run`, in
259/// CLI > workspace `env.encryption` > config precedence. Returns `None` when
260/// nothing is configured anywhere, so sealing secretless files never
261/// requires a recipient.
262fn resolve_seal_encryption(
263    cli_backend: Option<&str>,
264    cli_recipients: &[String],
265    workspace_encryption: Option<&Encryption>,
266    config: &Config,
267) -> Result<Option<EncryptRecipients>> {
268    let backend = resolve_backend(
269        cli_backend,
270        workspace_encryption.and_then(|encryption| encryption.backend.as_deref()),
271        config.secret_backend.as_deref(),
272    )?;
273
274    let cli_recipients = clean_recipients(cli_recipients);
275    if !cli_recipients.is_empty() {
276        return Ok(Some(match backend {
277            BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),
278            BackendKind::Age => EncryptRecipients::Age(cli_recipients),
279        }));
280    }
281
282    match backend {
283        BackendKind::Gpg => {
284            let recipient = resolve_recipient_optional(
285                workspace_encryption.and_then(|encryption| encryption.recipient.as_deref()),
286                config.gpg_key_id.as_deref(),
287            );
288            Ok(recipient.map(|value| EncryptRecipients::Gpg(vec![value.to_string()])))
289        }
290        BackendKind::Age => {
291            let workspace_recipients = workspace_encryption
292                .map(|encryption| clean_recipients(&encryption.age_recipients))
293                .unwrap_or_default();
294            let recipients = if !workspace_recipients.is_empty() {
295                workspace_recipients
296            } else {
297                clean_recipients(&config.age_recipients)
298            };
299            Ok((!recipients.is_empty()).then_some(EncryptRecipients::Age(recipients)))
300        }
301    }
302}
303
304fn resolve_backend(
305    cli_backend: Option<&str>,
306    workspace_backend: Option<&str>,
307    config_backend: Option<&str>,
308) -> Result<BackendKind> {
309    for candidate in [cli_backend, workspace_backend, config_backend] {
310        if let Some(value) = candidate.map(str::trim).filter(|value| !value.is_empty()) {
311            return value.parse();
312        }
313    }
314    Ok(BackendKind::default())
315}
316
317fn clean_recipients(recipients: &[String]) -> Vec<String> {
318    recipients
319        .iter()
320        .map(|value| value.trim().to_string())
321        .filter(|value| !value.is_empty())
322        .collect()
323}
324
325fn resolve_recipient_optional<'a>(
326    first: Option<&'a str>,
327    second: Option<&'a str>,
328) -> Option<&'a str> {
329    first
330        .map(str::trim)
331        .filter(|value| !value.is_empty())
332        .or_else(|| second.map(str::trim).filter(|value| !value.is_empty()))
333}
334
335async fn existing_workspace_sources(path: &Path, workspace: &Workspace) -> Result<Vec<PathBuf>> {
336    let mut modes = workspace.env.modes.clone();
337    if let Some(default_mode) = &workspace.env.default_mode
338        && !modes.contains(default_mode)
339    {
340        modes.push(default_mode.clone());
341    }
342    if modes.is_empty()
343        && workspace
344            .env
345            .files
346            .iter()
347            .any(|file| file.contains("{mode}"))
348    {
349        bail!("env.modes or env.default_mode is required to seal mode-specific files");
350    }
351    if modes.is_empty() {
352        modes.push(String::new());
353    }
354
355    let mut unique = BTreeSet::new();
356    for mode in modes {
357        for source in resolve_sources(path, &workspace.env.files, &mode)? {
358            if source.is_file() {
359                unique.insert(source);
360            }
361        }
362    }
363    Ok(unique.into_iter().collect())
364}
365
366fn resolve_sources(workspace_path: &Path, files: &[String], mode: &str) -> Result<Vec<PathBuf>> {
367    let root = workspace_path
368        .parent()
369        .context("workspace path has no parent directory")?;
370    files
371        .iter()
372        .map(|file| {
373            if file.contains("{mode}") && mode.is_empty() {
374                bail!("cannot expand {file} without a mode");
375            }
376            let expanded = file.replace("{mode}", mode);
377            let path = PathBuf::from(expanded);
378            Ok(if path.is_absolute() {
379                path
380            } else {
381                root.join(path)
382            })
383        })
384        .collect()
385}
386
387fn validate_mode(mode: &str) -> Result<()> {
388    if mode.is_empty()
389        || !mode
390            .chars()
391            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
392    {
393        bail!("mode must contain only letters, digits, hyphens, and underscores");
394    }
395    Ok(())
396}
397
398async fn seal_file(
399    path: &Path,
400    config: &Config,
401    encryption: Option<&EncryptRecipients>,
402) -> Result<()> {
403    let contents = tokio::fs::read_to_string(path)
404        .await
405        .with_context(|| format!("reading {}", path.display()))?;
406    let source = parse_source(path, &contents)?;
407    let mut old_values = decrypt_source_payload(path, &source, config).await?;
408    let mut new_values = BTreeMap::new();
409
410    for (key, state) in &source.secret {
411        super::validate_env_key(key)?;
412        let secret = match state {
413            SecretState::Sealed(true) => old_values
414                .remove(key)
415                .with_context(|| format!("{key} is marked sealed but is missing from payload"))?,
416            SecretState::Sealed(false) => Password::new()
417                .with_prompt(format!("Enter {key}"))
418                .with_confirmation("Confirm value", "Values did not match")
419                .interact()
420                .with_context(|| format!("reading {key}"))?,
421            SecretState::Plain(value) => value.clone(),
422        };
423        new_values.insert(key.clone(), secret);
424    }
425
426    let encoded = if new_values.is_empty() {
427        String::new()
428    } else {
429        let encryption = encryption.context(
430            "recipients are required; pass --recipient/--backend, set env.encryption in shine.workspace.toml, or set gpg_key_id/age_recipients",
431        )?;
432        let plaintext = toml::to_string(&SecretPayload {
433            version: FORMAT_VERSION,
434            values: new_values,
435        })?;
436        secret::encrypt_secret(plaintext.as_bytes(), encryption).await?
437    };
438
439    let mut document = contents
440        .parse::<DocumentMut>()
441        .with_context(|| format!("parsing {} for update", path.display()))?;
442    for key in source.secret.keys() {
443        let item = &mut document["secret"][key];
444        let decor = item.as_value().map(|value| value.decor().clone());
445        *item = value(true);
446        if let (Some(decor), Some(value)) = (decor, item.as_value_mut()) {
447            *value.decor_mut() = decor;
448        }
449    }
450    if !document.contains_key("payload") {
451        document["payload"] = toml_edit::table();
452    }
453    document["payload"]["data"] = value(encoded);
454    atomic_write(path, document.to_string().as_bytes()).await
455}
456
457fn parse_source(path: &Path, contents: &str) -> Result<SourceFile> {
458    let source: SourceFile = toml::from_str(contents)
459        .with_context(|| format!("parsing environment source {}", path.display()))?;
460    if source.version != FORMAT_VERSION {
461        bail!(
462            "unsupported environment source version {} in {}",
463            source.version,
464            path.display()
465        );
466    }
467    for key in source.plain.keys().chain(source.secret.keys()) {
468        super::validate_env_key(key)?;
469    }
470    if let Some(key) = source
471        .plain
472        .keys()
473        .find(|key| source.secret.contains_key(*key))
474    {
475        bail!(
476            "{key} appears in both [plain] and [secret] in {}",
477            path.display()
478        );
479    }
480    Ok(source)
481}
482
483async fn decrypt_source_payload(
484    path: &Path,
485    source: &SourceFile,
486    config: &Config,
487) -> Result<BTreeMap<String, String>> {
488    if source.payload.data.trim().is_empty() {
489        return Ok(BTreeMap::new());
490    }
491    let plaintext = secret::decrypt_secret(&source.payload.data, &config.age_identities())
492        .await
493        .with_context(|| format!("decrypting {}", path.display()))?;
494    let payload: SecretPayload = toml::from_str(&plaintext)
495        .with_context(|| format!("parsing decrypted payload from {}", path.display()))?;
496    if payload.version != FORMAT_VERSION {
497        bail!("unsupported encrypted payload version {}", payload.version);
498    }
499    Ok(payload.values)
500}
501
502async fn load_sealed_source(path: &Path, config: &Config) -> Result<BTreeMap<String, String>> {
503    let contents = tokio::fs::read_to_string(path)
504        .await
505        .with_context(|| format!("reading {}", path.display()))?;
506    let source = parse_source(path, &contents)?;
507    for (key, state) in &source.secret {
508        if !matches!(state, SecretState::Sealed(true)) {
509            bail!(
510                "{key} in {} is not sealed; run `shine env secret seal`",
511                path.display()
512            );
513        }
514    }
515    let secrets = decrypt_source_payload(path, &source, config).await?;
516    let expected: BTreeSet<_> = source.secret.keys().cloned().collect();
517    let actual: BTreeSet<_> = secrets.keys().cloned().collect();
518    if expected != actual {
519        bail!(
520            "secret key list does not match encrypted payload in {}",
521            path.display()
522        );
523    }
524    let mut values = source.plain;
525    values.extend(secrets);
526    Ok(values)
527}
528
529async fn compile_sources(sources: &[PathBuf], config: &Config) -> Result<BTreeMap<String, String>> {
530    let mut merged = BTreeMap::new();
531    let mut loaded = 0usize;
532    for path in sources {
533        if !path.is_file() {
534            continue;
535        }
536        merged.extend(load_sealed_source(path, config).await?);
537        loaded += 1;
538    }
539    if loaded == 0 {
540        bail!("none of the configured environment source files exist");
541    }
542    Ok(merged)
543}
544
545async fn calculate_input_hash(
546    workspace_path: &Path,
547    mode: &str,
548    sources: &[PathBuf],
549) -> Result<String> {
550    let mut hash = Sha256::new();
551    hash.update(FORMAT_VERSION.to_le_bytes());
552    hash.update(mode.as_bytes());
553    hash.update(
554        tokio::fs::read(workspace_path)
555            .await
556            .with_context(|| format!("reading {}", workspace_path.display()))?,
557    );
558    for path in sources {
559        hash.update(path.to_string_lossy().as_bytes());
560        match tokio::fs::read(path).await {
561            Ok(contents) => hash.update(contents),
562            Err(error) if error.kind() == std::io::ErrorKind::NotFound => hash.update(b"<missing>"),
563            Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
564        }
565    }
566    hash.update(workspace_path.to_string_lossy().as_bytes());
567    Ok(format!("sha256:{:x}", hash.finalize()))
568}
569
570fn cache_path(workspace_path: &Path, mode: &str) -> Result<PathBuf> {
571    let root = workspace_path
572        .parent()
573        .context("workspace path has no parent directory")?;
574    let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
575    let project_id = format!(
576        "{:x}",
577        Sha256::digest(canonical.to_string_lossy().as_bytes())
578    );
579    let base = BaseDirs::new().context("resolving system cache directory")?;
580    Ok(base
581        .cache_dir()
582        .join("shine")
583        .join("projects")
584        .join(project_id)
585        .join(format!("env-{mode}.toml")))
586}
587
588async fn read_valid_cache(
589    path: &Path,
590    mode: &str,
591    input_hash: &str,
592    config: &Config,
593) -> Result<Option<BTreeMap<String, String>>> {
594    let contents = match tokio::fs::read_to_string(path).await {
595        Ok(contents) => contents,
596        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
597        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
598    };
599    let cache: CacheFile =
600        toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
601    let Some(cached) = cache.modes.get(mode) else {
602        return Ok(None);
603    };
604    if cache.version != FORMAT_VERSION || cached.input_hash != input_hash {
605        return Ok(None);
606    }
607    let plaintext = secret::decrypt_secret(&cached.data, &config.age_identities()).await?;
608    let payload: SecretPayload = toml::from_str(&plaintext)?;
609    let keys: Vec<_> = payload.values.keys().cloned().collect();
610    if payload.version != FORMAT_VERSION || keys != cached.keys {
611        bail!("compiled environment cache failed integrity validation");
612    }
613    Ok(Some(payload.values))
614}
615
616async fn write_cache(
617    path: &Path,
618    workspace_path: &Path,
619    mode: &str,
620    input_hash: &str,
621    values: &BTreeMap<String, String>,
622    recipients: &EncryptRecipients,
623) -> Result<()> {
624    let plaintext = toml::to_string(&SecretPayload {
625        version: FORMAT_VERSION,
626        values: values.clone(),
627    })?;
628    let data = secret::encrypt_secret(plaintext.as_bytes(), recipients).await?;
629    let mut modes = BTreeMap::new();
630    modes.insert(
631        mode.to_string(),
632        CachedMode {
633            input_hash: input_hash.to_string(),
634            keys: values.keys().cloned().collect(),
635            data,
636        },
637    );
638    let cache = CacheFile {
639        version: FORMAT_VERSION,
640        project_root: workspace_path
641            .parent()
642            .unwrap_or_else(|| Path::new("."))
643            .to_string_lossy()
644            .into_owned(),
645        modes,
646    };
647    let contents = toml::to_string(&cache)?;
648    if let Some(parent) = path.parent() {
649        tokio::fs::create_dir_all(parent)
650            .await
651            .with_context(|| format!("creating {}", parent.display()))?;
652    }
653    atomic_write(path, contents.as_bytes()).await
654}
655
656async fn run_command(
657    command: &[OsString],
658    values: &BTreeMap<String, String>,
659    override_process_env: bool,
660    explicit: &BTreeMap<String, String>,
661) -> Result<()> {
662    let (program, args) = command
663        .split_first()
664        .context("a command is required after --")?;
665    let mut child = Command::new(program);
666    child.args(args);
667    for (key, value) in values {
668        if override_process_env || std::env::var_os(key).is_none() {
669            child.env(key, value);
670        }
671    }
672    child.envs(explicit);
673    let status = child
674        .status()
675        .await
676        .with_context(|| format!("running {}", program.to_string_lossy()))?;
677    if status.success() {
678        return Ok(());
679    }
680    if let Some(code) = status.code() {
681        std::process::exit(code);
682    }
683    #[cfg(unix)]
684    {
685        use std::os::unix::process::ExitStatusExt;
686        std::process::exit(128 + status.signal().unwrap_or(1));
687    }
688    #[cfg(not(unix))]
689    std::process::exit(1);
690}
691
692fn absolute_from_current(path: &Path) -> Result<PathBuf> {
693    if path.is_absolute() {
694        Ok(path.to_path_buf())
695    } else {
696        Ok(std::env::current_dir()
697            .context("reading current directory")?
698            .join(path))
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    #[test]
707    fn resolves_vite_style_layers_in_declared_order() {
708        let workspace = Path::new("/tmp/project/shine.workspace.toml");
709        let files = vec![
710            ".env.shine.toml".into(),
711            ".env.local.shine.toml".into(),
712            ".env.{mode}.shine.toml".into(),
713            ".env.{mode}.local.shine.toml".into(),
714        ];
715        assert_eq!(
716            resolve_sources(workspace, &files, "production").unwrap(),
717            vec![
718                PathBuf::from("/tmp/project/.env.shine.toml"),
719                PathBuf::from("/tmp/project/.env.local.shine.toml"),
720                PathBuf::from("/tmp/project/.env.production.shine.toml"),
721                PathBuf::from("/tmp/project/.env.production.local.shine.toml"),
722            ]
723        );
724    }
725
726    #[test]
727    fn source_rejects_duplicate_plain_and_secret_keys() {
728        let error = parse_source(
729            Path::new(".env.shine.toml"),
730            "version = 1\n[plain]\nTOKEN = \"plain\"\n[secret]\nTOKEN = true\n",
731        )
732        .unwrap_err();
733        assert!(error.to_string().contains("both [plain] and [secret]"));
734    }
735
736    #[test]
737    fn seal_encryption_gpg_recipient_priority_is_cli_workspace_config() {
738        let dir = std::env::temp_dir().join(format!("shine-seal-enc-{}", uuid::Uuid::new_v4()));
739        let mut config = Config::new_for_test(&dir);
740        config.gpg_key_id = Some("global".to_string());
741        let workspace_encryption = Encryption {
742            recipient: Some("workspace".to_string()),
743            backend: None,
744            age_recipients: Vec::new(),
745        };
746
747        let cli = resolve_seal_encryption(
748            None,
749            &["cli".to_string()],
750            Some(&workspace_encryption),
751            &config,
752        )
753        .unwrap();
754        assert!(matches!(cli, Some(EncryptRecipients::Gpg(values)) if values == ["cli"]));
755
756        let workspace =
757            resolve_seal_encryption(None, &[], Some(&workspace_encryption), &config).unwrap();
758        assert!(
759            matches!(workspace, Some(EncryptRecipients::Gpg(values)) if values == ["workspace"])
760        );
761
762        let global = resolve_seal_encryption(None, &[], None, &config).unwrap();
763        assert!(matches!(global, Some(EncryptRecipients::Gpg(values)) if values == ["global"]));
764    }
765
766    #[test]
767    fn seal_encryption_returns_none_when_nothing_configured() {
768        let dir = std::env::temp_dir().join(format!("shine-seal-enc-{}", uuid::Uuid::new_v4()));
769        let config = Config::new_for_test(&dir);
770
771        assert!(
772            resolve_seal_encryption(None, &[], None, &config)
773                .unwrap()
774                .is_none()
775        );
776    }
777
778    #[test]
779    fn seal_encryption_age_recipients_prefer_workspace_over_config() {
780        let dir = std::env::temp_dir().join(format!("shine-seal-enc-{}", uuid::Uuid::new_v4()));
781        let mut config = Config::new_for_test(&dir);
782        config.secret_backend = Some("age".to_string());
783        config.age_recipients = vec!["age1config".to_string()];
784        let workspace_encryption = Encryption {
785            recipient: None,
786            backend: None,
787            age_recipients: vec!["age1workspace".to_string()],
788        };
789
790        let resolved =
791            resolve_seal_encryption(None, &[], Some(&workspace_encryption), &config).unwrap();
792        assert!(
793            matches!(resolved, Some(EncryptRecipients::Age(values)) if values == ["age1workspace"])
794        );
795
796        let fallback = resolve_seal_encryption(
797            None,
798            &[],
799            Some(&Encryption {
800                recipient: None,
801                backend: None,
802                age_recipients: Vec::new(),
803            }),
804            &config,
805        )
806        .unwrap();
807        assert!(
808            matches!(fallback, Some(EncryptRecipients::Age(values)) if values == ["age1config"])
809        );
810    }
811
812    #[test]
813    fn seal_encryption_backend_priority_is_cli_workspace_config() {
814        let dir = std::env::temp_dir().join(format!("shine-seal-enc-{}", uuid::Uuid::new_v4()));
815        let mut config = Config::new_for_test(&dir);
816        config.secret_backend = Some("age".to_string());
817        config.gpg_key_id = Some("global".to_string());
818        let workspace_encryption = Encryption {
819            recipient: Some("workspace".to_string()),
820            backend: Some("gpg".to_string()),
821            age_recipients: Vec::new(),
822        };
823
824        let resolved =
825            resolve_seal_encryption(None, &[], Some(&workspace_encryption), &config).unwrap();
826        assert!(matches!(resolved, Some(EncryptRecipients::Gpg(_))));
827
828        let resolved_age = resolve_seal_encryption(None, &[], None, &config).unwrap();
829        assert!(
830            resolved_age.is_none(),
831            "age backend with no age_recipients should be lazily None: {resolved_age:?}"
832        );
833    }
834
835    #[tokio::test]
836    async fn plain_sources_merge_in_declared_order() {
837        let directory =
838            std::env::temp_dir().join(format!("shine-workspace-{}", uuid::Uuid::new_v4()));
839        tokio::fs::create_dir_all(&directory).await.unwrap();
840        let base = directory.join("base.toml");
841        let local = directory.join("local.toml");
842        tokio::fs::write(&base, "version = 1\n[plain]\nA = \"base\"\nB = \"base\"\n")
843            .await
844            .unwrap();
845        tokio::fs::write(&local, "version = 1\n[plain]\nB = \"local\"\n")
846            .await
847            .unwrap();
848
849        let config = Config::new_for_test(&directory);
850        let values = compile_sources(&[base, local], &config).await.unwrap();
851        assert_eq!(values.get("A").map(String::as_str), Some("base"));
852        assert_eq!(values.get("B").map(String::as_str), Some("local"));
853        tokio::fs::remove_dir_all(directory).await.unwrap();
854    }
855
856    #[tokio::test]
857    async fn plain_only_source_can_be_sealed_without_recipient() {
858        let directory = std::env::temp_dir().join(format!("shine-seal-{}", uuid::Uuid::new_v4()));
859        tokio::fs::create_dir_all(&directory).await.unwrap();
860        let path = directory.join("env.toml");
861        tokio::fs::write(&path, "version = 1\n[plain]\nNAME = \"shine\"\n")
862            .await
863            .unwrap();
864
865        let config = Config::new_for_test(&directory);
866        seal_file(&path, &config, None).await.unwrap();
867        let source = tokio::fs::read_to_string(&path).await.unwrap();
868        assert!(source.contains("[payload]"));
869        tokio::fs::remove_dir_all(directory).await.unwrap();
870    }
871
872    #[cfg(unix)]
873    #[tokio::test]
874    async fn run_command_injects_workspace_values() {
875        let values = BTreeMap::from([("SHINE_RUN_TEST".to_string(), "injected".to_string())]);
876        run_command(
877            &[
878                OsString::from("sh"),
879                OsString::from("-c"),
880                OsString::from("test \"$SHINE_RUN_TEST\" = injected"),
881            ],
882            &values,
883            true,
884            &BTreeMap::new(),
885        )
886        .await
887        .unwrap();
888    }
889
890    #[tokio::test]
891    async fn explicit_values_support_aliases_and_multiple_keys() {
892        let directory = std::env::temp_dir().join(format!("shine-with-{}", uuid::Uuid::new_v4()));
893        let mut config = Config::new_for_test(&directory);
894        config.env.insert("TOKEN_A".into(), "alpha".into());
895        config.env.insert("TOKEN_B".into(), "beta".into());
896
897        let values =
898            resolve_explicit_values(&config, &["TOKEN_A".into(), "TOKEN_B=OTHER_TOKEN".into()])
899                .await
900                .unwrap();
901
902        assert_eq!(values.get("TOKEN_A").map(String::as_str), Some("alpha"));
903        assert_eq!(values.get("OTHER_TOKEN").map(String::as_str), Some("beta"));
904    }
905
906    #[tokio::test]
907    async fn explicit_values_reject_duplicate_targets_before_resolution() {
908        let directory = std::env::temp_dir().join(format!("shine-with-{}", uuid::Uuid::new_v4()));
909        let config = Config::new_for_test(&directory);
910
911        let error =
912            resolve_explicit_values(&config, &["TOKEN_A=TOKEN".into(), "TOKEN_B=TOKEN".into()])
913                .await
914                .unwrap_err();
915
916        assert!(error.to_string().contains("duplicate target variable"));
917    }
918
919    #[cfg(unix)]
920    #[tokio::test]
921    async fn no_workspace_injects_explicit_without_discovery() {
922        let directory = std::env::temp_dir().join(format!("shine-nows-{}", uuid::Uuid::new_v4()));
923        let mut config = Config::new_for_test(&directory);
924        config.env.insert("SHINE_NOWS_TOKEN".into(), "alpha".into());
925
926        // no_workspace = true must skip discovery entirely and inject only --with.
927        handle_run(
928            &config,
929            None,
930            None,
931            true,
932            &["SHINE_NOWS_TOKEN".into()],
933            &[
934                OsString::from("sh"),
935                OsString::from("-c"),
936                OsString::from("test \"$SHINE_NOWS_TOKEN\" = alpha"),
937            ],
938        )
939        .await
940        .unwrap();
941    }
942
943    #[cfg(unix)]
944    #[tokio::test]
945    async fn no_workspace_allows_empty_with() {
946        let directory =
947            std::env::temp_dir().join(format!("shine-nows-empty-{}", uuid::Uuid::new_v4()));
948        let config = Config::new_for_test(&directory);
949
950        handle_run(
951            &config,
952            None,
953            None,
954            true,
955            &[],
956            &[
957                OsString::from("sh"),
958                OsString::from("-c"),
959                OsString::from("true"),
960            ],
961        )
962        .await
963        .unwrap();
964    }
965
966    #[tokio::test]
967    async fn explicit_values_reject_invalid_or_missing_keys() {
968        let directory = std::env::temp_dir().join(format!("shine-with-{}", uuid::Uuid::new_v4()));
969        let config = Config::new_for_test(&directory);
970
971        let invalid = resolve_explicit_values(&config, &["BAD-KEY".into()])
972            .await
973            .unwrap_err();
974        assert!(
975            invalid
976                .to_string()
977                .contains("invalid environment variable name")
978        );
979
980        let missing = resolve_explicit_values(&config, &["MISSING".into()])
981            .await
982            .unwrap_err();
983        assert!(
984            missing
985                .to_string()
986                .contains("MISSING_SECRET or MISSING is not set")
987        );
988    }
989
990    #[cfg(unix)]
991    #[tokio::test]
992    #[allow(clippy::await_holding_lock)]
993    async fn explicit_values_override_workspace_and_process_values() {
994        let _guard = crate::test_support::env_lock();
995        // SAFETY: the shared test env lock serializes process environment mutation.
996        unsafe { std::env::set_var("SHINE_RUN_OVERRIDE_TEST", "process") };
997        let workspace = BTreeMap::from([(
998            "SHINE_RUN_OVERRIDE_TEST".to_string(),
999            "workspace".to_string(),
1000        )]);
1001        let explicit = BTreeMap::from([(
1002            "SHINE_RUN_OVERRIDE_TEST".to_string(),
1003            "explicit".to_string(),
1004        )]);
1005
1006        run_command(
1007            &[
1008                OsString::from("sh"),
1009                OsString::from("-c"),
1010                OsString::from("test \"$SHINE_RUN_OVERRIDE_TEST\" = explicit"),
1011            ],
1012            &workspace,
1013            false,
1014            &explicit,
1015        )
1016        .await
1017        .unwrap();
1018
1019        assert_eq!(
1020            std::env::var("SHINE_RUN_OVERRIDE_TEST").as_deref(),
1021            Ok("process")
1022        );
1023        // SAFETY: the shared test env lock serializes process environment mutation.
1024        unsafe { std::env::remove_var("SHINE_RUN_OVERRIDE_TEST") };
1025    }
1026}