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