1use anyhow::{Context, Result, bail};
4
5use super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};
6use crate::config::{Config, EnvOverrideKind, EnvOverrideSource};
7use crate::secret::{BackendKind, EncryptRecipients};
8use crate::{colors, path_display, secret, shells};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16enum EnvSourceGroup {
17 Config,
18 Global,
19 Overlay { managed: bool },
20 Project,
21}
22
23impl EnvSourceGroup {
24 fn order(self) -> u8 {
26 match self {
27 EnvSourceGroup::Config => 0,
28 EnvSourceGroup::Global => 1,
29 EnvSourceGroup::Overlay { .. } => 2,
30 EnvSourceGroup::Project => 3,
31 }
32 }
33
34 fn label(self) -> &'static str {
36 match self {
37 EnvSourceGroup::Config => "config.toml",
38 EnvSourceGroup::Global => "global env file",
39 EnvSourceGroup::Overlay { managed: false } => "overlay",
40 EnvSourceGroup::Overlay { managed: true } => "overlay (managed)",
41 EnvSourceGroup::Project => "project env file",
42 }
43 }
44}
45
46fn env_source_group(source: Option<&EnvOverrideSource>) -> EnvSourceGroup {
50 match source {
51 None => EnvSourceGroup::Config,
52 Some(source) => match source.kind {
53 EnvOverrideKind::Global => EnvSourceGroup::Global,
54 EnvOverrideKind::Overlay => EnvSourceGroup::Overlay {
55 managed: source.is_managed_overlay,
56 },
57 EnvOverrideKind::Project => EnvSourceGroup::Project,
58 },
59 }
60}
61
62fn group_env_keys<'a>(
66 keys: impl Iterator<Item = &'a str>,
67 source_of: impl Fn(&str) -> Option<&'a EnvOverrideSource>,
68) -> Vec<(EnvSourceGroup, Vec<&'a str>)> {
69 let mut groups: Vec<(EnvSourceGroup, Vec<&'a str>)> = Vec::new();
70 for key in keys {
71 let group = env_source_group(source_of(key));
72 match groups.iter_mut().find(|(g, _)| *g == group) {
73 Some((_, members)) => members.push(key),
74 None => groups.push((group, vec![key])),
75 }
76 }
77 groups.sort_by_key(|(group, _)| group.order());
78 groups
79}
80
81pub async fn handle_list(config: &Config, reveal: bool) -> Result<()> {
82 let env = EnvConfig::load_or_init(config).await?;
83 let catalog = super::catalog::load(config).await?;
84 let terminal_width = usize::from(console::Term::stdout().size().1).max(40);
85 let key_width = env
86 .iter()
87 .map(|(key, _)| key.chars().count())
88 .max()
89 .unwrap_or(0);
90
91 println!("{}", colors::bold("Environment"));
92 println!();
93 if env.as_map().is_empty() {
94 println!(" {}", colors::dim("No variables configured."));
95 println!();
96 }
97
98 let groups = group_env_keys(env.iter().map(|(k, _)| k), |key| {
99 config.env_override_source(key)
100 });
101 for (group, keys) in groups {
102 match keys.first().and_then(|key| config.env_override_source(key)) {
104 Some(source) => println!(
105 "{} {}",
106 colors::bold(group.label()),
107 colors::dim(&path_display::format(&source.path))
108 ),
109 None => println!("{}", colors::bold(group.label())),
110 }
111 for k in keys {
112 let v = env.get(k).unwrap_or_default();
113 let metadata = catalog.get(k);
114 let description = env
115 .description(k)
116 .or_else(|| metadata.map(|item| item.description.as_str()))
117 .unwrap_or_default();
118 let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);
119 let display_value = display_env_value(v, sensitive, reveal);
120 let (display_value, description) =
121 fit_env_row(&display_value, description, key_width, terminal_width);
122 let key_padding = " ".repeat(key_width.saturating_sub(k.chars().count()));
123 if description.is_empty() {
124 println!(" {}{} {}", colors::cyan(k), key_padding, display_value);
125 } else {
126 println!(
127 " {}{} {:<value_width$} {}",
128 colors::cyan(k),
129 key_padding,
130 display_value,
131 colors::dim(&description),
132 value_width = env_value_width(key_width, terminal_width),
133 );
134 }
135 }
136 println!();
137 }
138
139 println!(
140 " {} {}",
141 colors::dim("Config"),
142 colors::dim(&path_display::format(config.config_path()))
143 );
144 println!(
145 " {}",
146 colors::dim(&format!("{} variables", env.as_map().len()))
147 );
148 Ok(())
149}
150
151fn is_sensitive_env_key(key: &str) -> bool {
152 let key = key.to_ascii_uppercase();
153 [
154 "SECRET",
155 "TOKEN",
156 "PASSWORD",
157 "PASSPHRASE",
158 "API_KEY",
159 "PRIVATE_KEY",
160 "ACCESS_KEY",
161 "SUBSCRIPTION_URL",
162 ]
163 .iter()
164 .any(|suffix| key == *suffix || key.ends_with(&format!("_{suffix}")))
165}
166
167fn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {
168 if value.is_empty() {
169 "<empty>".to_string()
170 } else if sensitive && !reveal {
171 "<redacted>".to_string()
172 } else {
173 value.to_string()
174 }
175}
176
177fn env_value_width(key_width: usize, terminal_width: usize) -> usize {
178 terminal_width.saturating_sub(key_width + 28).clamp(12, 36)
179}
180
181fn fit_env_row(
182 value: &str,
183 description: &str,
184 key_width: usize,
185 terminal_width: usize,
186) -> (String, String) {
187 let value_width = env_value_width(key_width, terminal_width);
188 let value = truncate_text(value, value_width);
189 let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);
190 let description = if description_width < 8 {
191 String::new()
192 } else {
193 truncate_text(description, description_width)
194 };
195 (value, description)
196}
197
198fn truncate_text(value: &str, max_width: usize) -> String {
199 if value.chars().count() <= max_width {
200 return value.to_string();
201 }
202 if max_width <= 1 {
203 return "…".to_string();
204 }
205 let mut result = value.chars().take(max_width - 1).collect::<String>();
206 result.push('…');
207 result
208}
209
210#[derive(Debug)]
214enum EnvWriteTarget<'a> {
215 ConfigToml,
216 OverrideFile(&'a crate::config::EnvOverrideSource),
217}
218
219fn resolve_env_write_target<'a>(
225 config: &'a Config,
226 key: &str,
227 force: bool,
228) -> Result<EnvWriteTarget<'a>> {
229 let Some(source) = config.env_override_source(key) else {
230 return Ok(EnvWriteTarget::ConfigToml);
231 };
232 if !force {
233 bail!(
234 "{key} currently resolves from {} (an env override file), which takes precedence over {}; this write would have no effect.\nRe-run with --force to write directly into that file instead.",
235 path_display::format(&source.path),
236 path_display::format(config.config_path()),
237 );
238 }
239 if source.is_managed_overlay {
240 eprintln!(
241 "{}",
242 colors::yellow(&format!(
243 "Warning: {} is the shine-managed overlay mirror; this change will be discarded on the next `shine preset pull`/`shine update`. Edit it upstream on the maintaining device instead.",
244 path_display::format(&source.path)
245 ))
246 );
247 }
248 Ok(EnvWriteTarget::OverrideFile(source))
249}
250
251pub async fn handle_set(config: &Config, key: &str, value: &str, force: bool) -> Result<()> {
252 let catalog = super::catalog::load(config).await?;
253 let sensitive =
254 catalog.get(key).is_some_and(|item| item.sensitive) || is_sensitive_env_key(key);
255 let display_value = display_env_value(value, sensitive, false);
256 match resolve_env_write_target(config, key, force)? {
257 EnvWriteTarget::ConfigToml => {
258 let mut env = EnvConfig::load_or_init(config).await?;
259 env.set(key, value);
260 env.save(config).await?;
261 println!(
262 "{}",
263 colors::green(&format!(
264 "set {key} = \"{display_value}\" in {}",
265 path_display::format(config.config_path())
266 ))
267 );
268 }
269 EnvWriteTarget::OverrideFile(source) => {
270 crate::config::write_env_override_entry(&source.path, key, Some(value)).await?;
271 println!(
272 "{}",
273 colors::green(&format!(
274 "set {key} = \"{display_value}\" in {}",
275 path_display::format(&source.path)
276 ))
277 );
278 }
279 }
280 println!(
281 "{}",
282 colors::dim("Run `shine upgrade` to apply to already-installed presets.")
283 );
284 Ok(())
285}
286
287pub async fn handle_delete(config: &Config, key: &str, force: bool) -> Result<()> {
288 if !config.env.contains_key(key) && config.env_override_source(key).is_none() {
289 bail!("{key} is not set in the active config [env]");
290 }
291 match resolve_env_write_target(config, key, force)? {
292 EnvWriteTarget::ConfigToml => {
293 let mut env = EnvConfig::load_or_init(config).await?;
294 env.remove(key);
295 env.save(config).await?;
296 println!(
297 "{}",
298 colors::green(&format!(
299 "deleted {key} from {}",
300 path_display::format(config.config_path())
301 ))
302 );
303 }
304 EnvWriteTarget::OverrideFile(source) => {
305 crate::config::write_env_override_entry(&source.path, key, None).await?;
306 println!(
307 "{}",
308 colors::green(&format!(
309 "deleted {key} from {}",
310 path_display::format(&source.path)
311 ))
312 );
313 }
314 }
315 println!(
316 "{}",
317 colors::dim("Run `shine upgrade` to apply to already-installed presets.")
318 );
319 Ok(())
320}
321
322pub async fn handle_get(config: &Config, key: &str) -> Result<()> {
323 let env = EnvConfig::load_or_init(config).await?;
324 match env.get(key) {
325 Some(v) => println!("{v}"),
326 None => {
327 eprintln!(
328 "{}",
329 colors::yellow(&format!("{key} is not set in the active config [env]"))
330 );
331 std::process::exit(1);
332 }
333 }
334 Ok(())
335}
336
337pub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {
338 let env = EnvConfig::load_or_init(config).await?;
339 let Some(value) = env.get(key) else {
340 bail!("{key} is not set in the active config [env]");
341 };
342 let plaintext = secret::decrypt_secret(value, &config.age_identities())
343 .await
344 .with_context(|| format!("decrypting {key}"))?;
345 print!("{plaintext}");
346 Ok(())
347}
348
349pub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {
350 validate_env_export_key(key)?;
351 if let Some(alias) = alias {
352 validate_env_export_key(alias)?;
353 }
354 let env = EnvConfig::load_or_init(config).await?;
355 let value = match resolve_env_export_value(&env, key)? {
356 EnvExportValue::Secret {
357 key: secret_key,
358 value,
359 } => secret::decrypt_secret(value, &config.age_identities())
360 .await
361 .with_context(|| format!("decrypting {secret_key}"))?,
362 EnvExportValue::Plaintext(value) => value.to_string(),
363 };
364 let export_as = alias.unwrap_or(key);
365 println!(
366 "{}",
367 format_env_export(&config.shell_type, export_as, &value)
368 );
369 Ok(())
370}
371
372type EnvExportValue<'a> = StoredValue<'a>;
373
374fn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {
375 resolve_stored_value(env, key)
376}
377
378fn env_export_secret_key(key: &str) -> String {
379 secret_key(key)
380}
381
382fn validate_env_export_key(key: &str) -> Result<()> {
383 let mut chars = key.chars();
384 let Some(first) = chars.next() else {
385 bail!("env secret export key must not be empty");
386 };
387 if !(first == '_' || first.is_ascii_alphabetic()) {
388 bail!("env secret export key must start with a letter or underscore: {key}");
389 }
390 if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
391 bail!("env secret export key must contain only letters, digits, and underscores: {key}");
392 }
393 Ok(())
394}
395
396pub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {
400 match shell {
401 shells::ShellType::Fish => format!("set -gx {key} {}", fish_quote(value)),
402 shells::ShellType::PowerShell => {
403 format!("$env:{key} = {}", powershell_string_quote(value))
404 }
405 _ => format!("export {key}={}", posix_shell_quote(value)),
406 }
407}
408
409fn posix_shell_quote(value: &str) -> String {
410 format!("'{}'", value.replace('\'', "'\\''"))
411}
412
413fn fish_quote(value: &str) -> String {
414 format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
415}
416
417fn powershell_string_quote(value: &str) -> String {
418 format!("'{}'", value.replace('\'', "''"))
419}
420
421#[derive(Debug, PartialEq, Eq)]
422enum EnvEncryptOutput {
423 Print,
424 Set(String),
425}
426
427fn resolve_env_encrypt_output(
428 set_key: Option<&str>,
429 from_key: Option<&str>,
430) -> Result<EnvEncryptOutput> {
431 if let Some(key) = set_key {
432 return Ok(EnvEncryptOutput::Set(key.to_string()));
433 }
434 if let Some(key) = from_key {
435 validate_env_export_key(key)?;
436 return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));
437 }
438 Ok(EnvEncryptOutput::Print)
439}
440
441fn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {
442 if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {
443 return backend.parse();
444 }
445 if let Some(backend) = config
446 .secret_backend
447 .as_deref()
448 .map(str::trim)
449 .filter(|value| !value.is_empty())
450 {
451 return backend.parse();
452 }
453 Ok(BackendKind::default())
454}
455
456fn clean_recipients(recipients: &[String]) -> Vec<String> {
457 recipients
458 .iter()
459 .map(|value| value.trim().to_string())
460 .filter(|value| !value.is_empty())
461 .collect()
462}
463
464fn resolve_encrypt_recipients(
465 backend: BackendKind,
466 cli_recipients: &[String],
467 config: &Config,
468) -> Result<EncryptRecipients> {
469 let cli_recipients = clean_recipients(cli_recipients);
470 if !cli_recipients.is_empty() {
471 if backend == BackendKind::Gpg
472 && let Some(hint) = cli_recipients
473 .iter()
474 .find(|value| value.starts_with("age1"))
475 {
476 bail!("recipient \"{hint}\" looks like an age recipient; did you mean --backend age?");
477 }
478 return Ok(match backend {
479 BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),
480 BackendKind::Age => EncryptRecipients::Age(cli_recipients),
481 });
482 }
483
484 match backend {
485 BackendKind::Gpg => {
486 if config.legacy_gpg_key_id.is_some() {
487 bail!(
488 "gpg_key_id is retired; run `shine state migrate` to convert it to gpg_recipients"
489 );
490 }
491 let recipients = clean_recipients(&config.gpg_recipients);
492 if recipients.is_empty() {
493 bail!(
494 "GPG recipients are required; pass -r/--recipient, set gpg_recipients, or set secret_backend/age_recipients for age"
495 );
496 }
497 Ok(EncryptRecipients::Gpg(recipients))
498 }
499 BackendKind::Age => {
500 let recipients = clean_recipients(&config.age_recipients);
501 if recipients.is_empty() {
502 bail!(
503 "age recipients are required; pass -r/--recipient or set age_recipients in config.toml"
504 );
505 }
506 Ok(EncryptRecipients::Age(recipients))
507 }
508 }
509}
510
511pub async fn handle_encrypt(
512 config: &Config,
513 backend: Option<&str>,
514 recipients: &[String],
515 set_key: Option<&str>,
516 from_key: Option<&str>,
517 force: bool,
518) -> Result<()> {
519 use std::io::Read as _;
520
521 let backend = resolve_encrypt_backend(config, backend)?;
522 let recipients = resolve_encrypt_recipients(backend, recipients, config)?;
523 let plaintext = if let Some(key) = from_key {
524 let env = EnvConfig::load_or_init(config).await?;
525 let Some(value) = env.get(key) else {
526 bail!("{key} is not set in the active config [env]");
527 };
528 value.as_bytes().to_vec()
529 } else {
530 let mut input = Vec::new();
531 std::io::stdin()
532 .read_to_end(&mut input)
533 .context("reading secret from stdin")?;
534 input
535 };
536 let encoded = secret::encrypt_secret(&plaintext, &recipients)
537 .await
538 .context("encrypting secret")?;
539 match resolve_env_encrypt_output(set_key, from_key)? {
540 EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {
541 EnvWriteTarget::ConfigToml => {
542 let mut env = EnvConfig::load_or_init(config).await?;
543 env.set(&key, &encoded);
544 env.save(config).await?;
545 println!(
546 "{}",
547 colors::green(&format!(
548 "set {key} = \"{encoded}\" in {}",
549 path_display::format(config.config_path())
550 ))
551 );
552 }
553 EnvWriteTarget::OverrideFile(source) => {
554 crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;
555 println!(
556 "{}",
557 colors::green(&format!(
558 "set {key} = \"{encoded}\" in {}",
559 path_display::format(&source.path)
560 ))
561 );
562 }
563 },
564 EnvEncryptOutput::Print => println!("{encoded}"),
565 }
566 Ok(())
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use crate::config::Config;
573 use tokio::fs;
574
575 async fn make_temp_dir() -> std::path::PathBuf {
576 crate::test_support::make_temp_dir("shine-env-cmd-test").await
577 }
578
579 fn config_in(dir: &std::path::Path) -> Config {
580 crate::test_support::test_config(dir)
581 }
582
583 #[test]
584 fn env_show_redacts_sensitive_values() {
585 assert_eq!(display_env_value("secret", true, false), "<redacted>");
586 assert_eq!(display_env_value("secret", true, true), "secret");
587 assert_eq!(display_env_value("", true, false), "<empty>");
588 assert!(is_sensitive_env_key("MY_API_KEY"));
589 assert!(is_sensitive_env_key("token"));
590 assert!(is_sensitive_env_key("SURGE_SUBSCRIPTION_URL"));
591 assert!(!is_sensitive_env_key("MONKEY"));
592 }
593
594 fn source(kind: EnvOverrideKind, managed: bool) -> EnvOverrideSource {
595 EnvOverrideSource {
596 path: std::path::PathBuf::from("/tmp/shine.env.toml"),
597 kind,
598 is_managed_overlay: managed,
599 }
600 }
601
602 #[test]
603 fn env_source_group_maps_each_layer() {
604 assert_eq!(env_source_group(None), EnvSourceGroup::Config);
605 assert_eq!(
606 env_source_group(Some(&source(EnvOverrideKind::Global, false))),
607 EnvSourceGroup::Global
608 );
609 assert_eq!(
610 env_source_group(Some(&source(EnvOverrideKind::Overlay, false))),
611 EnvSourceGroup::Overlay { managed: false }
612 );
613 assert_eq!(
614 env_source_group(Some(&source(EnvOverrideKind::Overlay, true))),
615 EnvSourceGroup::Overlay { managed: true }
616 );
617 assert_eq!(
618 env_source_group(Some(&source(EnvOverrideKind::Project, false))),
619 EnvSourceGroup::Project
620 );
621 }
622
623 #[test]
624 fn group_env_keys_orders_sections_and_skips_empty() {
625 let global = source(EnvOverrideKind::Global, false);
626 let overlay = source(EnvOverrideKind::Overlay, true);
627 let keys = ["PROJECT_LESS", "FROM_OVERLAY", "FROM_CONFIG", "FROM_GLOBAL"];
629 let groups = group_env_keys(keys.iter().copied(), |key| match key {
630 "FROM_GLOBAL" => Some(&global),
631 "FROM_OVERLAY" => Some(&overlay),
632 _ => None,
633 });
634
635 assert_eq!(
637 groups.iter().map(|(g, _)| *g).collect::<Vec<_>>(),
638 vec![
639 EnvSourceGroup::Config,
640 EnvSourceGroup::Global,
641 EnvSourceGroup::Overlay { managed: true },
642 ]
643 );
644 assert_eq!(groups[0].1, vec!["PROJECT_LESS", "FROM_CONFIG"]);
645 assert_eq!(groups[1].1, vec!["FROM_GLOBAL"]);
646 assert_eq!(groups[2].1, vec!["FROM_OVERLAY"]);
647 }
648
649 #[test]
650 fn group_env_keys_all_config_yields_single_group() {
651 let keys = ["A", "B", "C"];
652 let groups = group_env_keys(keys.iter().copied(), |_| None);
653 assert_eq!(groups.len(), 1);
654 assert_eq!(groups[0].0, EnvSourceGroup::Config);
655 assert_eq!(groups[0].1, vec!["A", "B", "C"]);
656 }
657
658 #[test]
659 fn env_source_group_labels_are_stable() {
660 assert_eq!(EnvSourceGroup::Config.label(), "config.toml");
661 assert_eq!(EnvSourceGroup::Global.label(), "global env file");
662 assert_eq!(
663 EnvSourceGroup::Overlay { managed: false }.label(),
664 "overlay"
665 );
666 assert_eq!(
667 EnvSourceGroup::Overlay { managed: true }.label(),
668 "overlay (managed)"
669 );
670 assert_eq!(EnvSourceGroup::Project.label(), "project env file");
671 }
672
673 #[test]
674 fn env_show_truncates_long_values_to_requested_width() {
675 assert_eq!(truncate_text("abcdefgh", 5), "abcd…");
676 let (value, description) = fit_env_row(
677 "abcdefghijklmnopqrstuvwxyz",
678 "A description that is also fairly long",
679 8,
680 48,
681 );
682 assert!(value.chars().count() <= env_value_width(8, 48));
683 assert!(description.chars().count() <= 48);
684 }
685
686 #[test]
687 fn env_export_uses_alias_as_variable_name() {
688 let value = "secret123";
689 assert_eq!(
690 format_env_export(&shells::ShellType::Zsh, "MY_ALIAS", value),
691 "export MY_ALIAS='secret123'"
692 );
693 }
694
695 #[test]
696 fn env_export_alias_formats_powershell_correctly() {
697 let value = "secret123";
698 assert_eq!(
699 format_env_export(&shells::ShellType::PowerShell, "MY_ALIAS", value),
700 "$env:MY_ALIAS = 'secret123'"
701 );
702 }
703
704 #[tokio::test]
705 async fn env_delete_removes_key_from_saved_config() {
706 let dir = make_temp_dir().await;
707 let mut config = config_in(&dir);
708 config.env.insert("MY_TOKEN".into(), "secret".into());
709 config.save().await.unwrap();
710
711 handle_delete(&config, "MY_TOKEN", false).await.unwrap();
712
713 let contents = fs::read_to_string(config.config_path()).await.unwrap();
714 let parsed: toml::Table = toml::from_str(&contents).unwrap();
715 let env = parsed
716 .get("env")
717 .and_then(|value| value.as_table())
718 .unwrap();
719 assert!(
720 !env.contains_key("MY_TOKEN"),
721 "deleted key should not remain in saved config: {contents}"
722 );
723
724 fs::remove_dir_all(&dir).await.unwrap();
725 }
726
727 #[tokio::test]
728 async fn env_delete_fails_when_key_is_missing() {
729 let dir = make_temp_dir().await;
730 let config = config_in(&dir);
731
732 let err = handle_delete(&config, "MY_TOKEN", false).await.unwrap_err();
733
734 assert!(
735 err.to_string()
736 .contains("MY_TOKEN is not set in the active config [env]"),
737 "error should explain missing key: {err:#}"
738 );
739 fs::remove_dir_all(&dir).await.unwrap();
740 }
741
742 #[test]
743 fn env_export_secret_key_appends_secret_suffix() {
744 assert_eq!(
745 env_export_secret_key("DEEPSEEK_API_KEY"),
746 "DEEPSEEK_API_KEY_SECRET"
747 );
748 assert_eq!(env_export_secret_key("xxx"), "xxx_SECRET");
749 }
750
751 #[test]
752 fn env_export_resolves_secret_when_present() {
753 let mut env = EnvConfig::default();
754 env.set("MY_TOKEN_SECRET", "encrypted");
755
756 assert_eq!(
757 resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
758 EnvExportValue::Secret {
759 key: "MY_TOKEN_SECRET".to_string(),
760 value: "encrypted"
761 }
762 );
763 }
764
765 #[test]
766 fn env_export_falls_back_to_plaintext_value() {
767 let mut env = EnvConfig::default();
768 env.set("MY_TOKEN", "plain");
769
770 assert_eq!(
771 resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
772 EnvExportValue::Plaintext("plain")
773 );
774 }
775
776 #[test]
777 fn env_export_secret_wins_over_plaintext_value() {
778 let mut env = EnvConfig::default();
779 env.set("MY_TOKEN", "plain");
780 env.set("MY_TOKEN_SECRET", "encrypted");
781
782 assert_eq!(
783 resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
784 EnvExportValue::Secret {
785 key: "MY_TOKEN_SECRET".to_string(),
786 value: "encrypted"
787 }
788 );
789 }
790
791 #[test]
792 fn env_export_reports_both_missing_keys() {
793 let env = EnvConfig::default();
794
795 let err = resolve_env_export_value(&env, "MY_TOKEN").unwrap_err();
796
797 assert!(
798 err.to_string()
799 .contains("MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]"),
800 "error should explain both checked keys: {err:#}"
801 );
802 }
803
804 #[test]
805 fn env_export_key_validation_accepts_shell_variable_names() {
806 for key in ["FOO", "_FOO", "foo_123", "A1"] {
807 validate_env_export_key(key).unwrap();
808 }
809 }
810
811 #[test]
812 fn env_export_key_validation_rejects_unsafe_names() {
813 for key in ["", "1FOO", "FOO-BAR", "FOO;BAR", "FOO BAR", "FOO.SECRET"] {
814 assert!(
815 validate_env_export_key(key).is_err(),
816 "key should be rejected: {key}"
817 );
818 }
819 }
820
821 #[test]
822 fn env_export_formats_posix_shell_code_safely() {
823 let value = "abc def'ghi$HOME\nnext; rm -rf /";
824 assert_eq!(
825 format_env_export(&shells::ShellType::Zsh, "TOKEN", value),
826 "export TOKEN='abc def'\\''ghi$HOME\nnext; rm -rf /'"
827 );
828 }
829
830 #[test]
831 fn env_export_formats_fish_shell_code_safely() {
832 let value = "abc def'ghi\\path\nnext; rm -rf /";
833 assert_eq!(
834 format_env_export(&shells::ShellType::Fish, "TOKEN", value),
835 "set -gx TOKEN 'abc def\\'ghi\\\\path\nnext; rm -rf /'"
836 );
837 }
838
839 #[test]
840 fn env_export_formats_powershell_code_safely() {
841 let value = "abc def'ghi$HOME\nnext; Remove-Item /";
842 assert_eq!(
843 format_env_export(&shells::ShellType::PowerShell, "TOKEN", value),
844 "$env:TOKEN = 'abc def''ghi$HOME\nnext; Remove-Item /'"
845 );
846 }
847
848 #[test]
849 fn env_encrypt_output_defaults_from_key_to_secret_key() {
850 assert_eq!(
851 resolve_env_encrypt_output(None, Some("GH_TOKEN")).unwrap(),
852 EnvEncryptOutput::Set("GH_TOKEN_SECRET".to_string())
853 );
854 }
855
856 #[test]
857 fn env_encrypt_output_explicit_set_wins_over_default() {
858 assert_eq!(
859 resolve_env_encrypt_output(Some("CUSTOM_SECRET"), Some("GH_TOKEN")).unwrap(),
860 EnvEncryptOutput::Set("CUSTOM_SECRET".to_string())
861 );
862 }
863
864 #[test]
865 fn env_encrypt_output_prints_stdin_without_set() {
866 assert_eq!(
867 resolve_env_encrypt_output(None, None).unwrap(),
868 EnvEncryptOutput::Print
869 );
870 }
871
872 #[test]
873 fn env_encrypt_output_rejects_invalid_inferred_from_key() {
874 let err = resolve_env_encrypt_output(None, Some("GH-TOKEN")).unwrap_err();
875
876 assert!(
877 err.to_string().contains(
878 "env secret export key must contain only letters, digits, and underscores"
879 ),
880 "error should explain invalid inferred key: {err:#}"
881 );
882 }
883
884 #[test]
885 fn encrypt_backend_cli_wins_over_config() {
886 let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
887 let mut config = config_in(&dir);
888 config.secret_backend = Some("age".to_string());
889
890 assert_eq!(
891 resolve_encrypt_backend(&config, Some("gpg")).unwrap(),
892 BackendKind::Gpg
893 );
894 }
895
896 #[test]
897 fn encrypt_backend_falls_back_to_config() {
898 let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
899 let mut config = config_in(&dir);
900 config.secret_backend = Some("age".to_string());
901
902 assert_eq!(
903 resolve_encrypt_backend(&config, None).unwrap(),
904 BackendKind::Age
905 );
906 }
907
908 #[test]
909 fn encrypt_backend_defaults_to_gpg() {
910 let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
911 let config = config_in(&dir);
912
913 assert_eq!(
914 resolve_encrypt_backend(&config, None).unwrap(),
915 BackendKind::Gpg
916 );
917 }
918
919 #[test]
920 fn encrypt_recipients_cli_wins_over_config_for_gpg() {
921 let dir =
922 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
923 let mut config = config_in(&dir);
924 config.gpg_recipients = vec!["config@example.com".to_string()];
925
926 let recipients =
927 resolve_encrypt_recipients(BackendKind::Gpg, &["cli@example.com".to_string()], &config)
928 .unwrap();
929
930 match recipients {
931 EncryptRecipients::Gpg(values) => assert_eq!(values, vec!["cli@example.com"]),
932 EncryptRecipients::Age(_) => panic!("expected gpg recipients"),
933 }
934 }
935
936 #[test]
937 fn encrypt_recipients_gpg_falls_back_to_config() {
938 let dir =
939 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
940 let mut config = config_in(&dir);
941 config.gpg_recipients = vec![
942 "config@example.com".to_string(),
943 "team@example.com".to_string(),
944 ];
945
946 let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();
947
948 match recipients {
949 EncryptRecipients::Gpg(values) => {
950 assert_eq!(values, vec!["config@example.com", "team@example.com"])
951 }
952 EncryptRecipients::Age(_) => panic!("expected gpg recipients"),
953 }
954 }
955
956 #[test]
957 fn encrypt_recipients_gpg_treats_empty_config_as_missing() {
958 let dir =
959 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
960 let mut config = config_in(&dir);
961 config.gpg_recipients = vec![" ".to_string()];
962
963 let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();
964
965 assert!(
966 err.to_string()
967 .contains("pass -r/--recipient, set gpg_recipients"),
968 "error should explain how to set recipient: {err:#}"
969 );
970 }
971
972 #[test]
973 fn encrypt_recipients_gpg_errors_when_missing() {
974 let dir =
975 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
976 let config = config_in(&dir);
977
978 let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();
979
980 assert!(
981 err.to_string()
982 .contains("pass -r/--recipient, set gpg_recipients"),
983 "error should explain how to set recipient: {err:#}"
984 );
985 }
986
987 #[test]
988 fn encrypt_recipients_age_falls_back_to_config() {
989 let dir =
990 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
991 let mut config = config_in(&dir);
992 config.age_recipients = vec!["age1qexample".to_string()];
993
994 let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();
995
996 match recipients {
997 EncryptRecipients::Age(values) => assert_eq!(values, vec!["age1qexample"]),
998 EncryptRecipients::Gpg(_) => panic!("expected age recipients"),
999 }
1000 }
1001
1002 #[test]
1003 fn encrypt_recipients_age_errors_when_missing() {
1004 let dir =
1005 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1006 let config = config_in(&dir);
1007
1008 let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();
1009
1010 assert!(
1011 err.to_string().contains("age recipients are required"),
1012 "error should explain how to set age recipients: {err:#}"
1013 );
1014 }
1015
1016 #[test]
1017 fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {
1018 let dir =
1019 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1020 let config = config_in(&dir);
1021
1022 let err =
1023 resolve_encrypt_recipients(BackendKind::Gpg, &["age1qexample".to_string()], &config)
1024 .unwrap_err();
1025
1026 assert!(
1027 err.to_string().contains("did you mean --backend age"),
1028 "error should hint at the age backend: {err:#}"
1029 );
1030 }
1031
1032 fn shadow_key(
1033 config: &mut Config,
1034 key: &str,
1035 path: std::path::PathBuf,
1036 is_managed_overlay: bool,
1037 ) {
1038 let kind = if is_managed_overlay {
1039 crate::config::EnvOverrideKind::Overlay
1040 } else {
1041 crate::config::EnvOverrideKind::Global
1042 };
1043 config.env_override_sources.insert(
1044 key.to_string(),
1045 crate::config::EnvOverrideSource {
1046 path,
1047 kind,
1048 is_managed_overlay,
1049 },
1050 );
1051 }
1052
1053 #[test]
1054 fn resolve_env_write_target_returns_config_toml_when_unshadowed() {
1055 let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1056 let config = config_in(&dir);
1057
1058 let target = resolve_env_write_target(&config, "MY_TOKEN", false).unwrap();
1059
1060 assert!(matches!(target, EnvWriteTarget::ConfigToml));
1061 }
1062
1063 #[test]
1064 fn resolve_env_write_target_refuses_without_force_when_shadowed() {
1065 let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1066 let mut config = config_in(&dir);
1067 let override_path = dir.join("shine.env.toml");
1068 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1069
1070 let err = resolve_env_write_target(&config, "MY_TOKEN", false).unwrap_err();
1071
1072 assert!(
1073 err.to_string().contains(override_path.to_str().unwrap()),
1074 "error should name the winning override file: {err:#}"
1075 );
1076 assert!(
1077 err.to_string().contains("--force"),
1078 "error should hint at --force: {err:#}"
1079 );
1080 }
1081
1082 #[test]
1083 fn resolve_env_write_target_returns_override_file_with_force() {
1084 let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1085 let mut config = config_in(&dir);
1086 let override_path = dir.join("shine.env.toml");
1087 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1088
1089 let target = resolve_env_write_target(&config, "MY_TOKEN", true).unwrap();
1090
1091 match target {
1092 EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),
1093 EnvWriteTarget::ConfigToml => panic!("expected the shadowing override file"),
1094 }
1095 }
1096
1097 #[test]
1098 fn resolve_env_write_target_allows_managed_overlay_with_force() {
1099 let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1100 let mut config = config_in(&dir);
1101 let overlay_path = dir.join("overlay").join("shine.env.toml");
1102 shadow_key(&mut config, "MY_TOKEN", overlay_path.clone(), true);
1103
1104 let target = resolve_env_write_target(&config, "MY_TOKEN", true).unwrap();
1105
1106 match target {
1107 EnvWriteTarget::OverrideFile(source) => {
1108 assert_eq!(source.path, overlay_path);
1109 assert!(source.is_managed_overlay);
1110 }
1111 EnvWriteTarget::ConfigToml => panic!("expected the managed overlay override file"),
1112 }
1113 }
1114
1115 #[tokio::test]
1116 async fn env_set_refuses_when_shadowed_without_force() {
1117 let dir = make_temp_dir().await;
1118 let mut config = config_in(&dir);
1119 let override_path = dir.join("shine.env.toml");
1120 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1121
1122 let err = handle_set(&config, "MY_TOKEN", "newval", false)
1123 .await
1124 .unwrap_err();
1125
1126 assert!(err.to_string().contains(override_path.to_str().unwrap()));
1127 assert!(
1128 !fs::try_exists(&override_path).await.unwrap(),
1129 "refused write must not touch the override file"
1130 );
1131 assert!(
1132 !fs::read_to_string(config.config_path())
1133 .await
1134 .unwrap_or_default()
1135 .contains("MY_TOKEN"),
1136 "refused write must not touch config.toml either"
1137 );
1138
1139 fs::remove_dir_all(&dir).await.unwrap();
1140 }
1141
1142 #[tokio::test]
1143 async fn env_set_writes_into_override_file_when_forced() {
1144 let dir = make_temp_dir().await;
1145 let mut config = config_in(&dir);
1146 let override_path = dir.join("shine.env.toml");
1147 fs::write(&override_path, "MY_TOKEN = \"old\"\n")
1148 .await
1149 .unwrap();
1150 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1151
1152 handle_set(&config, "MY_TOKEN", "newval", true)
1153 .await
1154 .unwrap();
1155
1156 let content = fs::read_to_string(&override_path).await.unwrap();
1157 assert!(content.contains("MY_TOKEN = \"newval\""));
1158 assert!(
1159 !fs::read_to_string(config.config_path())
1160 .await
1161 .unwrap_or_default()
1162 .contains("MY_TOKEN"),
1163 "forced write must go into the override file, not config.toml"
1164 );
1165
1166 fs::remove_dir_all(&dir).await.unwrap();
1167 }
1168
1169 #[tokio::test]
1170 async fn env_delete_refuses_when_shadowed_without_force() {
1171 let dir = make_temp_dir().await;
1172 let mut config = config_in(&dir);
1173 let override_path = dir.join("shine.env.toml");
1174 fs::write(&override_path, "MY_TOKEN = \"secret\"\n")
1175 .await
1176 .unwrap();
1177 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1178
1179 let err = handle_delete(&config, "MY_TOKEN", false).await.unwrap_err();
1180
1181 assert!(err.to_string().contains(override_path.to_str().unwrap()));
1182 let content = fs::read_to_string(&override_path).await.unwrap();
1183 assert!(
1184 content.contains("MY_TOKEN"),
1185 "refused delete must leave the override file untouched"
1186 );
1187
1188 fs::remove_dir_all(&dir).await.unwrap();
1189 }
1190
1191 #[tokio::test]
1192 async fn env_delete_removes_from_override_file_when_forced() {
1193 let dir = make_temp_dir().await;
1194 let mut config = config_in(&dir);
1195 let override_path = dir.join("shine.env.toml");
1196 fs::write(&override_path, "MY_TOKEN = \"secret\"\nOTHER = \"kept\"\n")
1197 .await
1198 .unwrap();
1199 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1200
1201 handle_delete(&config, "MY_TOKEN", true).await.unwrap();
1202
1203 let content = fs::read_to_string(&override_path).await.unwrap();
1204 let table: toml::Table = toml::from_str(&content).unwrap();
1205 assert!(!table.contains_key("MY_TOKEN"));
1206 assert!(table.contains_key("OTHER"));
1207
1208 fs::remove_dir_all(&dir).await.unwrap();
1209 }
1210}