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_with_config(value, config)
343 .await
344 .with_context(|| format!("decrypting {key}"))?;
345 write_decrypted_plaintext(std::io::stdout().lock(), &plaintext)?;
346 Ok(())
347}
348
349fn write_decrypted_plaintext(mut output: impl std::io::Write, plaintext: &str) -> Result<()> {
350 output
351 .write_all(plaintext.as_bytes())
352 .context("writing decrypted plaintext")?;
353 output.flush().context("flushing decrypted plaintext")
354}
355
356pub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {
357 validate_env_export_key(key)?;
358 if let Some(alias) = alias {
359 validate_env_export_key(alias)?;
360 }
361 let env = EnvConfig::load_or_init(config).await?;
362 let value = match resolve_env_export_value(&env, key)? {
363 EnvExportValue::Secret {
364 key: secret_key,
365 value,
366 } => secret::decrypt_with_config(value, config)
367 .await
368 .with_context(|| format!("decrypting {secret_key}"))?,
369 EnvExportValue::Plaintext(value) => value.to_string(),
370 };
371 let export_as = alias.unwrap_or(key);
372 println!(
373 "{}",
374 format_env_export(&config.shell_type, export_as, &value)
375 );
376 Ok(())
377}
378
379type EnvExportValue<'a> = StoredValue<'a>;
380
381fn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {
382 resolve_stored_value(env, key)
383}
384
385fn env_export_secret_key(key: &str) -> String {
386 secret_key(key)
387}
388
389fn validate_env_export_key(key: &str) -> Result<()> {
390 let mut chars = key.chars();
391 let Some(first) = chars.next() else {
392 bail!("env secret export key must not be empty");
393 };
394 if !(first == '_' || first.is_ascii_alphabetic()) {
395 bail!("env secret export key must start with a letter or underscore: {key}");
396 }
397 if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
398 bail!("env secret export key must contain only letters, digits, and underscores: {key}");
399 }
400 Ok(())
401}
402
403pub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {
407 match shell {
408 shells::ShellType::Fish => format!("set -gx {key} {}", fish_quote(value)),
409 shells::ShellType::PowerShell => {
410 format!("$env:{key} = {}", powershell_string_quote(value))
411 }
412 _ => format!("export {key}={}", posix_shell_quote(value)),
413 }
414}
415
416fn posix_shell_quote(value: &str) -> String {
417 format!("'{}'", value.replace('\'', "'\\''"))
418}
419
420fn fish_quote(value: &str) -> String {
421 format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
422}
423
424fn powershell_string_quote(value: &str) -> String {
425 format!("'{}'", value.replace('\'', "''"))
426}
427
428#[derive(Debug, PartialEq, Eq)]
429enum EnvEncryptOutput {
430 Print,
431 Set(String),
432}
433
434fn resolve_env_encrypt_output(
435 set_key: Option<&str>,
436 from_key: Option<&str>,
437) -> Result<EnvEncryptOutput> {
438 if let Some(key) = set_key {
439 return Ok(EnvEncryptOutput::Set(key.to_string()));
440 }
441 if let Some(key) = from_key {
442 validate_env_export_key(key)?;
443 return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));
444 }
445 Ok(EnvEncryptOutput::Print)
446}
447
448fn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {
449 if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {
450 return backend.parse();
451 }
452 if let Some(backend) = config
453 .secret_backend
454 .as_deref()
455 .map(str::trim)
456 .filter(|value| !value.is_empty())
457 {
458 return backend.parse();
459 }
460 Ok(BackendKind::default())
461}
462
463fn clean_recipients(recipients: &[String]) -> Vec<String> {
464 recipients
465 .iter()
466 .map(|value| value.trim().to_string())
467 .filter(|value| !value.is_empty())
468 .collect()
469}
470
471fn resolve_encrypt_recipients(
472 backend: BackendKind,
473 cli_recipients: &[String],
474 config: &Config,
475) -> Result<EncryptRecipients> {
476 let cli_recipients = clean_recipients(cli_recipients);
477 if !cli_recipients.is_empty() {
478 if backend == BackendKind::Gpg
479 && let Some(hint) = cli_recipients
480 .iter()
481 .find(|value| value.starts_with("age1"))
482 {
483 bail!("recipient \"{hint}\" looks like an age recipient; did you mean --backend age?");
484 }
485 return Ok(match backend {
486 BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),
487 BackendKind::Age => EncryptRecipients::Age(cli_recipients),
488 BackendKind::Hybrid => {
489 bail!("hybrid requires workspace access lists; use env secret seal")
490 }
491 });
492 }
493
494 match backend {
495 BackendKind::Hybrid => bail!("hybrid requires workspace access lists; use env secret seal"),
496 BackendKind::Gpg => {
497 if config.legacy_gpg_key_id.is_some() {
498 bail!(
499 "gpg_key_id is retired; run `shine state migrate` to convert it to gpg_recipients"
500 );
501 }
502 let recipients = clean_recipients(&config.gpg_recipients);
503 if recipients.is_empty() {
504 bail!(
505 "GPG recipients are required; pass -r/--recipient, set gpg_recipients, or set secret_backend/age_recipients for age"
506 );
507 }
508 Ok(EncryptRecipients::Gpg(recipients))
509 }
510 BackendKind::Age => {
511 let recipients = clean_recipients(&config.age_recipients);
512 if recipients.is_empty() {
513 bail!(
514 "age recipients are required; pass -r/--recipient or set age_recipients in config.toml"
515 );
516 }
517 Ok(EncryptRecipients::Age(recipients))
518 }
519 }
520}
521
522pub async fn handle_encrypt(
523 config: &Config,
524 backend: Option<&str>,
525 recipients: &[String],
526 set_key: Option<&str>,
527 from_key: Option<&str>,
528 force: bool,
529) -> Result<()> {
530 use std::io::Read as _;
531
532 let backend = resolve_encrypt_backend(config, backend)?;
533 let recipients = resolve_encrypt_recipients(backend, recipients, config)?;
534 let plaintext = if let Some(key) = from_key {
535 let env = EnvConfig::load_or_init(config).await?;
536 let Some(value) = env.get(key) else {
537 bail!("{key} is not set in the active config [env]");
538 };
539 value.as_bytes().to_vec()
540 } else {
541 let mut input = Vec::new();
542 std::io::stdin()
543 .read_to_end(&mut input)
544 .context("reading secret from stdin")?;
545 input
546 };
547 let encoded = secret::encrypt_secret(&plaintext, &recipients)
548 .await
549 .context("encrypting secret")?;
550 match resolve_env_encrypt_output(set_key, from_key)? {
551 EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {
552 EnvWriteTarget::ConfigToml => {
553 let mut env = EnvConfig::load_or_init(config).await?;
554 env.set(&key, &encoded);
555 env.save(config).await?;
556 println!(
557 "{}",
558 colors::green(&format!(
559 "set {key} = \"{encoded}\" in {}",
560 path_display::format(config.config_path())
561 ))
562 );
563 }
564 EnvWriteTarget::OverrideFile(source) => {
565 crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;
566 println!(
567 "{}",
568 colors::green(&format!(
569 "set {key} = \"{encoded}\" in {}",
570 path_display::format(&source.path)
571 ))
572 );
573 }
574 },
575 EnvEncryptOutput::Print => println!("{encoded}"),
576 }
577 Ok(())
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use crate::config::Config;
584 use tokio::fs;
585
586 async fn make_temp_dir() -> std::path::PathBuf {
587 crate::test_support::make_temp_dir("shine-env-cmd-test").await
588 }
589
590 fn config_in(dir: &std::path::Path) -> Config {
591 crate::test_support::test_config(dir)
592 }
593
594 #[test]
595 fn env_show_redacts_sensitive_values() {
596 assert_eq!(display_env_value("secret", true, false), "<redacted>");
597 assert_eq!(display_env_value("secret", true, true), "secret");
598 assert_eq!(display_env_value("", true, false), "<empty>");
599 assert!(is_sensitive_env_key("MY_API_KEY"));
600 assert!(is_sensitive_env_key("token"));
601 assert!(is_sensitive_env_key("SURGE_SUBSCRIPTION_URL"));
602 assert!(!is_sensitive_env_key("MONKEY"));
603 }
604
605 #[test]
606 fn decrypted_plaintext_is_written_without_an_appended_line_ending() {
607 let mut output = Vec::new();
608 write_decrypted_plaintext(&mut output, "abc").unwrap();
609 assert_eq!(output, b"abc");
610
611 output.clear();
612 write_decrypted_plaintext(&mut output, "abc\n").unwrap();
613 assert_eq!(output, b"abc\n");
614 }
615
616 fn source(kind: EnvOverrideKind, managed: bool) -> EnvOverrideSource {
617 EnvOverrideSource {
618 path: std::path::PathBuf::from("/tmp/shine.env.toml"),
619 kind,
620 is_managed_overlay: managed,
621 }
622 }
623
624 #[test]
625 fn env_source_group_maps_each_layer() {
626 assert_eq!(env_source_group(None), EnvSourceGroup::Config);
627 assert_eq!(
628 env_source_group(Some(&source(EnvOverrideKind::Global, false))),
629 EnvSourceGroup::Global
630 );
631 assert_eq!(
632 env_source_group(Some(&source(EnvOverrideKind::Overlay, false))),
633 EnvSourceGroup::Overlay { managed: false }
634 );
635 assert_eq!(
636 env_source_group(Some(&source(EnvOverrideKind::Overlay, true))),
637 EnvSourceGroup::Overlay { managed: true }
638 );
639 assert_eq!(
640 env_source_group(Some(&source(EnvOverrideKind::Project, false))),
641 EnvSourceGroup::Project
642 );
643 }
644
645 #[test]
646 fn group_env_keys_orders_sections_and_skips_empty() {
647 let global = source(EnvOverrideKind::Global, false);
648 let overlay = source(EnvOverrideKind::Overlay, true);
649 let keys = ["PROJECT_LESS", "FROM_OVERLAY", "FROM_CONFIG", "FROM_GLOBAL"];
651 let groups = group_env_keys(keys.iter().copied(), |key| match key {
652 "FROM_GLOBAL" => Some(&global),
653 "FROM_OVERLAY" => Some(&overlay),
654 _ => None,
655 });
656
657 assert_eq!(
659 groups.iter().map(|(g, _)| *g).collect::<Vec<_>>(),
660 vec![
661 EnvSourceGroup::Config,
662 EnvSourceGroup::Global,
663 EnvSourceGroup::Overlay { managed: true },
664 ]
665 );
666 assert_eq!(groups[0].1, vec!["PROJECT_LESS", "FROM_CONFIG"]);
667 assert_eq!(groups[1].1, vec!["FROM_GLOBAL"]);
668 assert_eq!(groups[2].1, vec!["FROM_OVERLAY"]);
669 }
670
671 #[test]
672 fn group_env_keys_all_config_yields_single_group() {
673 let keys = ["A", "B", "C"];
674 let groups = group_env_keys(keys.iter().copied(), |_| None);
675 assert_eq!(groups.len(), 1);
676 assert_eq!(groups[0].0, EnvSourceGroup::Config);
677 assert_eq!(groups[0].1, vec!["A", "B", "C"]);
678 }
679
680 #[test]
681 fn env_source_group_labels_are_stable() {
682 assert_eq!(EnvSourceGroup::Config.label(), "config.toml");
683 assert_eq!(EnvSourceGroup::Global.label(), "global env file");
684 assert_eq!(
685 EnvSourceGroup::Overlay { managed: false }.label(),
686 "overlay"
687 );
688 assert_eq!(
689 EnvSourceGroup::Overlay { managed: true }.label(),
690 "overlay (managed)"
691 );
692 assert_eq!(EnvSourceGroup::Project.label(), "project env file");
693 }
694
695 #[test]
696 fn env_show_truncates_long_values_to_requested_width() {
697 assert_eq!(truncate_text("abcdefgh", 5), "abcd…");
698 let (value, description) = fit_env_row(
699 "abcdefghijklmnopqrstuvwxyz",
700 "A description that is also fairly long",
701 8,
702 48,
703 );
704 assert!(value.chars().count() <= env_value_width(8, 48));
705 assert!(description.chars().count() <= 48);
706 }
707
708 #[test]
709 fn env_export_uses_alias_as_variable_name() {
710 let value = "secret123";
711 assert_eq!(
712 format_env_export(&shells::ShellType::Zsh, "MY_ALIAS", value),
713 "export MY_ALIAS='secret123'"
714 );
715 }
716
717 #[test]
718 fn env_export_alias_formats_powershell_correctly() {
719 let value = "secret123";
720 assert_eq!(
721 format_env_export(&shells::ShellType::PowerShell, "MY_ALIAS", value),
722 "$env:MY_ALIAS = 'secret123'"
723 );
724 }
725
726 #[tokio::test]
727 async fn env_delete_removes_key_from_saved_config() {
728 let dir = make_temp_dir().await;
729 let mut config = config_in(&dir);
730 config.env.insert("MY_TOKEN".into(), "secret".into());
731 config.save().await.unwrap();
732
733 handle_delete(&config, "MY_TOKEN", false).await.unwrap();
734
735 let contents = fs::read_to_string(config.config_path()).await.unwrap();
736 let parsed: toml::Table = toml::from_str(&contents).unwrap();
737 let env = parsed
738 .get("env")
739 .and_then(|value| value.as_table())
740 .unwrap();
741 assert!(
742 !env.contains_key("MY_TOKEN"),
743 "deleted key should not remain in saved config: {contents}"
744 );
745
746 fs::remove_dir_all(&dir).await.unwrap();
747 }
748
749 #[tokio::test]
750 async fn env_delete_fails_when_key_is_missing() {
751 let dir = make_temp_dir().await;
752 let config = config_in(&dir);
753
754 let err = handle_delete(&config, "MY_TOKEN", false).await.unwrap_err();
755
756 assert!(
757 err.to_string()
758 .contains("MY_TOKEN is not set in the active config [env]"),
759 "error should explain missing key: {err:#}"
760 );
761 fs::remove_dir_all(&dir).await.unwrap();
762 }
763
764 #[test]
765 fn env_export_secret_key_appends_secret_suffix() {
766 assert_eq!(
767 env_export_secret_key("DEEPSEEK_API_KEY"),
768 "DEEPSEEK_API_KEY_SECRET"
769 );
770 assert_eq!(env_export_secret_key("xxx"), "xxx_SECRET");
771 }
772
773 #[test]
774 fn env_export_resolves_secret_when_present() {
775 let mut env = EnvConfig::default();
776 env.set("MY_TOKEN_SECRET", "encrypted");
777
778 assert_eq!(
779 resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
780 EnvExportValue::Secret {
781 key: "MY_TOKEN_SECRET".to_string(),
782 value: "encrypted"
783 }
784 );
785 }
786
787 #[test]
788 fn env_export_falls_back_to_plaintext_value() {
789 let mut env = EnvConfig::default();
790 env.set("MY_TOKEN", "plain");
791
792 assert_eq!(
793 resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
794 EnvExportValue::Plaintext("plain")
795 );
796 }
797
798 #[test]
799 fn env_export_secret_wins_over_plaintext_value() {
800 let mut env = EnvConfig::default();
801 env.set("MY_TOKEN", "plain");
802 env.set("MY_TOKEN_SECRET", "encrypted");
803
804 assert_eq!(
805 resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
806 EnvExportValue::Secret {
807 key: "MY_TOKEN_SECRET".to_string(),
808 value: "encrypted"
809 }
810 );
811 }
812
813 #[test]
814 fn env_export_reports_both_missing_keys() {
815 let env = EnvConfig::default();
816
817 let err = resolve_env_export_value(&env, "MY_TOKEN").unwrap_err();
818
819 assert!(
820 err.to_string()
821 .contains("MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]"),
822 "error should explain both checked keys: {err:#}"
823 );
824 }
825
826 #[test]
827 fn env_export_key_validation_accepts_shell_variable_names() {
828 for key in ["FOO", "_FOO", "foo_123", "A1"] {
829 validate_env_export_key(key).unwrap();
830 }
831 }
832
833 #[test]
834 fn env_export_key_validation_rejects_unsafe_names() {
835 for key in ["", "1FOO", "FOO-BAR", "FOO;BAR", "FOO BAR", "FOO.SECRET"] {
836 assert!(
837 validate_env_export_key(key).is_err(),
838 "key should be rejected: {key}"
839 );
840 }
841 }
842
843 #[test]
844 fn env_export_formats_posix_shell_code_safely() {
845 let value = "abc def'ghi$HOME\nnext; rm -rf /";
846 assert_eq!(
847 format_env_export(&shells::ShellType::Zsh, "TOKEN", value),
848 "export TOKEN='abc def'\\''ghi$HOME\nnext; rm -rf /'"
849 );
850 }
851
852 #[test]
853 fn env_export_formats_fish_shell_code_safely() {
854 let value = "abc def'ghi\\path\nnext; rm -rf /";
855 assert_eq!(
856 format_env_export(&shells::ShellType::Fish, "TOKEN", value),
857 "set -gx TOKEN 'abc def\\'ghi\\\\path\nnext; rm -rf /'"
858 );
859 }
860
861 #[test]
862 fn env_export_formats_powershell_code_safely() {
863 let value = "abc def'ghi$HOME\nnext; Remove-Item /";
864 assert_eq!(
865 format_env_export(&shells::ShellType::PowerShell, "TOKEN", value),
866 "$env:TOKEN = 'abc def''ghi$HOME\nnext; Remove-Item /'"
867 );
868 }
869
870 #[test]
871 fn env_encrypt_output_defaults_from_key_to_secret_key() {
872 assert_eq!(
873 resolve_env_encrypt_output(None, Some("GH_TOKEN")).unwrap(),
874 EnvEncryptOutput::Set("GH_TOKEN_SECRET".to_string())
875 );
876 }
877
878 #[test]
879 fn env_encrypt_output_explicit_set_wins_over_default() {
880 assert_eq!(
881 resolve_env_encrypt_output(Some("CUSTOM_SECRET"), Some("GH_TOKEN")).unwrap(),
882 EnvEncryptOutput::Set("CUSTOM_SECRET".to_string())
883 );
884 }
885
886 #[test]
887 fn env_encrypt_output_prints_stdin_without_set() {
888 assert_eq!(
889 resolve_env_encrypt_output(None, None).unwrap(),
890 EnvEncryptOutput::Print
891 );
892 }
893
894 #[test]
895 fn env_encrypt_output_rejects_invalid_inferred_from_key() {
896 let err = resolve_env_encrypt_output(None, Some("GH-TOKEN")).unwrap_err();
897
898 assert!(
899 err.to_string().contains(
900 "env secret export key must contain only letters, digits, and underscores"
901 ),
902 "error should explain invalid inferred key: {err:#}"
903 );
904 }
905
906 #[test]
907 fn encrypt_backend_cli_wins_over_config() {
908 let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
909 let mut config = config_in(&dir);
910 config.secret_backend = Some("age".to_string());
911
912 assert_eq!(
913 resolve_encrypt_backend(&config, Some("gpg")).unwrap(),
914 BackendKind::Gpg
915 );
916 }
917
918 #[test]
919 fn encrypt_backend_falls_back_to_config() {
920 let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
921 let mut config = config_in(&dir);
922 config.secret_backend = Some("age".to_string());
923
924 assert_eq!(
925 resolve_encrypt_backend(&config, None).unwrap(),
926 BackendKind::Age
927 );
928 }
929
930 #[test]
931 fn encrypt_backend_defaults_to_gpg() {
932 let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
933 let config = config_in(&dir);
934
935 assert_eq!(
936 resolve_encrypt_backend(&config, None).unwrap(),
937 BackendKind::Gpg
938 );
939 }
940
941 #[test]
942 fn encrypt_recipients_cli_wins_over_config_for_gpg() {
943 let dir =
944 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
945 let mut config = config_in(&dir);
946 config.gpg_recipients = vec!["config@example.com".to_string()];
947
948 let recipients =
949 resolve_encrypt_recipients(BackendKind::Gpg, &["cli@example.com".to_string()], &config)
950 .unwrap();
951
952 match recipients {
953 EncryptRecipients::Gpg(values) => assert_eq!(values, vec!["cli@example.com"]),
954 _ => panic!("expected gpg recipients"),
955 }
956 }
957
958 #[test]
959 fn encrypt_recipients_gpg_falls_back_to_config() {
960 let dir =
961 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
962 let mut config = config_in(&dir);
963 config.gpg_recipients = vec![
964 "config@example.com".to_string(),
965 "team@example.com".to_string(),
966 ];
967
968 let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();
969
970 match recipients {
971 EncryptRecipients::Gpg(values) => {
972 assert_eq!(values, vec!["config@example.com", "team@example.com"])
973 }
974 _ => panic!("expected gpg recipients"),
975 }
976 }
977
978 #[test]
979 fn encrypt_recipients_gpg_treats_empty_config_as_missing() {
980 let dir =
981 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
982 let mut config = config_in(&dir);
983 config.gpg_recipients = vec![" ".to_string()];
984
985 let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();
986
987 assert!(
988 err.to_string()
989 .contains("pass -r/--recipient, set gpg_recipients"),
990 "error should explain how to set recipient: {err:#}"
991 );
992 }
993
994 #[test]
995 fn encrypt_recipients_gpg_errors_when_missing() {
996 let dir =
997 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
998 let config = config_in(&dir);
999
1000 let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();
1001
1002 assert!(
1003 err.to_string()
1004 .contains("pass -r/--recipient, set gpg_recipients"),
1005 "error should explain how to set recipient: {err:#}"
1006 );
1007 }
1008
1009 #[test]
1010 fn encrypt_recipients_age_falls_back_to_config() {
1011 let dir =
1012 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1013 let mut config = config_in(&dir);
1014 config.age_recipients = vec!["age1qexample".to_string()];
1015
1016 let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();
1017
1018 match recipients {
1019 EncryptRecipients::Age(values) => assert_eq!(values, vec!["age1qexample"]),
1020 _ => panic!("expected age recipients"),
1021 }
1022 }
1023
1024 #[test]
1025 fn encrypt_recipients_age_errors_when_missing() {
1026 let dir =
1027 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1028 let config = config_in(&dir);
1029
1030 let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();
1031
1032 assert!(
1033 err.to_string().contains("age recipients are required"),
1034 "error should explain how to set age recipients: {err:#}"
1035 );
1036 }
1037
1038 #[test]
1039 fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {
1040 let dir =
1041 std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1042 let config = config_in(&dir);
1043
1044 let err =
1045 resolve_encrypt_recipients(BackendKind::Gpg, &["age1qexample".to_string()], &config)
1046 .unwrap_err();
1047
1048 assert!(
1049 err.to_string().contains("did you mean --backend age"),
1050 "error should hint at the age backend: {err:#}"
1051 );
1052 }
1053
1054 fn shadow_key(
1055 config: &mut Config,
1056 key: &str,
1057 path: std::path::PathBuf,
1058 is_managed_overlay: bool,
1059 ) {
1060 let kind = if is_managed_overlay {
1061 crate::config::EnvOverrideKind::Overlay
1062 } else {
1063 crate::config::EnvOverrideKind::Global
1064 };
1065 config.env_override_sources.insert(
1066 key.to_string(),
1067 crate::config::EnvOverrideSource {
1068 path,
1069 kind,
1070 is_managed_overlay,
1071 },
1072 );
1073 }
1074
1075 #[test]
1076 fn resolve_env_write_target_returns_config_toml_when_unshadowed() {
1077 let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1078 let config = config_in(&dir);
1079
1080 let target = resolve_env_write_target(&config, "MY_TOKEN", false).unwrap();
1081
1082 assert!(matches!(target, EnvWriteTarget::ConfigToml));
1083 }
1084
1085 #[test]
1086 fn resolve_env_write_target_refuses_without_force_when_shadowed() {
1087 let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1088 let mut config = config_in(&dir);
1089 let override_path = dir.join("shine.env.toml");
1090 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1091
1092 let err = resolve_env_write_target(&config, "MY_TOKEN", false).unwrap_err();
1093
1094 assert!(
1095 err.to_string()
1096 .contains(&crate::path_display::format(&override_path)),
1097 "error should name the winning override file: {err:#}"
1098 );
1099 assert!(
1100 err.to_string().contains("--force"),
1101 "error should hint at --force: {err:#}"
1102 );
1103 }
1104
1105 #[test]
1106 fn resolve_env_write_target_returns_override_file_with_force() {
1107 let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1108 let mut config = config_in(&dir);
1109 let override_path = dir.join("shine.env.toml");
1110 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1111
1112 let target = resolve_env_write_target(&config, "MY_TOKEN", true).unwrap();
1113
1114 match target {
1115 EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),
1116 EnvWriteTarget::ConfigToml => panic!("expected the shadowing override file"),
1117 }
1118 }
1119
1120 #[test]
1121 fn resolve_env_write_target_allows_managed_overlay_with_force() {
1122 let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1123 let mut config = config_in(&dir);
1124 let overlay_path = dir.join("overlay").join("shine.env.toml");
1125 shadow_key(&mut config, "MY_TOKEN", overlay_path.clone(), true);
1126
1127 let target = resolve_env_write_target(&config, "MY_TOKEN", true).unwrap();
1128
1129 match target {
1130 EnvWriteTarget::OverrideFile(source) => {
1131 assert_eq!(source.path, overlay_path);
1132 assert!(source.is_managed_overlay);
1133 }
1134 EnvWriteTarget::ConfigToml => panic!("expected the managed overlay override file"),
1135 }
1136 }
1137
1138 #[tokio::test]
1139 async fn env_set_refuses_when_shadowed_without_force() {
1140 let dir = make_temp_dir().await;
1141 let mut config = config_in(&dir);
1142 let override_path = dir.join("shine.env.toml");
1143 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1144
1145 let err = handle_set(&config, "MY_TOKEN", "newval", false)
1146 .await
1147 .unwrap_err();
1148
1149 assert!(
1150 err.to_string()
1151 .contains(&crate::path_display::format(&override_path))
1152 );
1153 assert!(
1154 !fs::try_exists(&override_path).await.unwrap(),
1155 "refused write must not touch the override file"
1156 );
1157 assert!(
1158 !fs::read_to_string(config.config_path())
1159 .await
1160 .unwrap_or_default()
1161 .contains("MY_TOKEN"),
1162 "refused write must not touch config.toml either"
1163 );
1164
1165 fs::remove_dir_all(&dir).await.unwrap();
1166 }
1167
1168 #[tokio::test]
1169 async fn env_set_writes_into_override_file_when_forced() {
1170 let dir = make_temp_dir().await;
1171 let mut config = config_in(&dir);
1172 let override_path = dir.join("shine.env.toml");
1173 fs::write(&override_path, "MY_TOKEN = \"old\"\n")
1174 .await
1175 .unwrap();
1176 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1177
1178 handle_set(&config, "MY_TOKEN", "newval", true)
1179 .await
1180 .unwrap();
1181
1182 let content = fs::read_to_string(&override_path).await.unwrap();
1183 assert!(content.contains("MY_TOKEN = \"newval\""));
1184 assert!(
1185 !fs::read_to_string(config.config_path())
1186 .await
1187 .unwrap_or_default()
1188 .contains("MY_TOKEN"),
1189 "forced write must go into the override file, not config.toml"
1190 );
1191
1192 fs::remove_dir_all(&dir).await.unwrap();
1193 }
1194
1195 #[tokio::test]
1196 async fn env_delete_refuses_when_shadowed_without_force() {
1197 let dir = make_temp_dir().await;
1198 let mut config = config_in(&dir);
1199 let override_path = dir.join("shine.env.toml");
1200 fs::write(&override_path, "MY_TOKEN = \"secret\"\n")
1201 .await
1202 .unwrap();
1203 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1204
1205 let err = handle_delete(&config, "MY_TOKEN", false).await.unwrap_err();
1206
1207 assert!(
1208 err.to_string()
1209 .contains(&crate::path_display::format(&override_path))
1210 );
1211 let content = fs::read_to_string(&override_path).await.unwrap();
1212 assert!(
1213 content.contains("MY_TOKEN"),
1214 "refused delete must leave the override file untouched"
1215 );
1216
1217 fs::remove_dir_all(&dir).await.unwrap();
1218 }
1219
1220 #[tokio::test]
1221 async fn env_delete_removes_from_override_file_when_forced() {
1222 let dir = make_temp_dir().await;
1223 let mut config = config_in(&dir);
1224 let override_path = dir.join("shine.env.toml");
1225 fs::write(&override_path, "MY_TOKEN = \"secret\"\nOTHER = \"kept\"\n")
1226 .await
1227 .unwrap();
1228 shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1229
1230 handle_delete(&config, "MY_TOKEN", true).await.unwrap();
1231
1232 let content = fs::read_to_string(&override_path).await.unwrap();
1233 let table: toml::Table = toml::from_str(&content).unwrap();
1234 assert!(!table.contains_key("MY_TOKEN"));
1235 assert!(table.contains_key("OTHER"));
1236
1237 fs::remove_dir_all(&dir).await.unwrap();
1238 }
1239}