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