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