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