1use std::io::Write;
2use std::path::Path;
3
4pub fn write_secret_file(path: &Path, content: &str) -> anyhow::Result<()> {
14 let dir = path.parent().unwrap_or_else(|| Path::new("."));
15 let file_name = path
16 .file_name()
17 .and_then(|n| n.to_str())
18 .ok_or_else(|| anyhow::anyhow!("cannot write a secret to {}", path.display()))?;
19 let temp = dir.join(format!(".{}.{}.tmp", file_name, std::process::id()));
20
21 let _ = std::fs::remove_file(&temp);
24
25 let result = (|| -> anyhow::Result<()> {
26 let mut options = std::fs::OpenOptions::new();
27 options.write(true).create_new(true);
28 #[cfg(unix)]
29 {
30 use std::os::unix::fs::OpenOptionsExt;
31 options.mode(0o600);
32 }
33 let mut file = options.open(&temp)?;
34 file.write_all(content.as_bytes())?;
35 file.sync_all()?;
36 drop(file);
37 std::fs::rename(&temp, path)?;
38 Ok(())
39 })();
40
41 if result.is_err() {
42 let _ = std::fs::remove_file(&temp);
43 }
44 result
45}
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50
51 #[test]
52 fn secret_file_is_owner_only() {
53 let tmp = tempfile::tempdir().unwrap();
54 let path = tmp.path().join("secret");
55 write_secret_file(&path, "value").unwrap();
56 assert_eq!(std::fs::read_to_string(&path).unwrap(), "value");
57 #[cfg(unix)]
58 {
59 use std::os::unix::fs::PermissionsExt;
60 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
61 assert_eq!(mode & 0o777, 0o600);
62 }
63 }
64
65 #[test]
66 fn rewriting_a_loose_file_tightens_it() {
67 let tmp = tempfile::tempdir().unwrap();
68 let path = tmp.path().join("secret");
69 std::fs::write(&path, "old").unwrap();
70 #[cfg(unix)]
71 {
72 use std::os::unix::fs::PermissionsExt;
73 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
74 }
75 write_secret_file(&path, "new").unwrap();
76 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
77 #[cfg(unix)]
78 {
79 use std::os::unix::fs::PermissionsExt;
80 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
81 assert_eq!(mode & 0o777, 0o600);
82 }
83 }
84
85 #[cfg(unix)]
89 #[test]
90 fn the_new_secret_never_lives_in_the_old_loose_inode() {
91 use std::os::unix::fs::MetadataExt;
92 use std::os::unix::fs::PermissionsExt;
93
94 let tmp = tempfile::tempdir().unwrap();
95 let path = tmp.path().join("secret");
96 std::fs::write(&path, "old").unwrap();
97 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
98 let old_inode = std::fs::metadata(&path).unwrap().ino();
99
100 let stale = std::fs::File::open(&path).unwrap();
103
104 write_secret_file(&path, "new-secret").unwrap();
105
106 let new_inode = std::fs::metadata(&path).unwrap().ino();
107 assert_ne!(
108 old_inode, new_inode,
109 "the secret must land in a fresh 0600 inode, not the existing 0644 one"
110 );
111 let mut through_stale = String::new();
112 {
113 use std::io::Read;
114 let mut stale = stale;
115 stale.read_to_string(&mut through_stale).unwrap();
116 }
117 assert_eq!(
118 through_stale, "old",
119 "a handle opened before the write must never observe the new secret"
120 );
121 assert_eq!(
122 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
123 0o600
124 );
125 }
126
127 #[cfg(unix)]
128 #[test]
129 fn a_leftover_temp_file_does_not_taint_the_write() {
130 use std::os::unix::fs::PermissionsExt;
131 let tmp = tempfile::tempdir().unwrap();
132 let path = tmp.path().join("secret");
133 let stale_temp = tmp
134 .path()
135 .join(format!(".secret.{}.tmp", std::process::id()));
136 std::fs::write(&stale_temp, "junk").unwrap();
137 std::fs::set_permissions(&stale_temp, std::fs::Permissions::from_mode(0o666)).unwrap();
138
139 write_secret_file(&path, "value").unwrap();
140
141 assert_eq!(std::fs::read_to_string(&path).unwrap(), "value");
142 assert_eq!(
143 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
144 0o600
145 );
146 }
147}