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