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