1use std::fmt;
39use std::io::Read;
40use std::path::{Path, PathBuf};
41
42use thiserror::Error;
43
44use crate::utils::env::EnvSource;
45use crate::utils::secret::Secret;
46
47mod command;
48
49pub use command::{TIMEOUT_VAR as COMMAND_TIMEOUT_VAR, TTL_VAR as COMMAND_TTL_VAR};
50
51pub const FILE_SUFFIX: &str = "_FILE";
53
54pub const COMMAND_SUFFIX: &str = "_COMMAND";
57
58pub const SECRET_ENV_VARS: &[&str] = &[
65 "ANTHROPIC_API_KEY",
67 "CLAUDE_API_KEY",
68 "ANTHROPIC_AUTH_TOKEN",
69 "OPENAI_API_KEY",
70 "OPENAI_AUTH_TOKEN",
71 "TYPESAFE_API_KEY",
73 "OMNI_DEV_JEV_API_KEY",
74 "ATLASSIAN_API_TOKEN",
76 "ATLASSIAN_PAT",
77 "DATADOG_API_KEY",
79 "DATADOG_APP_KEY",
80 "GMAIL_REFRESH_TOKEN",
82 "DRIVE_CLIENT_SECRET",
84 "DRIVE_REFRESH_TOKEN",
85 "SNOWFLAKE_TOKEN",
87 "SNOWFLAKE_PRIVATE_KEY",
88 "OMNI_BRIDGE_TOKEN",
90];
91
92pub const EXEMPT_SECRET_ENV_VARS: &[(&str, &str)] = &[
96 (
97 "GH_TOKEN",
98 "GitHub CLI child-environment output, never read by omni-dev; PAT mode \
99 preserves gh's own credential resolution and App mode mints this value",
100 ),
101 (
102 "GITHUB_TOKEN",
103 "GitHub CLI child-environment output, never read by omni-dev; set alongside \
104 GH_TOKEN so inherited credentials cannot override App authentication",
105 ),
106 (
107 "GMAIL_CLIENT_SECRET",
108 "GMAIL_CLIENT_SECRET_FILE already names a Google client_secret.json for \
109 `gmail auth import`; an installed-app OAuth client secret is not \
110 confidential, and that command already reads it from a file (ADR-0089)",
111 ),
112 (
113 "SNOWFLAKE_PRIVATE_KEY_PASSPHRASE",
114 "only read to reject encrypted keys, which are unsupported; include it \
115 once encrypted keys are supported (#2006)",
116 ),
117 (
118 "PROGRAMMATIC_ACCESS_TOKEN",
119 "a Snowflake authenticator wire value, not an environment variable",
120 ),
121];
122
123#[derive(Debug, Error)]
126pub enum SecretEnvError {
127 #[error("both {name} and {file_var} are set; set only one of them")]
129 Conflict {
130 name: String,
132 file_var: String,
134 },
135
136 #[error("{} are set; set only one of them", join_names(set))]
139 CommandConflict {
140 set: Vec<String>,
143 },
144
145 #[error("cannot run the command named by {command_var}: {reason}")]
147 CommandSplit {
148 command_var: String,
150 reason: &'static str,
152 },
153
154 #[error("cannot run {program}, named by {command_var}: {source}{hint}")]
156 CommandSpawn {
157 command_var: String,
159 program: String,
161 hint: &'static str,
163 source: std::io::Error,
165 },
166
167 #[error("{program}, named by {command_var}, failed ({status}){detail}")]
169 CommandFailed {
170 command_var: String,
172 program: String,
174 status: String,
176 detail: String,
178 },
179
180 #[error(
182 "{program}, named by {command_var}, did not finish within {secs}s and was killed; \
183 raise {COMMAND_TIMEOUT_VAR} if it is waiting for you to approve a prompt"
184 )]
185 CommandTimedOut {
186 command_var: String,
188 program: String,
190 secs: u64,
192 },
193
194 #[error("{program}, named by {command_var}, wrote more than {limit} bytes of output")]
196 CommandOutputTooLarge {
197 command_var: String,
199 program: String,
201 limit: usize,
203 },
204
205 #[error("{program}, named by {command_var}, printed no secret")]
207 CommandEmptyOutput {
208 command_var: String,
210 program: String,
212 },
213
214 #[error("the output of {program}, named by {command_var}, is not valid UTF-8")]
216 CommandNotUtf8 {
217 command_var: String,
219 program: String,
221 },
222
223 #[error("{file_var} must be an absolute path, got '{}'", path.display())]
225 RelativePath {
226 file_var: String,
228 path: PathBuf,
230 },
231
232 #[error("cannot read the secret file {} named by {file_var}: {source}", path.display())]
234 Unreadable {
235 file_var: String,
237 path: PathBuf,
239 source: std::io::Error,
241 },
242
243 #[error("cannot write the secret file {} named by {file_var}: {source}", path.display())]
245 Unwritable {
246 file_var: String,
248 path: PathBuf,
250 source: std::io::Error,
252 },
253
254 #[error(
257 "the secret file {} named by {file_var} holds JSON, not a bare secret; refusing to \
258 replace it. Point {file_var} at a file of its own",
259 path.display()
260 )]
261 HoldsJson {
262 file_var: String,
264 path: PathBuf,
266 },
267
268 #[error("the secret file {} named by {file_var} is not a regular file", path.display())]
270 NotAFile {
271 file_var: String,
273 path: PathBuf,
275 },
276
277 #[error("the secret file {} named by {file_var} is empty", path.display())]
279 Empty {
280 file_var: String,
282 path: PathBuf,
284 },
285
286 #[error("the secret file {} named by {file_var} is not valid UTF-8", path.display())]
288 NotUtf8 {
289 file_var: String,
291 path: PathBuf,
293 },
294
295 #[error(
298 "the secret file {path} named by {file_var} has mode {mode}, which grants group or \
299 other access; restrict it with `chmod 600 {path}`",
300 path = path.display(),
301 mode = OctalMode(*mode)
302 )]
303 LoosePermissions {
304 file_var: String,
306 path: PathBuf,
308 mode: u32,
310 },
311
312 #[error(
314 "the secret file {path} named by {file_var} is owned by root but has mode {mode}, \
315 which lets group or other users write it; remove that with `chmod go-w {path}`",
316 path = path.display(),
317 mode = OctalMode(*mode)
318 )]
319 WritableByOthers {
320 file_var: String,
322 path: PathBuf,
324 mode: u32,
326 },
327
328 #[error(
330 "the secret file {} named by {file_var} is owned by uid {owner}, which is neither \
331 the current user (uid {expected}) nor root; take ownership of it (or copy it) and \
332 `chmod 600` it",
333 path.display()
334 )]
335 WrongOwner {
336 file_var: String,
338 path: PathBuf,
340 owner: u32,
342 expected: u32,
344 },
345}
346
347fn join_names(names: &[String]) -> String {
349 match names {
350 [] => String::new(),
351 [only] => only.clone(),
352 [init @ .., last] => format!("{} and {last}", init.join(", ")),
353 }
354}
355
356struct OctalMode(u32);
358
359impl fmt::Display for OctalMode {
360 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361 write!(f, "{:04o}", self.0)
362 }
363}
364
365#[must_use]
367pub fn file_var_name(name: &str) -> String {
368 format!("{name}{FILE_SUFFIX}")
369}
370
371#[must_use]
373pub fn command_var_name(name: &str) -> String {
374 format!("{name}{COMMAND_SUFFIX}")
375}
376
377pub fn secret_var(env: &impl EnvSource, name: &str) -> Result<Option<Secret>, SecretEnvError> {
388 debug_assert!(
389 SECRET_ENV_VARS.contains(&name),
390 "{name} must be registered in SECRET_ENV_VARS"
391 );
392 let file_var = file_var_name(name);
393 let command_var = command_var_name(name);
394 let (value, file, command) = env.var_triple(name, &file_var, &command_var);
395 let value = value.filter(|v| !v.is_empty());
396 let file = file.filter(|v| !v.is_empty());
397 let command = command.filter(|v| !v.is_empty());
398 match (value.as_deref(), file.as_deref(), command.as_deref()) {
399 (value, file, None) => resolve_secret_pair(name, value, &file_var, file),
400 (None, None, Some(command)) => {
401 command::resolve(&command_var, command, command::Limits::from_env(env)).map(Some)
402 }
403 (value, file, Some(_)) => Err(SecretEnvError::CommandConflict {
404 set: [
405 value.map(|_| name),
406 file.map(|_| file_var.as_str()),
407 Some(command_var.as_str()),
408 ]
409 .into_iter()
410 .flatten()
411 .map(str::to_string)
412 .collect(),
413 }),
414 }
415}
416
417pub fn resolve_secret_pair(
431 name: &str,
432 value: Option<&str>,
433 file_var: &str,
434 file: Option<&str>,
435) -> Result<Option<Secret>, SecretEnvError> {
436 let value = value.filter(|v| !v.is_empty());
437 let file = file.filter(|v| !v.is_empty());
438 match (value, file) {
439 (Some(_), Some(_)) => Err(SecretEnvError::Conflict {
440 name: name.to_string(),
441 file_var: file_var.to_string(),
442 }),
443 (Some(value), None) => Ok(Some(Secret::new(value))),
444 (None, Some(path)) => read_secret_file(file_var, Path::new(path)).map(Some),
445 (None, None) => Ok(None),
446 }
447}
448
449pub fn secret_var_any(
456 env: &impl EnvSource,
457 names: &[&str],
458) -> Result<Option<Secret>, SecretEnvError> {
459 for name in names {
460 if let Some(secret) = secret_var(env, name)? {
461 return Ok(Some(secret));
462 }
463 }
464 Ok(None)
465}
466
467#[must_use]
472pub fn secret_var_is_set(env: &impl EnvSource, name: &str) -> bool {
473 debug_assert!(
474 SECRET_ENV_VARS.contains(&name),
475 "{name} must be registered in SECRET_ENV_VARS"
476 );
477 let (value, file, command) =
478 env.var_triple(name, &file_var_name(name), &command_var_name(name));
479 let set = |v: Option<String>| v.is_some_and(|v| !v.is_empty());
480 set(value) || set(file) || set(command)
481}
482
483pub fn read_secret_file(file_var: &str, path: &Path) -> Result<Secret, SecretEnvError> {
493 if !path.is_absolute() {
494 return Err(SecretEnvError::RelativePath {
495 file_var: file_var.to_string(),
496 path: path.to_path_buf(),
497 });
498 }
499 let unreadable = |source| SecretEnvError::Unreadable {
500 file_var: file_var.to_string(),
501 path: path.to_path_buf(),
502 source,
503 };
504 check_metadata(
507 file_var,
508 path,
509 &std::fs::metadata(path).map_err(unreadable)?,
510 )?;
511 let mut file = std::fs::File::open(path).map_err(unreadable)?;
512 check_metadata(file_var, path, &file.metadata().map_err(unreadable)?)?;
513 let mut bytes = Vec::new();
514 file.read_to_end(&mut bytes).map_err(unreadable)?;
515 let mut text = String::from_utf8(bytes).map_err(|_| SecretEnvError::NotUtf8 {
516 file_var: file_var.to_string(),
517 path: path.to_path_buf(),
518 })?;
519 trim_one_newline(&mut text);
520 if text.is_empty() {
521 return Err(SecretEnvError::Empty {
522 file_var: file_var.to_string(),
523 path: path.to_path_buf(),
524 });
525 }
526 Ok(Secret::new(text))
527}
528
529pub fn write_secret_file(
539 file_var: &str,
540 path: &Path,
541 secret: &Secret,
542) -> Result<bool, SecretEnvError> {
543 match plan_secret_file_write(file_var, path, secret)? {
544 Some(write) => write.write(secret).map(|()| true),
545 None => Ok(false),
546 }
547}
548
549#[derive(Debug)]
552pub struct SecretFileWrite {
553 file_var: String,
554 path: PathBuf,
555 target: PathBuf,
556 replaces_existing: bool,
557}
558
559pub fn plan_secret_file_write(
586 file_var: &str,
587 path: &Path,
588 secret: &Secret,
589) -> Result<Option<SecretFileWrite>, SecretEnvError> {
590 if !path.is_absolute() {
591 return Err(SecretEnvError::RelativePath {
592 file_var: file_var.to_string(),
593 path: path.to_path_buf(),
594 });
595 }
596 let unwritable = |source| SecretEnvError::Unwritable {
597 file_var: file_var.to_string(),
598 path: path.to_path_buf(),
599 source,
600 };
601 let (target, replaces_existing) = match std::fs::symlink_metadata(path) {
602 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (path.to_path_buf(), false),
603 Err(e) => return Err(unwritable(e)),
604 Ok(_) => {
605 match read_secret_file(file_var, path) {
606 Ok(current) if current.expose_secret() == secret.expose_secret() => {
607 return Ok(None)
608 }
609 Ok(current) if current.expose_secret().trim_start().starts_with('{') => {
610 return Err(SecretEnvError::HoldsJson {
611 file_var: file_var.to_string(),
612 path: path.to_path_buf(),
613 })
614 }
615 Ok(_) | Err(SecretEnvError::Empty { .. }) => {}
616 Err(e) => return Err(e),
617 }
618 (std::fs::canonicalize(path).map_err(unwritable)?, true)
619 }
620 };
621 if !target.parent().is_some_and(Path::is_dir) {
622 return Err(unwritable(std::io::Error::new(
623 std::io::ErrorKind::NotFound,
624 "its directory does not exist",
625 )));
626 }
627 Ok(Some(SecretFileWrite {
628 file_var: file_var.to_string(),
629 path: path.to_path_buf(),
630 target,
631 replaces_existing,
632 }))
633}
634
635impl SecretFileWrite {
636 #[must_use]
638 pub fn replaces_existing(&self) -> bool {
639 self.replaces_existing
640 }
641
642 pub fn write(self, secret: &Secret) -> Result<(), SecretEnvError> {
650 use std::io::Write;
651 let unwritable = |source| SecretEnvError::Unwritable {
652 file_var: self.file_var.clone(),
653 path: self.path.clone(),
654 source,
655 };
656 let dir = self.target.parent().unwrap_or(Path::new("/"));
657 let mut temp = tempfile::Builder::new()
660 .prefix(".omni-dev-secret-")
661 .tempfile_in(dir)
662 .map_err(unwritable)?;
663 temp.write_all(secret.expose_secret().as_bytes())
664 .and_then(|()| temp.write_all(b"\n"))
665 .and_then(|()| temp.as_file().sync_all())
666 .map_err(unwritable)?;
667 temp.persist(&self.target)
668 .map_err(|e| unwritable(e.error))?;
669 Ok(())
670 }
671}
672
673fn trim_one_newline(text: &mut String) {
676 if text.ends_with("\r\n") {
677 text.truncate(text.len() - 2);
678 } else if text.ends_with('\n') {
679 text.truncate(text.len() - 1);
680 }
681}
682
683fn check_metadata(
685 file_var: &str,
686 path: &Path,
687 metadata: &std::fs::Metadata,
688) -> Result<(), SecretEnvError> {
689 if !metadata.is_file() {
690 return Err(SecretEnvError::NotAFile {
691 file_var: file_var.to_string(),
692 path: path.to_path_buf(),
693 });
694 }
695 #[cfg(unix)]
696 {
697 use std::os::unix::fs::MetadataExt;
698 check_unix_security(
699 file_var,
700 path,
701 metadata.mode(),
702 metadata.uid(),
703 nix::unistd::geteuid().as_raw(),
704 )?;
705 }
706 Ok(())
707}
708
709#[cfg(unix)]
711const ROOT_UID: u32 = 0;
712
713#[cfg(unix)]
726fn check_unix_security(
727 file_var: &str,
728 path: &Path,
729 mode: u32,
730 owner: u32,
731 euid: u32,
732) -> Result<(), SecretEnvError> {
733 let mode = mode & 0o777;
734 if owner == ROOT_UID {
735 if mode & 0o022 != 0 {
736 return Err(SecretEnvError::WritableByOthers {
737 file_var: file_var.to_string(),
738 path: path.to_path_buf(),
739 mode,
740 });
741 }
742 return Ok(());
743 }
744 if owner != euid {
745 return Err(SecretEnvError::WrongOwner {
746 file_var: file_var.to_string(),
747 path: path.to_path_buf(),
748 owner,
749 expected: euid,
750 });
751 }
752 if mode & 0o077 != 0 {
753 return Err(SecretEnvError::LoosePermissions {
754 file_var: file_var.to_string(),
755 path: path.to_path_buf(),
756 mode,
757 });
758 }
759 Ok(())
760}
761
762#[cfg(test)]
763#[allow(clippy::unwrap_used, clippy::expect_used)]
764mod tests {
765 use super::*;
766 use crate::test_support::env::MapEnv;
767
768 const NAME: &str = "DATADOG_API_KEY";
769 const FILE_VAR: &str = "DATADOG_API_KEY_FILE";
770 const SECRET_BYTES: &str = "sekret-bytes-never-logged";
771
772 fn secret_file(contents: &[u8], mode: u32) -> (tempfile::TempDir, PathBuf) {
774 let dir = tempfile::tempdir().unwrap();
775 let path = dir.path().join("secret");
776 std::fs::write(&path, contents).unwrap();
777 set_mode(&path, mode);
778 (dir, path)
779 }
780
781 #[cfg(unix)]
782 fn set_mode(path: &Path, mode: u32) {
783 use std::os::unix::fs::PermissionsExt;
784 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
785 }
786
787 #[cfg(not(unix))]
788 fn set_mode(_path: &Path, _mode: u32) {}
789
790 fn env_with_file(path: &Path) -> MapEnv {
791 MapEnv::new().with(FILE_VAR, path.to_str().unwrap())
792 }
793
794 fn resolve(env: &MapEnv) -> Result<Option<String>, SecretEnvError> {
795 secret_var(env, NAME).map(|s| s.map(|s| s.expose_secret().to_string()))
796 }
797
798 #[test]
799 fn only_the_variable_is_used_verbatim() {
800 let env = MapEnv::new().with(NAME, " value with spaces \n");
801 assert_eq!(
802 resolve(&env).unwrap().as_deref(),
803 Some(" value with spaces \n")
804 );
805 }
806
807 #[test]
808 fn only_the_file_is_read() {
809 let (_dir, path) = secret_file(b"from-file\n", 0o600);
810 assert_eq!(
811 resolve(&env_with_file(&path)).unwrap().as_deref(),
812 Some("from-file")
813 );
814 }
815
816 #[test]
817 fn neither_set_is_none() {
818 assert!(resolve(&MapEnv::new()).unwrap().is_none());
819 }
820
821 #[test]
822 fn empty_values_count_as_unset() {
823 let env = MapEnv::new().with(NAME, "").with(FILE_VAR, "");
824 assert!(resolve(&env).unwrap().is_none());
825 assert!(!secret_var_is_set(&env, NAME));
826 let (_dir, path) = secret_file(b"f", 0o600);
828 let env = env_with_file(&path).with(NAME, "");
829 assert_eq!(resolve(&env).unwrap().as_deref(), Some("f"));
830 }
831
832 #[test]
833 fn both_set_is_a_conflict_naming_both() {
834 let (_dir, path) = secret_file(b"f", 0o600);
835 let env = env_with_file(&path).with(NAME, "v");
836 let err = resolve(&env).unwrap_err();
837 assert!(matches!(err, SecretEnvError::Conflict { .. }));
838 let msg = err.to_string();
839 assert!(msg.contains(NAME) && msg.contains(FILE_VAR), "{msg}");
840 }
841
842 #[test]
843 fn empty_file_is_an_error_not_unset() {
844 for contents in [&b""[..], b"\n", b"\r\n"] {
845 let (_dir, path) = secret_file(contents, 0o600);
846 let err = resolve(&env_with_file(&path)).unwrap_err();
847 assert!(matches!(err, SecretEnvError::Empty { .. }), "{contents:?}");
848 }
849 }
850
851 #[test]
852 fn missing_file_is_unreadable_and_names_the_path() {
853 let dir = tempfile::tempdir().unwrap();
854 let path = dir.path().join("absent");
855 let err = resolve(&env_with_file(&path)).unwrap_err();
856 assert!(matches!(err, SecretEnvError::Unreadable { .. }));
857 assert!(err.to_string().contains(path.to_str().unwrap()));
858 assert!(err.to_string().contains(FILE_VAR));
859 }
860
861 #[test]
862 fn a_directory_is_not_a_file() {
863 let dir = tempfile::tempdir().unwrap();
864 let err = resolve(&env_with_file(dir.path())).unwrap_err();
865 assert!(matches!(err, SecretEnvError::NotAFile { .. }));
866 }
867
868 #[test]
869 fn relative_path_is_rejected() {
870 let env = MapEnv::new().with(FILE_VAR, "secrets/key");
871 let err = resolve(&env).unwrap_err();
872 assert!(matches!(err, SecretEnvError::RelativePath { .. }));
873 assert!(err.to_string().contains("absolute"));
874 }
875
876 #[test]
877 fn trims_exactly_one_trailing_crlf() {
878 let (_dir, path) = secret_file(b"crlf\r\n", 0o600);
879 assert_eq!(
880 resolve(&env_with_file(&path)).unwrap().as_deref(),
881 Some("crlf")
882 );
883 }
884
885 #[test]
886 fn trims_only_one_newline_and_keeps_other_whitespace() {
887 let (_dir, path) = secret_file(b" in ner \t\n\n", 0o600);
888 assert_eq!(
889 resolve(&env_with_file(&path)).unwrap().as_deref(),
890 Some(" in ner \t\n")
891 );
892 }
893
894 #[test]
895 fn non_utf8_is_rejected() {
896 let (_dir, path) = secret_file(&[0xff, 0xfe, b'x'], 0o600);
897 let err = resolve(&env_with_file(&path)).unwrap_err();
898 assert!(matches!(err, SecretEnvError::NotUtf8 { .. }));
899 }
900
901 #[cfg(unix)]
902 #[test]
903 fn mode_0600_and_0400_are_accepted() {
904 for mode in [0o600, 0o400] {
905 let (_dir, path) = secret_file(b"ok", mode);
906 assert_eq!(
907 resolve(&env_with_file(&path)).unwrap().as_deref(),
908 Some("ok")
909 );
910 }
911 }
912
913 #[cfg(unix)]
914 #[test]
915 fn mode_0644_is_rejected_with_the_mode_and_the_fix() {
916 let (_dir, path) = secret_file(SECRET_BYTES.as_bytes(), 0o644);
917 let err = resolve(&env_with_file(&path)).unwrap_err();
918 assert!(matches!(
919 err,
920 SecretEnvError::LoosePermissions { mode: 0o644, .. }
921 ));
922 let msg = err.to_string();
923 assert!(msg.contains("0644"), "{msg}");
924 assert!(
925 msg.contains(&format!("chmod 600 {}", path.display())),
926 "{msg}"
927 );
928 assert!(!msg.contains(SECRET_BYTES), "{msg}");
929 }
930
931 #[cfg(unix)]
932 #[test]
933 fn any_group_or_other_bit_is_rejected() {
934 for mode in [0o640, 0o604, 0o610, 0o601] {
935 let (_dir, path) = secret_file(b"x", mode);
936 assert!(
938 matches!(
939 resolve(&env_with_file(&path)).unwrap_err(),
940 SecretEnvError::LoosePermissions { .. }
941 ),
942 "{mode:o}"
943 );
944 }
946 }
947
948 #[cfg(unix)]
949 #[test]
950 fn another_users_file_is_rejected_by_the_pure_check() {
951 let path = Path::new("/home/other/key");
952 let err = check_unix_security(FILE_VAR, path, 0o100_600, 502, 501).unwrap_err();
953 assert!(matches!(
954 err,
955 SecretEnvError::WrongOwner {
956 owner: 502,
957 expected: 501,
958 ..
959 }
960 ));
961 let msg = err.to_string();
962 assert!(msg.contains("uid 502") && msg.contains("uid 501"), "{msg}");
963 assert!(check_unix_security(FILE_VAR, path, 0o100_600, 501, 501).is_ok());
965 }
966
967 #[cfg(unix)]
971 #[test]
972 fn root_owned_read_only_secrets_are_accepted() {
973 let path = Path::new("/run/secrets/key");
974 for mode in [0o100_400, 0o100_440, 0o100_444, 0o100_600, 0o100_644] {
975 for euid in [0, 501] {
976 assert!(
977 check_unix_security(FILE_VAR, path, mode, ROOT_UID, euid).is_ok(),
978 "mode {mode:o} euid {euid}"
979 );
980 }
981 }
982 }
983
984 #[cfg(unix)]
985 #[test]
986 fn root_owned_secrets_writable_by_others_are_rejected() {
987 let path = Path::new("/run/secrets/key");
988 for mode in [0o100_664, 0o100_646, 0o100_666, 0o100_620] {
989 let err = check_unix_security(FILE_VAR, path, mode, ROOT_UID, 501).unwrap_err();
990 assert!(
991 matches!(err, SecretEnvError::WritableByOthers { .. }),
992 "mode {mode:o}"
993 );
994 let msg = err.to_string();
995 assert!(msg.contains("chmod go-w /run/secrets/key"), "{msg}");
996 }
997 }
998
999 #[cfg(unix)]
1002 #[test]
1003 fn root_process_still_rejects_group_writable_root_files() {
1004 let path = Path::new("/run/secrets/key");
1005 assert!(matches!(
1006 check_unix_security(FILE_VAR, path, 0o100_660, ROOT_UID, ROOT_UID).unwrap_err(),
1007 SecretEnvError::WritableByOthers { .. }
1008 ));
1009 }
1010
1011 #[cfg(unix)]
1012 #[test]
1013 fn symlinks_are_followed_and_the_target_is_checked() {
1014 let (dir, target) = secret_file(b"via-link\n", 0o600);
1015 let link = dir.path().join("link");
1016 std::os::unix::fs::symlink(&target, &link).unwrap();
1017 assert_eq!(
1018 resolve(&env_with_file(&link)).unwrap().as_deref(),
1019 Some("via-link")
1020 );
1021
1022 set_mode(&target, 0o644);
1023 assert!(matches!(
1024 resolve(&env_with_file(&link)).unwrap_err(),
1025 SecretEnvError::LoosePermissions { mode: 0o644, .. }
1026 ));
1027 }
1028
1029 #[test]
1030 fn no_error_message_contains_the_secret_bytes() {
1031 let (_dir, path) = secret_file(SECRET_BYTES.as_bytes(), 0o600);
1032 let env = env_with_file(&path).with(NAME, SECRET_BYTES);
1033 let err = resolve(&env).unwrap_err();
1034 assert!(!err.to_string().contains(SECRET_BYTES));
1035 assert!(!format!("{err:?}").contains(SECRET_BYTES));
1036 }
1037
1038 #[test]
1039 fn aliases_keep_precedence_and_only_conflict_within_a_pair() {
1040 let (_dir, path) = secret_file(b"from-file", 0o600);
1041 let names = ["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"];
1042
1043 let env = MapEnv::new()
1045 .with("OPENAI_API_KEY_FILE", path.to_str().unwrap())
1046 .with("OPENAI_AUTH_TOKEN", "b");
1047 let got = secret_var_any(&env, &names).unwrap().unwrap();
1048 assert_eq!(got.expose_secret(), "from-file");
1049
1050 let env = MapEnv::new().with("OPENAI_AUTH_TOKEN_FILE", path.to_str().unwrap());
1052 let got = secret_var_any(&env, &names).unwrap().unwrap();
1053 assert_eq!(got.expose_secret(), "from-file");
1054
1055 let env = MapEnv::new()
1057 .with("OPENAI_API_KEY", "a")
1058 .with("OPENAI_API_KEY_FILE", path.to_str().unwrap());
1059 assert!(matches!(
1060 secret_var_any(&env, &names).unwrap_err(),
1061 SecretEnvError::Conflict { .. }
1062 ));
1063
1064 assert!(secret_var_any(&MapEnv::new(), &names).unwrap().is_none());
1065 }
1066
1067 #[test]
1068 fn is_set_does_not_read_the_file() {
1069 let env = MapEnv::new().with(FILE_VAR, "/definitely/not/there");
1070 assert!(secret_var_is_set(&env, NAME));
1071 assert!(secret_var_is_set(&MapEnv::new().with(NAME, "v"), NAME));
1072 assert!(!secret_var_is_set(&MapEnv::new(), NAME));
1073 }
1074
1075 const COMMAND_VAR: &str = "DATADOG_API_KEY_COMMAND";
1078
1079 fn command_env(command: &str) -> MapEnv {
1081 MapEnv::new()
1082 .with(COMMAND_VAR, command)
1083 .with(COMMAND_TTL_VAR, "0")
1084 }
1085
1086 #[cfg(unix)]
1087 #[test]
1088 fn only_the_command_is_run() {
1089 let env = command_env("/usr/bin/printf %s from-command");
1090 assert_eq!(resolve(&env).unwrap().as_deref(), Some("from-command"));
1091 }
1092
1093 #[test]
1094 fn is_set_counts_a_command_without_running_it() {
1095 let env = MapEnv::new().with(COMMAND_VAR, "/definitely/not/there");
1097 assert!(secret_var_is_set(&env, NAME));
1098 assert!(!secret_var_is_set(
1099 &MapEnv::new().with(COMMAND_VAR, ""),
1100 NAME
1101 ));
1102 }
1103
1104 #[cfg(unix)]
1105 #[test]
1106 fn is_set_never_spawns_the_command() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let marker = dir.path().join("ran");
1109 let env = command_env(&format!("/usr/bin/touch {}", marker.display()));
1110 assert!(secret_var_is_set(&env, NAME));
1111 assert!(!marker.exists(), "a status check ran the helper");
1112 }
1113
1114 #[test]
1115 fn an_empty_command_counts_as_unset() {
1116 let env = MapEnv::new().with(COMMAND_VAR, "").with(NAME, "direct");
1117 assert_eq!(resolve(&env).unwrap().as_deref(), Some("direct"));
1118 assert_eq!(resolve(&MapEnv::new().with(COMMAND_VAR, "")).unwrap(), None);
1119 }
1120
1121 #[test]
1122 fn a_command_with_either_other_source_is_a_conflict_naming_every_one_set() {
1123 let (_tmp, path) = secret_file(b"x\n", 0o600);
1124 let file = path.to_str().unwrap();
1125 let cases: [(MapEnv, &str); 3] = [
1126 (
1127 MapEnv::new().with(NAME, "v").with(COMMAND_VAR, "/bin/true"),
1128 "DATADOG_API_KEY and DATADOG_API_KEY_COMMAND are set",
1129 ),
1130 (
1131 MapEnv::new()
1132 .with(FILE_VAR, file)
1133 .with(COMMAND_VAR, "/bin/true"),
1134 "DATADOG_API_KEY_FILE and DATADOG_API_KEY_COMMAND are set",
1135 ),
1136 (
1137 MapEnv::new()
1138 .with(NAME, "v")
1139 .with(FILE_VAR, file)
1140 .with(COMMAND_VAR, "/bin/true"),
1141 "DATADOG_API_KEY, DATADOG_API_KEY_FILE and DATADOG_API_KEY_COMMAND are set",
1142 ),
1143 ];
1144 for (env, expected) in cases {
1145 let err = resolve(&env).unwrap_err();
1146 assert!(
1147 matches!(err, SecretEnvError::CommandConflict { .. }),
1148 "{err}"
1149 );
1150 assert!(err.to_string().starts_with(expected), "{err}");
1151 assert!(err.to_string().ends_with("set only one of them"), "{err}");
1152 }
1153 }
1154
1155 #[test]
1156 fn a_conflict_never_runs_the_command() {
1157 let dir = tempfile::tempdir().unwrap();
1158 let marker = dir.path().join("ran");
1159 let env = MapEnv::new()
1160 .with(NAME, "v")
1161 .with(COMMAND_VAR, &format!("/usr/bin/touch {}", marker.display()));
1162 assert!(resolve(&env).is_err());
1163 assert!(!marker.exists());
1164 }
1165
1166 #[cfg(unix)]
1167 #[test]
1168 fn aliases_keep_precedence_across_commands() {
1169 let names = ["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"];
1170 let env = MapEnv::new()
1171 .with("OPENAI_API_KEY_COMMAND", "/usr/bin/printf %s first")
1172 .with("OPENAI_AUTH_TOKEN", "second")
1173 .with(COMMAND_TTL_VAR, "0");
1174 assert_eq!(
1175 secret_var_any(&env, &names)
1176 .unwrap()
1177 .unwrap()
1178 .expose_secret(),
1179 "first"
1180 );
1181 let env = MapEnv::new()
1184 .with("OPENAI_API_KEY", "a")
1185 .with("OPENAI_API_KEY_COMMAND", "/bin/true")
1186 .with("OPENAI_AUTH_TOKEN", "b");
1187 assert!(secret_var_any(&env, &names).is_err());
1188 }
1189
1190 #[cfg(unix)]
1191 #[test]
1192 fn a_failing_command_reports_the_variable_and_never_the_arguments() {
1193 let env = command_env("/bin/sh -c 'exit 7' --token=hunter2");
1194 let err = resolve(&env).unwrap_err();
1195 let text = err.to_string();
1196 assert!(text.contains(COMMAND_VAR), "{text}");
1197 assert!(text.contains("exit code 7"), "{text}");
1198 assert!(!text.contains("hunter2"), "{text}");
1199 }
1200
1201 #[test]
1202 #[cfg(unix)]
1203 fn the_command_knobs_are_read_through_the_env_source() {
1204 let env = MapEnv::new()
1206 .with(COMMAND_VAR, "/bin/sleep 5")
1207 .with(COMMAND_TIMEOUT_VAR, "1")
1208 .with(COMMAND_TTL_VAR, "0");
1209 let err = resolve(&env).unwrap_err();
1210 assert!(
1211 matches!(err, SecretEnvError::CommandTimedOut { .. }),
1212 "{err}"
1213 );
1214 }
1215
1216 #[test]
1217 fn join_names_handles_every_arity() {
1218 let names = |v: &[&str]| v.iter().map(ToString::to_string).collect::<Vec<_>>();
1219 assert_eq!(join_names(&[]), "");
1220 assert_eq!(join_names(&names(&["A"])), "A");
1221 assert_eq!(join_names(&names(&["A", "B"])), "A and B");
1222 assert_eq!(join_names(&names(&["A", "B", "C"])), "A, B and C");
1223 }
1224
1225 #[test]
1226 fn registry_and_exemptions_are_disjoint_and_unique() {
1227 let mut seen = std::collections::BTreeSet::new();
1228 for name in SECRET_ENV_VARS
1229 .iter()
1230 .chain(EXEMPT_SECRET_ENV_VARS.iter().map(|(n, _)| n))
1231 {
1232 assert!(seen.insert(*name), "{name} is listed twice");
1233 assert!(!name.ends_with(FILE_SUFFIX), "{name}");
1234 assert!(!name.ends_with(COMMAND_SUFFIX), "{name}");
1235 }
1236 }
1237
1238 struct Source {
1243 rel: String,
1244 code: String,
1245 }
1246
1247 fn production_sources() -> Vec<Source> {
1248 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
1249 for entry in std::fs::read_dir(dir).unwrap() {
1250 let path = entry.unwrap().path();
1251 if path.is_dir() {
1252 walk(&path, out);
1253 } else if path.extension().is_some_and(|e| e == "rs") {
1254 out.push(path);
1255 }
1256 }
1257 }
1258 let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
1259 let mut files = Vec::new();
1260 walk(&src, &mut files);
1261 files
1262 .into_iter()
1263 .filter_map(|path| {
1264 let rel = path
1265 .strip_prefix(&src)
1266 .unwrap()
1267 .to_string_lossy()
1268 .replace('\\', "/");
1269 let name = path.file_name().unwrap().to_string_lossy();
1270 let is_test_file = rel == "test_support.rs"
1271 || name == "tests.rs"
1272 || name.ends_with("_tests.rs")
1273 || rel.contains("/tests/");
1274 if is_test_file {
1275 return None;
1276 }
1277 let text = std::fs::read_to_string(&path).unwrap();
1278 let code = strip_test_modules(&rel, &text)
1279 .lines()
1280 .filter(|l| !l.trim_start().starts_with("//"))
1281 .collect::<Vec<_>>()
1282 .join("\n");
1283 Some(Source { rel, code })
1284 })
1285 .collect()
1286 }
1287
1288 fn strip_test_modules(rel: &str, text: &str) -> String {
1293 let mut out = String::new();
1294 let mut rest = text;
1295 while let Some(at) = rest.find("#[cfg(test)]") {
1296 out.push_str(&rest[..at]);
1297 let after = &rest[at..];
1298 let brace = after.find('{');
1299 let semi = after.find(';');
1300 let mut item = after["#[cfg(test)]".len()..].trim_start();
1302 while item.starts_with("#[") {
1303 item = item.find('\n').map_or("", |nl| item[nl..].trim_start());
1304 }
1305 let is_mod_block = (item.starts_with("mod ") || item.starts_with("pub(crate) mod "))
1306 && brace.is_some_and(|b| semi.is_none_or(|s| b < s));
1307 if !is_mod_block {
1308 out.push_str("#[cfg(test)]");
1309 rest = &after["#[cfg(test)]".len()..];
1310 continue;
1311 }
1312 let start = brace.unwrap();
1313 let end = matching_brace(&after[start..])
1314 .unwrap_or_else(|| panic!("{rel}: unbalanced #[cfg(test)] module"));
1315 rest = &after[start + end + 1..];
1316 }
1317 out.push_str(rest);
1318 out
1319 }
1320
1321 fn matching_brace(text: &str) -> Option<usize> {
1325 let b = text.as_bytes();
1326 let mut depth = 0usize;
1327 let mut i = 0;
1328 while i < b.len() {
1329 match b[i] {
1330 b'/' if b.get(i + 1) == Some(&b'/') => {
1331 i = text[i..].find('\n').map_or(b.len(), |n| i + n);
1332 continue;
1333 }
1334 b'/' if b.get(i + 1) == Some(&b'*') => {
1335 i = text[i + 2..].find("*/").map_or(b.len(), |n| i + 2 + n + 2);
1336 continue;
1337 }
1338 b'r' if !is_ident_byte(b, i) && matches!(b.get(i + 1), Some(b'"' | b'#')) => {
1339 let hashes = b[i + 1..].iter().take_while(|&&c| c == b'#').count();
1340 if b.get(i + 1 + hashes) == Some(&b'"') {
1341 let close = format!("\"{}", "#".repeat(hashes));
1342 let body = i + 2 + hashes;
1343 i = text[body..]
1344 .find(&close)
1345 .map_or(b.len(), |n| body + n + close.len());
1346 continue;
1347 }
1348 }
1349 b'"' => {
1350 i += 1;
1351 while i < b.len() && b[i] != b'"' {
1352 i += if b[i] == b'\\' { 2 } else { 1 };
1353 }
1354 }
1355 b'\'' => {
1356 if b.get(i + 1) == Some(&b'\\') {
1359 if let Some(n) = text[i + 2..].find('\'') {
1360 i += 2 + n;
1361 }
1362 } else if let Some(c) = text[i + 1..].chars().next() {
1363 if text[i + 1 + c.len_utf8()..].starts_with('\'') {
1364 i += 1 + c.len_utf8();
1365 }
1366 } }
1368 b'{' => depth += 1,
1369 b'}' => {
1370 depth = depth.checked_sub(1)?;
1371 if depth == 0 {
1372 return Some(i);
1373 }
1374 }
1375 _ => {}
1376 }
1377 i += 1;
1378 }
1379 None
1380 }
1381
1382 fn is_ident_byte(b: &[u8], i: usize) -> bool {
1385 i > 0 && (b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_')
1386 }
1387
1388 fn env_like_literals(code: &str) -> Vec<String> {
1390 let re = regex::Regex::new(r#""([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)""#).unwrap();
1391 re.captures_iter(code).map(|c| c[1].to_string()).collect()
1392 }
1393
1394 fn is_secret_shaped(name: &str) -> bool {
1395 [
1396 "_API_KEY",
1397 "_APP_KEY",
1398 "_TOKEN",
1399 "_SECRET",
1400 "_PRIVATE_KEY",
1401 "_PASSWORD",
1402 "_PASSPHRASE",
1403 "_CREDENTIALS",
1404 ]
1405 .iter()
1406 .any(|s| name.ends_with(s))
1407 }
1408
1409 fn is_known(name: &str) -> bool {
1410 SECRET_ENV_VARS.contains(&name) || EXEMPT_SECRET_ENV_VARS.iter().any(|(n, _)| *n == name)
1411 }
1412
1413 #[test]
1416 fn every_secret_shaped_literal_is_registered_or_exempt() {
1417 let mut unknown = Vec::new();
1418 for source in production_sources() {
1419 for name in env_like_literals(&source.code) {
1420 if is_secret_shaped(&name) && !is_known(&name) {
1421 unknown.push(format!("{}: {name}", source.rel)); }
1423 }
1424 }
1425 assert!(
1426 unknown.is_empty(),
1427 "secret-shaped variables must be added to SECRET_ENV_VARS (and read via \
1428 secret_var) or to EXEMPT_SECRET_ENV_VARS with a reason (STYLE-0030): \
1429 {unknown:#?}"
1430 );
1431 }
1432
1433 #[test]
1436 fn registered_secrets_are_never_read_through_plain_accessors() {
1437 let sources = production_sources();
1438 let const_re =
1439 regex::Regex::new(r#"const ([A-Z][A-Z0-9_]*): &str = "([A-Z0-9_]+)";"#).unwrap();
1440 let mut spellings: Vec<(String, &str)> = SECRET_ENV_VARS
1443 .iter()
1444 .map(|n| (format!("\"{n}\""), *n))
1445 .collect();
1446 for source in &sources {
1447 for c in const_re.captures_iter(&source.code) {
1448 if let Some(name) = SECRET_ENV_VARS.iter().find(|n| **n == &c[2]) {
1449 spellings.push((c[1].to_string(), name));
1450 }
1451 }
1452 }
1453 let ident_re = |s: &str| regex::Regex::new(&format!(r"\b{}\b", regex::escape(s))).unwrap();
1454 let spelling_res: Vec<_> = spellings
1455 .iter()
1456 .map(|(s, n)| {
1457 let re = if s.starts_with('"') {
1458 regex::Regex::new(®ex::escape(s)).unwrap()
1459 } else {
1460 ident_re(s)
1461 };
1462 (re, *n)
1463 })
1464 .collect();
1465 let call_re = regex::Regex::new(
1466 r"(?:\.var|\bvar_any|\bget_env_var|\bnon_empty_var|\benv::var|\bvar_os|\btruthy_var)\s*\(",
1467 )
1468 .unwrap();
1469
1470 let mut offenders = Vec::new();
1471 for source in &sources {
1472 if source.rel == "utils/secret_env.rs" {
1473 continue;
1474 }
1475 for m in call_re.find_iter(&source.code) {
1476 let args = balanced_args(&source.code[m.end()..]);
1477 for (re, name) in &spelling_res {
1478 if re.is_match(args) {
1479 offenders.push(format!("{}: `{}…` reads {name}", source.rel, m.as_str()));
1481 }
1483 }
1484 }
1485 }
1486 assert!(
1487 offenders.is_empty(),
1488 "registered secrets must be read through utils::secret_env (STYLE-0030): \
1489 {offenders:#?}"
1490 );
1491 }
1492
1493 fn balanced_args(after_paren: &str) -> &str {
1495 let mut depth = 1usize;
1496 for (i, c) in after_paren.char_indices() {
1497 match c {
1498 '(' => depth += 1,
1499 ')' => {
1500 depth -= 1;
1501 if depth == 0 {
1502 return &after_paren[..i];
1503 }
1504 }
1505 _ => {}
1506 }
1507 }
1508 after_paren
1509 }
1510
1511 #[test]
1516 fn no_registered_file_companion_collides_with_an_existing_variable() {
1517 let companions: Vec<String> = SECRET_ENV_VARS
1518 .iter()
1519 .flat_map(|n| [file_var_name(n), command_var_name(n)])
1520 .collect();
1521 let mut collisions = Vec::new();
1522 for source in production_sources() {
1523 for name in env_like_literals(&source.code) {
1524 if companions.contains(&name) {
1525 collisions.push(format!("{}: {name}", source.rel)); }
1527 }
1528 }
1529 assert!(
1530 collisions.is_empty(),
1531 "a registered secret's _FILE or _COMMAND companion is already a variable with \
1532 another meaning; rename it or exempt the secret (ADR-0089, ADR-0090): \
1533 {collisions:#?}"
1534 );
1535 assert!(!SECRET_ENV_VARS.contains(&"GMAIL_CLIENT_SECRET"));
1537 }
1538
1539 #[test]
1544 fn only_the_resolver_writers_and_scrub_name_command_companions() {
1545 const ALLOWED: [&str; 4] = [
1546 "utils/secret_env.rs",
1547 "utils/secret_env/command.rs",
1548 "utils/settings.rs",
1549 "claude/ai/claude_cli.rs",
1550 ];
1551 let mut strays = Vec::new();
1552 for source in production_sources() {
1553 let mentions =
1554 source.code.contains("command_var_name(") || source.code.contains("COMMAND_SUFFIX");
1555 if mentions && !ALLOWED.contains(&source.rel.as_str()) {
1556 strays.push(source.rel); }
1558 }
1559 assert!(
1560 strays.is_empty(),
1561 "read secrets through secret_var, not by naming their _COMMAND companion \
1562 (ADR-0090): {strays:?}"
1563 );
1564 }
1565
1566 #[test]
1569 fn strip_test_modules_removes_only_test_blocks() {
1570 let text = "fn a() {}\n#[cfg(test)]\nfn helper() {}\n#[cfg(test)]\n\
1571 #[allow(x)]\nmod tests {\n fn t() { if x { } }\n}\nfn b() {}\n";
1572 let stripped = strip_test_modules("t.rs", text);
1573 assert!(stripped.contains("fn a()"));
1574 assert!(stripped.contains("fn helper()"));
1575 assert!(stripped.contains("fn b()"));
1576 assert!(!stripped.contains("fn t()"));
1577 }
1578
1579 #[test]
1582 fn strip_test_modules_ignores_braces_in_literals_and_comments() {
1583 let text = "#[cfg(test)]\nmod tests {\n let a = '{'; let b = \"}}\\\"{\";\n \
1584 let c = r#\"{\"#; // }\n /* { */ fn f<'a>(x: &'a str) {}\n}\nfn after() {}\n";
1585 let stripped = strip_test_modules("t.rs", text);
1586 assert!(stripped.contains("fn after()"), "{stripped}");
1587 assert!(!stripped.contains("fn f<"), "{stripped}");
1588 }
1589
1590 #[test]
1594 fn matching_brace_returns_none_when_unclosed() {
1595 assert_eq!(matching_brace("{ fn f() { }"), None);
1596 }
1597
1598 #[test]
1603 fn balanced_args_counts_nested_parens_and_degrades_when_unclosed() {
1604 assert_eq!(balanced_args("f(x), y) rest"), "f(x), y");
1605 assert_eq!(balanced_args("no closing paren"), "no closing paren");
1606 }
1607
1608 #[test]
1611 fn resolve_secret_pair_uses_the_given_labels_in_a_conflict() {
1612 let err = resolve_secret_pair(
1613 "drive.accounts.work.client_secret",
1614 Some("v"),
1615 "drive.accounts.work.client_secret_file",
1616 Some("/x"),
1617 )
1618 .unwrap_err();
1619 assert_eq!(
1620 err.to_string(),
1621 "both drive.accounts.work.client_secret and \
1622 drive.accounts.work.client_secret_file are set; set only one of them"
1623 );
1624 }
1625
1626 #[test]
1627 fn resolve_secret_pair_treats_empty_members_as_unset() {
1628 assert!(resolve_secret_pair("a", Some(""), "a_file", Some(""))
1629 .unwrap()
1630 .is_none());
1631 let resolved = resolve_secret_pair("a", Some("v"), "a_file", Some("")).unwrap();
1632 assert_eq!(resolved.unwrap().expose_secret(), "v");
1633 }
1634
1635 #[test]
1636 fn write_secret_file_creates_an_owner_only_file_the_reader_accepts() {
1637 let dir = tempfile::tempdir().unwrap();
1638 let path = dir.path().join("token");
1639 let written =
1640 write_secret_file("k_file", &path, &Secret::new(SECRET_BYTES.to_string())).unwrap();
1641 assert!(written);
1642 assert_eq!(
1643 read_secret_file("k_file", &path).unwrap().expose_secret(),
1644 SECRET_BYTES
1645 );
1646 #[cfg(unix)]
1647 {
1648 use std::os::unix::fs::PermissionsExt;
1649 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1650 assert_eq!(mode & 0o777, 0o600);
1651 }
1652 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1654 }
1655
1656 #[test]
1657 fn write_secret_file_replaces_a_different_value() {
1658 let (_dir, path) = secret_file(b"old\n", 0o600);
1659 assert!(write_secret_file("k_file", &path, &Secret::new("new".to_string())).unwrap());
1660 assert_eq!(
1661 read_secret_file("k_file", &path).unwrap().expose_secret(),
1662 "new"
1663 );
1664 }
1665
1666 #[cfg(unix)]
1667 #[test]
1668 fn write_secret_file_leaves_an_unchanged_file_alone() {
1669 use std::os::unix::fs::MetadataExt;
1670 let (_dir, path) = secret_file(b"same\n", 0o600);
1671 let inode = std::fs::metadata(&path).unwrap().ino();
1672 assert!(!write_secret_file("k_file", &path, &Secret::new("same".to_string())).unwrap());
1673 assert_eq!(std::fs::metadata(&path).unwrap().ino(), inode);
1674 }
1675
1676 #[cfg(unix)]
1677 #[test]
1678 fn write_secret_file_writes_through_a_symlink_to_its_target() {
1679 let (dir, target) = secret_file(b"old\n", 0o600);
1680 let link = dir.path().join("link");
1681 std::os::unix::fs::symlink(&target, &link).unwrap();
1682 assert!(write_secret_file("k_file", &link, &Secret::new("new".to_string())).unwrap());
1683 assert!(std::fs::symlink_metadata(&link)
1684 .unwrap()
1685 .file_type()
1686 .is_symlink());
1687 assert_eq!(
1688 read_secret_file("k_file", &target).unwrap().expose_secret(),
1689 "new"
1690 );
1691 }
1692
1693 #[test]
1694 fn write_secret_file_rejects_a_relative_path() {
1695 let err = write_secret_file(
1696 "k_file",
1697 Path::new("rel/token"),
1698 &Secret::new("v".to_string()),
1699 )
1700 .unwrap_err();
1701 assert!(matches!(err, SecretEnvError::RelativePath { .. }), "{err}");
1702 }
1703
1704 #[test]
1705 fn write_secret_file_reports_a_missing_directory_without_the_secret() {
1706 let dir = tempfile::tempdir().unwrap();
1707 let path = dir.path().join("missing").join("token");
1708 let err =
1709 write_secret_file("k_file", &path, &Secret::new(SECRET_BYTES.to_string())).unwrap_err();
1710 assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1711 let message = err.to_string();
1712 assert!(message.contains("k_file"), "{message}");
1713 assert!(!message.contains(SECRET_BYTES), "{message}");
1714 }
1715
1716 #[test]
1717 fn write_secret_file_replaces_an_empty_placeholder() {
1718 let (_dir, path) = secret_file(b"", 0o600);
1719 assert!(write_secret_file("k_file", &path, &Secret::new("v".to_string())).unwrap());
1720 assert_eq!(
1721 read_secret_file("k_file", &path).unwrap().expose_secret(),
1722 "v"
1723 );
1724 }
1725
1726 #[cfg(unix)]
1727 #[test]
1728 fn write_secret_file_refuses_to_replace_a_file_the_reader_rejects() {
1729 let (_dir, path) = secret_file(b"old\n", 0o644);
1730 let err = write_secret_file("k_file", &path, &Secret::new("new".to_string())).unwrap_err();
1731 assert!(
1732 matches!(err, SecretEnvError::LoosePermissions { .. }),
1733 "{err}"
1734 );
1735 assert_eq!(std::fs::read(&path).unwrap(), b"old\n");
1736 }
1737
1738 #[test]
1739 fn write_secret_file_refuses_to_replace_a_json_file() {
1740 let json = b"{\"installed\": {\"client_secret\": \"s\"}}\n";
1741 let (_dir, path) = secret_file(json, 0o600);
1742 let err = write_secret_file("k_file", &path, &Secret::new("s".to_string())).unwrap_err();
1743 assert!(matches!(err, SecretEnvError::HoldsJson { .. }), "{err}");
1744 assert!(!err.to_string().contains("installed"), "{err}");
1745 assert_eq!(std::fs::read(&path).unwrap(), json);
1746 }
1747
1748 #[cfg(unix)]
1749 #[test]
1750 fn write_secret_file_refuses_a_dangling_symlink() {
1751 let dir = tempfile::tempdir().unwrap();
1752 let link = dir.path().join("link");
1753 std::os::unix::fs::symlink(dir.path().join("absent"), &link).unwrap();
1754 let err = write_secret_file("k_file", &link, &Secret::new("v".to_string())).unwrap_err();
1755 assert!(matches!(err, SecretEnvError::Unreadable { .. }), "{err}");
1756 assert!(std::fs::symlink_metadata(&link)
1757 .unwrap()
1758 .file_type()
1759 .is_symlink());
1760 assert!(!dir.path().join("absent").exists());
1761 }
1762
1763 #[test]
1764 fn plan_secret_file_write_reports_whether_it_replaces_a_file() {
1765 let (dir, path) = secret_file(b"old\n", 0o600);
1766 let secret = Secret::new("new".to_string());
1767 let replace = plan_secret_file_write("k_file", &path, &secret)
1768 .unwrap()
1769 .unwrap();
1770 assert!(replace.replaces_existing());
1771 let create = plan_secret_file_write("k_file", &dir.path().join("fresh"), &secret)
1772 .unwrap()
1773 .unwrap();
1774 assert!(!create.replaces_existing());
1775 assert_eq!(std::fs::read(&path).unwrap(), b"old\n");
1777 assert!(!dir.path().join("fresh").exists());
1778 }
1779
1780 #[test]
1785 fn plan_secret_file_write_reports_a_non_not_found_stat_error() {
1786 let dir = tempfile::tempdir().unwrap();
1787 let not_a_dir = dir.path().join("not-a-dir");
1788 std::fs::write(¬_a_dir, b"x").unwrap();
1789 let path = not_a_dir.join("secret");
1790 let err =
1791 plan_secret_file_write("k_file", &path, &Secret::new("v".to_string())).unwrap_err();
1792 assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1793 }
1794
1795 #[test]
1800 fn write_secret_file_reports_an_io_error_from_an_unwritable_directory() {
1801 let dir = tempfile::tempdir().unwrap();
1802 let path = dir.path().join("secret");
1803 set_mode(dir.path(), 0o500);
1804 let err = write_secret_file("k_file", &path, &Secret::new("v".to_string())).unwrap_err();
1805 set_mode(dir.path(), 0o700);
1806 assert!(matches!(err, SecretEnvError::Unwritable { .. }), "{err}");
1807 assert!(!path.exists());
1808 }
1809
1810 #[test]
1813 fn guards_scan_the_real_read_sites() {
1814 let sources = production_sources();
1815 let all: Vec<String> = sources
1816 .iter()
1817 .flat_map(|s| env_like_literals(&s.code))
1818 .collect();
1819 for name in ["DATADOG_API_KEY", "OMNI_BRIDGE_TOKEN", "CLAUDE_API_KEY"] {
1820 assert!(all.iter().any(|n| n == name), "{name} not found");
1821 }
1822 }
1823}