1use std::fmt;
32use std::io::Read;
33use std::path::{Path, PathBuf};
34
35use thiserror::Error;
36
37use crate::utils::env::EnvSource;
38use crate::utils::secret::Secret;
39
40pub const FILE_SUFFIX: &str = "_FILE";
42
43pub const SECRET_ENV_VARS: &[&str] = &[
50 "ANTHROPIC_API_KEY",
52 "CLAUDE_API_KEY",
53 "ANTHROPIC_AUTH_TOKEN",
54 "OPENAI_API_KEY",
55 "OPENAI_AUTH_TOKEN",
56 "TYPESAFE_API_KEY",
58 "OMNI_DEV_JEV_API_KEY",
59 "ATLASSIAN_API_TOKEN",
61 "DATADOG_API_KEY",
63 "DATADOG_APP_KEY",
64 "GMAIL_REFRESH_TOKEN",
66 "DRIVE_CLIENT_SECRET",
68 "DRIVE_REFRESH_TOKEN",
69 "SNOWFLAKE_TOKEN",
71 "SNOWFLAKE_PRIVATE_KEY",
72 "OMNI_BRIDGE_TOKEN",
74];
75
76pub const EXEMPT_SECRET_ENV_VARS: &[(&str, &str)] = &[
80 (
81 "GMAIL_CLIENT_SECRET",
82 "GMAIL_CLIENT_SECRET_FILE already names a Google client_secret.json for \
83 `gmail auth import`; an installed-app OAuth client secret is not \
84 confidential, and that command already reads it from a file (ADR-0089)",
85 ),
86 (
87 "SNOWFLAKE_PRIVATE_KEY_PASSPHRASE",
88 "only read to reject encrypted keys, which are unsupported; include it \
89 once encrypted keys are supported (#2006)",
90 ),
91 (
92 "PROGRAMMATIC_ACCESS_TOKEN",
93 "a Snowflake authenticator wire value, not an environment variable",
94 ),
95];
96
97#[derive(Debug, Error)]
100pub enum SecretEnvError {
101 #[error("both {name} and {file_var} are set; set only one of them")]
103 Conflict {
104 name: String,
106 file_var: String,
108 },
109
110 #[error("{file_var} must be an absolute path, got '{}'", path.display())]
112 RelativePath {
113 file_var: String,
115 path: PathBuf,
117 },
118
119 #[error("cannot read the secret file {} named by {file_var}: {source}", path.display())]
121 Unreadable {
122 file_var: String,
124 path: PathBuf,
126 source: std::io::Error,
128 },
129
130 #[error("cannot write the secret file {} named by {file_var}: {source}", path.display())]
132 Unwritable {
133 file_var: String,
135 path: PathBuf,
137 source: std::io::Error,
139 },
140
141 #[error(
144 "the secret file {} named by {file_var} holds JSON, not a bare secret; refusing to \
145 replace it. Point {file_var} at a file of its own",
146 path.display()
147 )]
148 HoldsJson {
149 file_var: String,
151 path: PathBuf,
153 },
154
155 #[error("the secret file {} named by {file_var} is not a regular file", path.display())]
157 NotAFile {
158 file_var: String,
160 path: PathBuf,
162 },
163
164 #[error("the secret file {} named by {file_var} is empty", path.display())]
166 Empty {
167 file_var: String,
169 path: PathBuf,
171 },
172
173 #[error("the secret file {} named by {file_var} is not valid UTF-8", path.display())]
175 NotUtf8 {
176 file_var: String,
178 path: PathBuf,
180 },
181
182 #[error(
185 "the secret file {path} named by {file_var} has mode {mode}, which grants group or \
186 other access; restrict it with `chmod 600 {path}`",
187 path = path.display(),
188 mode = OctalMode(*mode)
189 )]
190 LoosePermissions {
191 file_var: String,
193 path: PathBuf,
195 mode: u32,
197 },
198
199 #[error(
201 "the secret file {path} named by {file_var} is owned by root but has mode {mode}, \
202 which lets group or other users write it; remove that with `chmod go-w {path}`",
203 path = path.display(),
204 mode = OctalMode(*mode)
205 )]
206 WritableByOthers {
207 file_var: String,
209 path: PathBuf,
211 mode: u32,
213 },
214
215 #[error(
217 "the secret file {} named by {file_var} is owned by uid {owner}, which is neither \
218 the current user (uid {expected}) nor root; take ownership of it (or copy it) and \
219 `chmod 600` it",
220 path.display()
221 )]
222 WrongOwner {
223 file_var: String,
225 path: PathBuf,
227 owner: u32,
229 expected: u32,
231 },
232}
233
234struct OctalMode(u32);
236
237impl fmt::Display for OctalMode {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 write!(f, "{:04o}", self.0)
240 }
241}
242
243#[must_use]
245pub fn file_var_name(name: &str) -> String {
246 format!("{name}{FILE_SUFFIX}")
247}
248
249pub fn secret_var(env: &impl EnvSource, name: &str) -> Result<Option<Secret>, SecretEnvError> {
258 debug_assert!(
259 SECRET_ENV_VARS.contains(&name),
260 "{name} must be registered in SECRET_ENV_VARS"
261 );
262 let file_var = file_var_name(name);
263 let (value, file) = env.var_pair(name, &file_var);
264 resolve_secret_pair(name, value.as_deref(), &file_var, file.as_deref())
265}
266
267pub fn resolve_secret_pair(
281 name: &str,
282 value: Option<&str>,
283 file_var: &str,
284 file: Option<&str>,
285) -> Result<Option<Secret>, SecretEnvError> {
286 let value = value.filter(|v| !v.is_empty());
287 let file = file.filter(|v| !v.is_empty());
288 match (value, file) {
289 (Some(_), Some(_)) => Err(SecretEnvError::Conflict {
290 name: name.to_string(),
291 file_var: file_var.to_string(),
292 }),
293 (Some(value), None) => Ok(Some(Secret::new(value))),
294 (None, Some(path)) => read_secret_file(file_var, Path::new(path)).map(Some),
295 (None, None) => Ok(None),
296 }
297}
298
299pub fn secret_var_any(
306 env: &impl EnvSource,
307 names: &[&str],
308) -> Result<Option<Secret>, SecretEnvError> {
309 for name in names {
310 if let Some(secret) = secret_var(env, name)? {
311 return Ok(Some(secret));
312 }
313 }
314 Ok(None)
315}
316
317#[must_use]
320pub fn secret_var_is_set(env: &impl EnvSource, name: &str) -> bool {
321 debug_assert!(
322 SECRET_ENV_VARS.contains(&name),
323 "{name} must be registered in SECRET_ENV_VARS"
324 );
325 let (value, file) = env.var_pair(name, &file_var_name(name));
326 value.is_some_and(|v| !v.is_empty()) || file.is_some_and(|v| !v.is_empty())
327}
328
329pub fn read_secret_file(file_var: &str, path: &Path) -> Result<Secret, SecretEnvError> {
339 if !path.is_absolute() {
340 return Err(SecretEnvError::RelativePath {
341 file_var: file_var.to_string(),
342 path: path.to_path_buf(),
343 });
344 }
345 let unreadable = |source| SecretEnvError::Unreadable {
346 file_var: file_var.to_string(),
347 path: path.to_path_buf(),
348 source,
349 };
350 check_metadata(
353 file_var,
354 path,
355 &std::fs::metadata(path).map_err(unreadable)?,
356 )?;
357 let mut file = std::fs::File::open(path).map_err(unreadable)?;
358 check_metadata(file_var, path, &file.metadata().map_err(unreadable)?)?;
359 let mut bytes = Vec::new();
360 file.read_to_end(&mut bytes).map_err(unreadable)?;
361 let mut text = String::from_utf8(bytes).map_err(|_| SecretEnvError::NotUtf8 {
362 file_var: file_var.to_string(),
363 path: path.to_path_buf(),
364 })?;
365 trim_one_newline(&mut text);
366 if text.is_empty() {
367 return Err(SecretEnvError::Empty {
368 file_var: file_var.to_string(),
369 path: path.to_path_buf(),
370 });
371 }
372 Ok(Secret::new(text))
373}
374
375pub fn write_secret_file(
385 file_var: &str,
386 path: &Path,
387 secret: &Secret,
388) -> Result<bool, SecretEnvError> {
389 match plan_secret_file_write(file_var, path, secret)? {
390 Some(write) => write.write(secret).map(|()| true),
391 None => Ok(false),
392 }
393}
394
395#[derive(Debug)]
398pub struct SecretFileWrite {
399 file_var: String,
400 path: PathBuf,
401 target: PathBuf,
402 replaces_existing: bool,
403}
404
405pub fn plan_secret_file_write(
432 file_var: &str,
433 path: &Path,
434 secret: &Secret,
435) -> Result<Option<SecretFileWrite>, SecretEnvError> {
436 if !path.is_absolute() {
437 return Err(SecretEnvError::RelativePath {
438 file_var: file_var.to_string(),
439 path: path.to_path_buf(),
440 });
441 }
442 let unwritable = |source| SecretEnvError::Unwritable {
443 file_var: file_var.to_string(),
444 path: path.to_path_buf(),
445 source,
446 };
447 let (target, replaces_existing) = match std::fs::symlink_metadata(path) {
448 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (path.to_path_buf(), false),
449 Err(e) => return Err(unwritable(e)),
450 Ok(_) => {
451 match read_secret_file(file_var, path) {
452 Ok(current) if current.expose_secret() == secret.expose_secret() => {
453 return Ok(None)
454 }
455 Ok(current) if current.expose_secret().trim_start().starts_with('{') => {
456 return Err(SecretEnvError::HoldsJson {
457 file_var: file_var.to_string(),
458 path: path.to_path_buf(),
459 })
460 }
461 Ok(_) | Err(SecretEnvError::Empty { .. }) => {}
462 Err(e) => return Err(e),
463 }
464 (std::fs::canonicalize(path).map_err(unwritable)?, true)
465 }
466 };
467 if !target.parent().is_some_and(Path::is_dir) {
468 return Err(unwritable(std::io::Error::new(
469 std::io::ErrorKind::NotFound,
470 "its directory does not exist",
471 )));
472 }
473 Ok(Some(SecretFileWrite {
474 file_var: file_var.to_string(),
475 path: path.to_path_buf(),
476 target,
477 replaces_existing,
478 }))
479}
480
481impl SecretFileWrite {
482 #[must_use]
484 pub fn replaces_existing(&self) -> bool {
485 self.replaces_existing
486 }
487
488 pub fn write(self, secret: &Secret) -> Result<(), SecretEnvError> {
496 use std::io::Write;
497 let unwritable = |source| SecretEnvError::Unwritable {
498 file_var: self.file_var.clone(),
499 path: self.path.clone(),
500 source,
501 };
502 let dir = self.target.parent().unwrap_or(Path::new("/"));
503 let mut temp = tempfile::Builder::new()
506 .prefix(".omni-dev-secret-")
507 .tempfile_in(dir)
508 .map_err(unwritable)?;
509 temp.write_all(secret.expose_secret().as_bytes())
510 .and_then(|()| temp.write_all(b"\n"))
511 .and_then(|()| temp.as_file().sync_all())
512 .map_err(unwritable)?;
513 temp.persist(&self.target)
514 .map_err(|e| unwritable(e.error))?;
515 Ok(())
516 }
517}
518
519fn trim_one_newline(text: &mut String) {
522 if text.ends_with("\r\n") {
523 text.truncate(text.len() - 2);
524 } else if text.ends_with('\n') {
525 text.truncate(text.len() - 1);
526 }
527}
528
529fn check_metadata(
531 file_var: &str,
532 path: &Path,
533 metadata: &std::fs::Metadata,
534) -> Result<(), SecretEnvError> {
535 if !metadata.is_file() {
536 return Err(SecretEnvError::NotAFile {
537 file_var: file_var.to_string(),
538 path: path.to_path_buf(),
539 });
540 }
541 #[cfg(unix)]
542 {
543 use std::os::unix::fs::MetadataExt;
544 check_unix_security(
545 file_var,
546 path,
547 metadata.mode(),
548 metadata.uid(),
549 nix::unistd::geteuid().as_raw(),
550 )?;
551 }
552 Ok(())
553}
554
555#[cfg(unix)]
557const ROOT_UID: u32 = 0;
558
559#[cfg(unix)]
572fn check_unix_security(
573 file_var: &str,
574 path: &Path,
575 mode: u32,
576 owner: u32,
577 euid: u32,
578) -> Result<(), SecretEnvError> {
579 let mode = mode & 0o777;
580 if owner == ROOT_UID {
581 if mode & 0o022 != 0 {
582 return Err(SecretEnvError::WritableByOthers {
583 file_var: file_var.to_string(),
584 path: path.to_path_buf(),
585 mode,
586 });
587 }
588 return Ok(());
589 }
590 if owner != euid {
591 return Err(SecretEnvError::WrongOwner {
592 file_var: file_var.to_string(),
593 path: path.to_path_buf(),
594 owner,
595 expected: euid,
596 });
597 }
598 if mode & 0o077 != 0 {
599 return Err(SecretEnvError::LoosePermissions {
600 file_var: file_var.to_string(),
601 path: path.to_path_buf(),
602 mode,
603 });
604 }
605 Ok(())
606}
607
608#[cfg(test)]
609#[allow(clippy::unwrap_used, clippy::expect_used)]
610mod tests {
611 use super::*;
612 use crate::test_support::env::MapEnv;
613
614 const NAME: &str = "DATADOG_API_KEY";
615 const FILE_VAR: &str = "DATADOG_API_KEY_FILE";
616 const SECRET_BYTES: &str = "sekret-bytes-never-logged";
617
618 fn secret_file(contents: &[u8], mode: u32) -> (tempfile::TempDir, PathBuf) {
620 let dir = tempfile::tempdir().unwrap();
621 let path = dir.path().join("secret");
622 std::fs::write(&path, contents).unwrap();
623 set_mode(&path, mode);
624 (dir, path)
625 }
626
627 #[cfg(unix)]
628 fn set_mode(path: &Path, mode: u32) {
629 use std::os::unix::fs::PermissionsExt;
630 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
631 }
632
633 #[cfg(not(unix))]
634 fn set_mode(_path: &Path, _mode: u32) {}
635
636 fn env_with_file(path: &Path) -> MapEnv {
637 MapEnv::new().with(FILE_VAR, path.to_str().unwrap())
638 }
639
640 fn resolve(env: &MapEnv) -> Result<Option<String>, SecretEnvError> {
641 secret_var(env, NAME).map(|s| s.map(|s| s.expose_secret().to_string()))
642 }
643
644 #[test]
645 fn only_the_variable_is_used_verbatim() {
646 let env = MapEnv::new().with(NAME, " value with spaces \n");
647 assert_eq!(
648 resolve(&env).unwrap().as_deref(),
649 Some(" value with spaces \n")
650 );
651 }
652
653 #[test]
654 fn only_the_file_is_read() {
655 let (_dir, path) = secret_file(b"from-file\n", 0o600);
656 assert_eq!(
657 resolve(&env_with_file(&path)).unwrap().as_deref(),
658 Some("from-file")
659 );
660 }
661
662 #[test]
663 fn neither_set_is_none() {
664 assert!(resolve(&MapEnv::new()).unwrap().is_none());
665 }
666
667 #[test]
668 fn empty_values_count_as_unset() {
669 let env = MapEnv::new().with(NAME, "").with(FILE_VAR, "");
670 assert!(resolve(&env).unwrap().is_none());
671 assert!(!secret_var_is_set(&env, NAME));
672 let (_dir, path) = secret_file(b"f", 0o600);
674 let env = env_with_file(&path).with(NAME, "");
675 assert_eq!(resolve(&env).unwrap().as_deref(), Some("f"));
676 }
677
678 #[test]
679 fn both_set_is_a_conflict_naming_both() {
680 let (_dir, path) = secret_file(b"f", 0o600);
681 let env = env_with_file(&path).with(NAME, "v");
682 let err = resolve(&env).unwrap_err();
683 assert!(matches!(err, SecretEnvError::Conflict { .. }));
684 let msg = err.to_string();
685 assert!(msg.contains(NAME) && msg.contains(FILE_VAR), "{msg}");
686 }
687
688 #[test]
689 fn empty_file_is_an_error_not_unset() {
690 for contents in [&b""[..], b"\n", b"\r\n"] {
691 let (_dir, path) = secret_file(contents, 0o600);
692 let err = resolve(&env_with_file(&path)).unwrap_err();
693 assert!(matches!(err, SecretEnvError::Empty { .. }), "{contents:?}");
694 }
695 }
696
697 #[test]
698 fn missing_file_is_unreadable_and_names_the_path() {
699 let dir = tempfile::tempdir().unwrap();
700 let path = dir.path().join("absent");
701 let err = resolve(&env_with_file(&path)).unwrap_err();
702 assert!(matches!(err, SecretEnvError::Unreadable { .. }));
703 assert!(err.to_string().contains(path.to_str().unwrap()));
704 assert!(err.to_string().contains(FILE_VAR));
705 }
706
707 #[test]
708 fn a_directory_is_not_a_file() {
709 let dir = tempfile::tempdir().unwrap();
710 let err = resolve(&env_with_file(dir.path())).unwrap_err();
711 assert!(matches!(err, SecretEnvError::NotAFile { .. }));
712 }
713
714 #[test]
715 fn relative_path_is_rejected() {
716 let env = MapEnv::new().with(FILE_VAR, "secrets/key");
717 let err = resolve(&env).unwrap_err();
718 assert!(matches!(err, SecretEnvError::RelativePath { .. }));
719 assert!(err.to_string().contains("absolute"));
720 }
721
722 #[test]
723 fn trims_exactly_one_trailing_crlf() {
724 let (_dir, path) = secret_file(b"crlf\r\n", 0o600);
725 assert_eq!(
726 resolve(&env_with_file(&path)).unwrap().as_deref(),
727 Some("crlf")
728 );
729 }
730
731 #[test]
732 fn trims_only_one_newline_and_keeps_other_whitespace() {
733 let (_dir, path) = secret_file(b" in ner \t\n\n", 0o600);
734 assert_eq!(
735 resolve(&env_with_file(&path)).unwrap().as_deref(),
736 Some(" in ner \t\n")
737 );
738 }
739
740 #[test]
741 fn non_utf8_is_rejected() {
742 let (_dir, path) = secret_file(&[0xff, 0xfe, b'x'], 0o600);
743 let err = resolve(&env_with_file(&path)).unwrap_err();
744 assert!(matches!(err, SecretEnvError::NotUtf8 { .. }));
745 }
746
747 #[cfg(unix)]
748 #[test]
749 fn mode_0600_and_0400_are_accepted() {
750 for mode in [0o600, 0o400] {
751 let (_dir, path) = secret_file(b"ok", mode);
752 assert_eq!(
753 resolve(&env_with_file(&path)).unwrap().as_deref(),
754 Some("ok")
755 );
756 }
757 }
758
759 #[cfg(unix)]
760 #[test]
761 fn mode_0644_is_rejected_with_the_mode_and_the_fix() {
762 let (_dir, path) = secret_file(SECRET_BYTES.as_bytes(), 0o644);
763 let err = resolve(&env_with_file(&path)).unwrap_err();
764 assert!(matches!(
765 err,
766 SecretEnvError::LoosePermissions { mode: 0o644, .. }
767 ));
768 let msg = err.to_string();
769 assert!(msg.contains("0644"), "{msg}");
770 assert!(
771 msg.contains(&format!("chmod 600 {}", path.display())),
772 "{msg}"
773 );
774 assert!(!msg.contains(SECRET_BYTES), "{msg}");
775 }
776
777 #[cfg(unix)]
778 #[test]
779 fn any_group_or_other_bit_is_rejected() {
780 for mode in [0o640, 0o604, 0o610, 0o601] {
781 let (_dir, path) = secret_file(b"x", mode);
782 assert!(
784 matches!(
785 resolve(&env_with_file(&path)).unwrap_err(),
786 SecretEnvError::LoosePermissions { .. }
787 ),
788 "{mode:o}"
789 );
790 }
792 }
793
794 #[cfg(unix)]
795 #[test]
796 fn another_users_file_is_rejected_by_the_pure_check() {
797 let path = Path::new("/home/other/key");
798 let err = check_unix_security(FILE_VAR, path, 0o100_600, 502, 501).unwrap_err();
799 assert!(matches!(
800 err,
801 SecretEnvError::WrongOwner {
802 owner: 502,
803 expected: 501,
804 ..
805 }
806 ));
807 let msg = err.to_string();
808 assert!(msg.contains("uid 502") && msg.contains("uid 501"), "{msg}");
809 assert!(check_unix_security(FILE_VAR, path, 0o100_600, 501, 501).is_ok());
811 }
812
813 #[cfg(unix)]
817 #[test]
818 fn root_owned_read_only_secrets_are_accepted() {
819 let path = Path::new("/run/secrets/key");
820 for mode in [0o100_400, 0o100_440, 0o100_444, 0o100_600, 0o100_644] {
821 for euid in [0, 501] {
822 assert!(
823 check_unix_security(FILE_VAR, path, mode, ROOT_UID, euid).is_ok(),
824 "mode {mode:o} euid {euid}"
825 );
826 }
827 }
828 }
829
830 #[cfg(unix)]
831 #[test]
832 fn root_owned_secrets_writable_by_others_are_rejected() {
833 let path = Path::new("/run/secrets/key");
834 for mode in [0o100_664, 0o100_646, 0o100_666, 0o100_620] {
835 let err = check_unix_security(FILE_VAR, path, mode, ROOT_UID, 501).unwrap_err();
836 assert!(
837 matches!(err, SecretEnvError::WritableByOthers { .. }),
838 "mode {mode:o}"
839 );
840 let msg = err.to_string();
841 assert!(msg.contains("chmod go-w /run/secrets/key"), "{msg}");
842 }
843 }
844
845 #[cfg(unix)]
848 #[test]
849 fn root_process_still_rejects_group_writable_root_files() {
850 let path = Path::new("/run/secrets/key");
851 assert!(matches!(
852 check_unix_security(FILE_VAR, path, 0o100_660, ROOT_UID, ROOT_UID).unwrap_err(),
853 SecretEnvError::WritableByOthers { .. }
854 ));
855 }
856
857 #[cfg(unix)]
858 #[test]
859 fn symlinks_are_followed_and_the_target_is_checked() {
860 let (dir, target) = secret_file(b"via-link\n", 0o600);
861 let link = dir.path().join("link");
862 std::os::unix::fs::symlink(&target, &link).unwrap();
863 assert_eq!(
864 resolve(&env_with_file(&link)).unwrap().as_deref(),
865 Some("via-link")
866 );
867
868 set_mode(&target, 0o644);
869 assert!(matches!(
870 resolve(&env_with_file(&link)).unwrap_err(),
871 SecretEnvError::LoosePermissions { mode: 0o644, .. }
872 ));
873 }
874
875 #[test]
876 fn no_error_message_contains_the_secret_bytes() {
877 let (_dir, path) = secret_file(SECRET_BYTES.as_bytes(), 0o600);
878 let env = env_with_file(&path).with(NAME, SECRET_BYTES);
879 let err = resolve(&env).unwrap_err();
880 assert!(!err.to_string().contains(SECRET_BYTES));
881 assert!(!format!("{err:?}").contains(SECRET_BYTES));
882 }
883
884 #[test]
885 fn aliases_keep_precedence_and_only_conflict_within_a_pair() {
886 let (_dir, path) = secret_file(b"from-file", 0o600);
887 let names = ["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"];
888
889 let env = MapEnv::new()
891 .with("OPENAI_API_KEY_FILE", path.to_str().unwrap())
892 .with("OPENAI_AUTH_TOKEN", "b");
893 let got = secret_var_any(&env, &names).unwrap().unwrap();
894 assert_eq!(got.expose_secret(), "from-file");
895
896 let env = MapEnv::new().with("OPENAI_AUTH_TOKEN_FILE", path.to_str().unwrap());
898 let got = secret_var_any(&env, &names).unwrap().unwrap();
899 assert_eq!(got.expose_secret(), "from-file");
900
901 let env = MapEnv::new()
903 .with("OPENAI_API_KEY", "a")
904 .with("OPENAI_API_KEY_FILE", path.to_str().unwrap());
905 assert!(matches!(
906 secret_var_any(&env, &names).unwrap_err(),
907 SecretEnvError::Conflict { .. }
908 ));
909
910 assert!(secret_var_any(&MapEnv::new(), &names).unwrap().is_none());
911 }
912
913 #[test]
914 fn is_set_does_not_read_the_file() {
915 let env = MapEnv::new().with(FILE_VAR, "/definitely/not/there");
916 assert!(secret_var_is_set(&env, NAME));
917 assert!(secret_var_is_set(&MapEnv::new().with(NAME, "v"), NAME));
918 assert!(!secret_var_is_set(&MapEnv::new(), NAME));
919 }
920
921 #[test]
922 fn registry_and_exemptions_are_disjoint_and_unique() {
923 let mut seen = std::collections::BTreeSet::new();
924 for name in SECRET_ENV_VARS
925 .iter()
926 .chain(EXEMPT_SECRET_ENV_VARS.iter().map(|(n, _)| n))
927 {
928 assert!(seen.insert(*name), "{name} is listed twice");
929 assert!(!name.ends_with(FILE_SUFFIX), "{name}");
930 }
931 }
932
933 struct Source {
938 rel: String,
939 code: String,
940 }
941
942 fn production_sources() -> Vec<Source> {
943 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
944 for entry in std::fs::read_dir(dir).unwrap() {
945 let path = entry.unwrap().path();
946 if path.is_dir() {
947 walk(&path, out);
948 } else if path.extension().is_some_and(|e| e == "rs") {
949 out.push(path);
950 }
951 }
952 }
953 let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
954 let mut files = Vec::new();
955 walk(&src, &mut files);
956 files
957 .into_iter()
958 .filter_map(|path| {
959 let rel = path
960 .strip_prefix(&src)
961 .unwrap()
962 .to_string_lossy()
963 .replace('\\', "/");
964 let name = path.file_name().unwrap().to_string_lossy();
965 let is_test_file = rel == "test_support.rs"
966 || name == "tests.rs"
967 || name.ends_with("_tests.rs")
968 || rel.contains("/tests/");
969 if is_test_file {
970 return None;
971 }
972 let text = std::fs::read_to_string(&path).unwrap();
973 let code = strip_test_modules(&rel, &text)
974 .lines()
975 .filter(|l| !l.trim_start().starts_with("//"))
976 .collect::<Vec<_>>()
977 .join("\n");
978 Some(Source { rel, code })
979 })
980 .collect()
981 }
982
983 fn strip_test_modules(rel: &str, text: &str) -> String {
988 let mut out = String::new();
989 let mut rest = text;
990 while let Some(at) = rest.find("#[cfg(test)]") {
991 out.push_str(&rest[..at]);
992 let after = &rest[at..];
993 let brace = after.find('{');
994 let semi = after.find(';');
995 let mut item = after["#[cfg(test)]".len()..].trim_start();
997 while item.starts_with("#[") {
998 item = item.find('\n').map_or("", |nl| item[nl..].trim_start());
999 }
1000 let is_mod_block = (item.starts_with("mod ") || item.starts_with("pub(crate) mod "))
1001 && brace.is_some_and(|b| semi.is_none_or(|s| b < s));
1002 if !is_mod_block {
1003 out.push_str("#[cfg(test)]");
1004 rest = &after["#[cfg(test)]".len()..];
1005 continue;
1006 }
1007 let start = brace.unwrap();
1008 let end = matching_brace(&after[start..])
1009 .unwrap_or_else(|| panic!("{rel}: unbalanced #[cfg(test)] module"));
1010 rest = &after[start + end + 1..];
1011 }
1012 out.push_str(rest);
1013 out
1014 }
1015
1016 fn matching_brace(text: &str) -> Option<usize> {
1020 let b = text.as_bytes();
1021 let mut depth = 0usize;
1022 let mut i = 0;
1023 while i < b.len() {
1024 match b[i] {
1025 b'/' if b.get(i + 1) == Some(&b'/') => {
1026 i = text[i..].find('\n').map_or(b.len(), |n| i + n);
1027 continue;
1028 }
1029 b'/' if b.get(i + 1) == Some(&b'*') => {
1030 i = text[i + 2..].find("*/").map_or(b.len(), |n| i + 2 + n + 2);
1031 continue;
1032 }
1033 b'r' if !is_ident_byte(b, i) && matches!(b.get(i + 1), Some(b'"' | b'#')) => {
1034 let hashes = b[i + 1..].iter().take_while(|&&c| c == b'#').count();
1035 if b.get(i + 1 + hashes) == Some(&b'"') {
1036 let close = format!("\"{}", "#".repeat(hashes));
1037 let body = i + 2 + hashes;
1038 i = text[body..]
1039 .find(&close)
1040 .map_or(b.len(), |n| body + n + close.len());
1041 continue;
1042 }
1043 }
1044 b'"' => {
1045 i += 1;
1046 while i < b.len() && b[i] != b'"' {
1047 i += if b[i] == b'\\' { 2 } else { 1 };
1048 }
1049 }
1050 b'\'' => {
1051 if b.get(i + 1) == Some(&b'\\') {
1054 if let Some(n) = text[i + 2..].find('\'') {
1055 i += 2 + n;
1056 }
1057 } else if let Some(c) = text[i + 1..].chars().next() {
1058 if text[i + 1 + c.len_utf8()..].starts_with('\'') {
1059 i += 1 + c.len_utf8();
1060 }
1061 } }
1063 b'{' => depth += 1,
1064 b'}' => {
1065 depth = depth.checked_sub(1)?;
1066 if depth == 0 {
1067 return Some(i);
1068 }
1069 }
1070 _ => {}
1071 }
1072 i += 1;
1073 }
1074 None
1075 }
1076
1077 fn is_ident_byte(b: &[u8], i: usize) -> bool {
1080 i > 0 && (b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_')
1081 }
1082
1083 fn env_like_literals(code: &str) -> Vec<String> {
1085 let re = regex::Regex::new(r#""([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)""#).unwrap();
1086 re.captures_iter(code).map(|c| c[1].to_string()).collect()
1087 }
1088
1089 fn is_secret_shaped(name: &str) -> bool {
1090 [
1091 "_API_KEY",
1092 "_APP_KEY",
1093 "_TOKEN",
1094 "_SECRET",
1095 "_PRIVATE_KEY",
1096 "_PASSWORD",
1097 "_PASSPHRASE",
1098 "_CREDENTIALS",
1099 ]
1100 .iter()
1101 .any(|s| name.ends_with(s))
1102 }
1103
1104 fn is_known(name: &str) -> bool {
1105 SECRET_ENV_VARS.contains(&name) || EXEMPT_SECRET_ENV_VARS.iter().any(|(n, _)| *n == name)
1106 }
1107
1108 #[test]
1111 fn every_secret_shaped_literal_is_registered_or_exempt() {
1112 let mut unknown = Vec::new();
1113 for source in production_sources() {
1114 for name in env_like_literals(&source.code) {
1115 if is_secret_shaped(&name) && !is_known(&name) {
1116 unknown.push(format!("{}: {name}", source.rel)); }
1118 }
1119 }
1120 assert!(
1121 unknown.is_empty(),
1122 "secret-shaped variables must be added to SECRET_ENV_VARS (and read via \
1123 secret_var) or to EXEMPT_SECRET_ENV_VARS with a reason (STYLE-0030): \
1124 {unknown:#?}"
1125 );
1126 }
1127
1128 #[test]
1131 fn registered_secrets_are_never_read_through_plain_accessors() {
1132 let sources = production_sources();
1133 let const_re =
1134 regex::Regex::new(r#"const ([A-Z][A-Z0-9_]*): &str = "([A-Z0-9_]+)";"#).unwrap();
1135 let mut spellings: Vec<(String, &str)> = SECRET_ENV_VARS
1138 .iter()
1139 .map(|n| (format!("\"{n}\""), *n))
1140 .collect();
1141 for source in &sources {
1142 for c in const_re.captures_iter(&source.code) {
1143 if let Some(name) = SECRET_ENV_VARS.iter().find(|n| **n == &c[2]) {
1144 spellings.push((c[1].to_string(), name));
1145 }
1146 }
1147 }
1148 let ident_re = |s: &str| regex::Regex::new(&format!(r"\b{}\b", regex::escape(s))).unwrap();
1149 let spelling_res: Vec<_> = spellings
1150 .iter()
1151 .map(|(s, n)| {
1152 let re = if s.starts_with('"') {
1153 regex::Regex::new(®ex::escape(s)).unwrap()
1154 } else {
1155 ident_re(s)
1156 };
1157 (re, *n)
1158 })
1159 .collect();
1160 let call_re = regex::Regex::new(
1161 r"(?:\.var|\bvar_any|\bget_env_var|\bnon_empty_var|\benv::var|\bvar_os|\btruthy_var)\s*\(",
1162 )
1163 .unwrap();
1164
1165 let mut offenders = Vec::new();
1166 for source in &sources {
1167 if source.rel == "utils/secret_env.rs" {
1168 continue;
1169 }
1170 for m in call_re.find_iter(&source.code) {
1171 let args = balanced_args(&source.code[m.end()..]);
1172 for (re, name) in &spelling_res {
1173 if re.is_match(args) {
1174 offenders.push(format!("{}: `{}…` reads {name}", source.rel, m.as_str()));
1176 }
1178 }
1179 }
1180 }
1181 assert!(
1182 offenders.is_empty(),
1183 "registered secrets must be read through utils::secret_env (STYLE-0030): \
1184 {offenders:#?}"
1185 );
1186 }
1187
1188 fn balanced_args(after_paren: &str) -> &str {
1190 let mut depth = 1usize;
1191 for (i, c) in after_paren.char_indices() {
1192 match c {
1193 '(' => depth += 1,
1194 ')' => {
1195 depth -= 1;
1196 if depth == 0 {
1197 return &after_paren[..i];
1198 }
1199 }
1200 _ => {}
1201 }
1202 }
1203 after_paren
1204 }
1205
1206 #[test]
1211 fn no_registered_file_companion_collides_with_an_existing_variable() {
1212 let companions: Vec<String> = SECRET_ENV_VARS.iter().map(|n| file_var_name(n)).collect();
1213 let mut collisions = Vec::new();
1214 for source in production_sources() {
1215 for name in env_like_literals(&source.code) {
1216 if companions.contains(&name) {
1217 collisions.push(format!("{}: {name}", source.rel)); }
1219 }
1220 }
1221 assert!(
1222 collisions.is_empty(),
1223 "a registered secret's _FILE companion is already a variable with another \
1224 meaning; rename it or exempt the secret (ADR-0089): {collisions:#?}"
1225 );
1226 assert!(!SECRET_ENV_VARS.contains(&"GMAIL_CLIENT_SECRET"));
1228 }
1229
1230 #[test]
1233 fn strip_test_modules_removes_only_test_blocks() {
1234 let text = "fn a() {}\n#[cfg(test)]\nfn helper() {}\n#[cfg(test)]\n\
1235 #[allow(x)]\nmod tests {\n fn t() { if x { } }\n}\nfn b() {}\n";
1236 let stripped = strip_test_modules("t.rs", text);
1237 assert!(stripped.contains("fn a()"));
1238 assert!(stripped.contains("fn helper()"));
1239 assert!(stripped.contains("fn b()"));
1240 assert!(!stripped.contains("fn t()"));
1241 }
1242
1243 #[test]
1246 fn strip_test_modules_ignores_braces_in_literals_and_comments() {
1247 let text = "#[cfg(test)]\nmod tests {\n let a = '{'; let b = \"}}\\\"{\";\n \
1248 let c = r#\"{\"#; // }\n /* { */ fn f<'a>(x: &'a str) {}\n}\nfn after() {}\n";
1249 let stripped = strip_test_modules("t.rs", text);
1250 assert!(stripped.contains("fn after()"), "{stripped}");
1251 assert!(!stripped.contains("fn f<"), "{stripped}");
1252 }
1253
1254 #[test]
1258 fn matching_brace_returns_none_when_unclosed() {
1259 assert_eq!(matching_brace("{ fn f() { }"), None);
1260 }
1261
1262 #[test]
1267 fn balanced_args_counts_nested_parens_and_degrades_when_unclosed() {
1268 assert_eq!(balanced_args("f(x), y) rest"), "f(x), y");
1269 assert_eq!(balanced_args("no closing paren"), "no closing paren");
1270 }
1271
1272 #[test]
1275 fn resolve_secret_pair_uses_the_given_labels_in_a_conflict() {
1276 let err = resolve_secret_pair(
1277 "drive.accounts.work.client_secret",
1278 Some("v"),
1279 "drive.accounts.work.client_secret_file",
1280 Some("/x"),
1281 )
1282 .unwrap_err();
1283 assert_eq!(
1284 err.to_string(),
1285 "both drive.accounts.work.client_secret and \
1286 drive.accounts.work.client_secret_file are set; set only one of them"
1287 );
1288 }
1289
1290 #[test]
1291 fn resolve_secret_pair_treats_empty_members_as_unset() {
1292 assert!(resolve_secret_pair("a", Some(""), "a_file", Some(""))
1293 .unwrap()
1294 .is_none());
1295 let resolved = resolve_secret_pair("a", Some("v"), "a_file", Some("")).unwrap();
1296 assert_eq!(resolved.unwrap().expose_secret(), "v");
1297 }
1298
1299 #[test]
1300 fn write_secret_file_creates_an_owner_only_file_the_reader_accepts() {
1301 let dir = tempfile::tempdir().unwrap();
1302 let path = dir.path().join("token");
1303 let written =
1304 write_secret_file("k_file", &path, &Secret::new(SECRET_BYTES.to_string())).unwrap();
1305 assert!(written);
1306 assert_eq!(
1307 read_secret_file("k_file", &path).unwrap().expose_secret(),
1308 SECRET_BYTES
1309 );
1310 #[cfg(unix)]
1311 {
1312 use std::os::unix::fs::PermissionsExt;
1313 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1314 assert_eq!(mode & 0o777, 0o600);
1315 }
1316 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1318 }
1319
1320 #[test]
1321 fn write_secret_file_replaces_a_different_value() {
1322 let (_dir, path) = secret_file(b"old\n", 0o600);
1323 assert!(write_secret_file("k_file", &path, &Secret::new("new".to_string())).unwrap());
1324 assert_eq!(
1325 read_secret_file("k_file", &path).unwrap().expose_secret(),
1326 "new"
1327 );
1328 }
1329
1330 #[cfg(unix)]
1331 #[test]
1332 fn write_secret_file_leaves_an_unchanged_file_alone() {
1333 use std::os::unix::fs::MetadataExt;
1334 let (_dir, path) = secret_file(b"same\n", 0o600);
1335 let inode = std::fs::metadata(&path).unwrap().ino();
1336 assert!(!write_secret_file("k_file", &path, &Secret::new("same".to_string())).unwrap());
1337 assert_eq!(std::fs::metadata(&path).unwrap().ino(), inode);
1338 }
1339
1340 #[cfg(unix)]
1341 #[test]
1342 fn write_secret_file_writes_through_a_symlink_to_its_target() {
1343 let (dir, target) = secret_file(b"old\n", 0o600);
1344 let link = dir.path().join("link");
1345 std::os::unix::fs::symlink(&target, &link).unwrap();
1346 assert!(write_secret_file("k_file", &link, &Secret::new("new".to_string())).unwrap());
1347 assert!(std::fs::symlink_metadata(&link)
1348 .unwrap()
1349 .file_type()
1350 .is_symlink());
1351 assert_eq!(
1352 read_secret_file("k_file", &target).unwrap().expose_secret(),
1353 "new"
1354 );
1355 }
1356
1357 #[test]
1358 fn write_secret_file_rejects_a_relative_path() {
1359 let err = write_secret_file(
1360 "k_file",
1361 Path::new("rel/token"),
1362 &Secret::new("v".to_string()),
1363 )
1364 .unwrap_err();
1365 assert!(matches!(err, SecretEnvError::RelativePath { .. }), "{err}");
1366 }
1367
1368 #[test]
1369 fn write_secret_file_reports_a_missing_directory_without_the_secret() {
1370 let dir = tempfile::tempdir().unwrap();
1371 let path = dir.path().join("missing").join("token");
1372 let err =
1373 write_secret_file("k_file", &path, &Secret::new(SECRET_BYTES.to_string())).unwrap_err();
1374 assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1375 let message = err.to_string();
1376 assert!(message.contains("k_file"), "{message}");
1377 assert!(!message.contains(SECRET_BYTES), "{message}");
1378 }
1379
1380 #[test]
1381 fn write_secret_file_replaces_an_empty_placeholder() {
1382 let (_dir, path) = secret_file(b"", 0o600);
1383 assert!(write_secret_file("k_file", &path, &Secret::new("v".to_string())).unwrap());
1384 assert_eq!(
1385 read_secret_file("k_file", &path).unwrap().expose_secret(),
1386 "v"
1387 );
1388 }
1389
1390 #[cfg(unix)]
1391 #[test]
1392 fn write_secret_file_refuses_to_replace_a_file_the_reader_rejects() {
1393 let (_dir, path) = secret_file(b"old\n", 0o644);
1394 let err = write_secret_file("k_file", &path, &Secret::new("new".to_string())).unwrap_err();
1395 assert!(
1396 matches!(err, SecretEnvError::LoosePermissions { .. }),
1397 "{err}"
1398 );
1399 assert_eq!(std::fs::read(&path).unwrap(), b"old\n");
1400 }
1401
1402 #[test]
1403 fn write_secret_file_refuses_to_replace_a_json_file() {
1404 let json = b"{\"installed\": {\"client_secret\": \"s\"}}\n";
1405 let (_dir, path) = secret_file(json, 0o600);
1406 let err = write_secret_file("k_file", &path, &Secret::new("s".to_string())).unwrap_err();
1407 assert!(matches!(err, SecretEnvError::HoldsJson { .. }), "{err}");
1408 assert!(!err.to_string().contains("installed"), "{err}");
1409 assert_eq!(std::fs::read(&path).unwrap(), json);
1410 }
1411
1412 #[cfg(unix)]
1413 #[test]
1414 fn write_secret_file_refuses_a_dangling_symlink() {
1415 let dir = tempfile::tempdir().unwrap();
1416 let link = dir.path().join("link");
1417 std::os::unix::fs::symlink(dir.path().join("absent"), &link).unwrap();
1418 let err = write_secret_file("k_file", &link, &Secret::new("v".to_string())).unwrap_err();
1419 assert!(matches!(err, SecretEnvError::Unreadable { .. }), "{err}");
1420 assert!(std::fs::symlink_metadata(&link)
1421 .unwrap()
1422 .file_type()
1423 .is_symlink());
1424 assert!(!dir.path().join("absent").exists());
1425 }
1426
1427 #[test]
1428 fn plan_secret_file_write_reports_whether_it_replaces_a_file() {
1429 let (dir, path) = secret_file(b"old\n", 0o600);
1430 let secret = Secret::new("new".to_string());
1431 let replace = plan_secret_file_write("k_file", &path, &secret)
1432 .unwrap()
1433 .unwrap();
1434 assert!(replace.replaces_existing());
1435 let create = plan_secret_file_write("k_file", &dir.path().join("fresh"), &secret)
1436 .unwrap()
1437 .unwrap();
1438 assert!(!create.replaces_existing());
1439 assert_eq!(std::fs::read(&path).unwrap(), b"old\n");
1441 assert!(!dir.path().join("fresh").exists());
1442 }
1443
1444 #[test]
1449 fn plan_secret_file_write_reports_a_non_not_found_stat_error() {
1450 let dir = tempfile::tempdir().unwrap();
1451 let not_a_dir = dir.path().join("not-a-dir");
1452 std::fs::write(¬_a_dir, b"x").unwrap();
1453 let path = not_a_dir.join("secret");
1454 let err =
1455 plan_secret_file_write("k_file", &path, &Secret::new("v".to_string())).unwrap_err();
1456 assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1457 }
1458
1459 #[test]
1464 fn write_secret_file_reports_an_io_error_from_an_unwritable_directory() {
1465 let dir = tempfile::tempdir().unwrap();
1466 let path = dir.path().join("secret");
1467 set_mode(dir.path(), 0o500);
1468 let err = write_secret_file("k_file", &path, &Secret::new("v".to_string())).unwrap_err();
1469 set_mode(dir.path(), 0o700);
1470 assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1471 assert!(!path.exists());
1472 }
1473
1474 #[test]
1477 fn guards_scan_the_real_read_sites() {
1478 let sources = production_sources();
1479 let all: Vec<String> = sources
1480 .iter()
1481 .flat_map(|s| env_like_literals(&s.code))
1482 .collect();
1483 for name in ["DATADOG_API_KEY", "OMNI_BRIDGE_TOKEN", "CLAUDE_API_KEY"] {
1484 assert!(all.iter().any(|n| n == name), "{name} not found");
1485 }
1486 }
1487}