vti_common/
secure_file.rs1use std::path::Path;
31
32pub fn restrict_file_to_owner(path: &Path) -> std::io::Result<()> {
38 #[cfg(unix)]
39 {
40 use std::os::unix::fs::PermissionsExt;
41 let mut perm = std::fs::metadata(path)?.permissions();
42 perm.set_mode(0o600);
43 std::fs::set_permissions(path, perm)?;
44 }
45 #[cfg(windows)]
46 {
47 apply_windows_user_only_dacl(path)?;
48 }
49 #[cfg(not(any(unix, windows)))]
50 {
51 let _ = path;
52 }
53 Ok(())
54}
55
56pub fn restrict_dir_to_owner(path: &Path) -> std::io::Result<()> {
60 #[cfg(unix)]
61 {
62 use std::os::unix::fs::PermissionsExt;
63 let mut perm = std::fs::metadata(path)?.permissions();
64 perm.set_mode(0o700);
65 std::fs::set_permissions(path, perm)?;
66 }
67 #[cfg(windows)]
68 {
69 apply_windows_user_only_dacl(path)?;
70 }
71 #[cfg(not(any(unix, windows)))]
72 {
73 let _ = path;
74 }
75 Ok(())
76}
77
78pub fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
97 let mut opts = std::fs::OpenOptions::new();
98 opts.write(true).create_new(true);
99 #[cfg(unix)]
100 {
101 use std::os::unix::fs::OpenOptionsExt;
102 opts.mode(0o600);
103 }
104 let file = opts.open(path)?;
105
106 if let Err(e) = write_and_harden(file, path, bytes) {
107 let _ = std::fs::remove_file(path);
108 return Err(e);
109 }
110 Ok(())
111}
112
113fn write_and_harden(mut file: std::fs::File, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
114 use std::io::Write;
115
116 file.write_all(bytes)?;
117 file.sync_all()?;
118 drop(file);
119 #[cfg(windows)]
120 restrict_file_to_owner(path)?;
121 #[cfg(not(windows))]
122 let _ = path;
123 Ok(())
124}
125
126#[cfg(windows)]
127fn apply_windows_user_only_dacl(path: &Path) -> std::io::Result<()> {
128 use std::process::Command;
129
130 let user = std::env::var("USERNAME").map_err(|_| {
138 std::io::Error::new(
139 std::io::ErrorKind::NotFound,
140 "USERNAME env var not set — cannot apply Windows user-only DACL",
141 )
142 })?;
143 let user_trimmed = user.trim();
144 if user_trimmed.is_empty() {
145 return Err(std::io::Error::new(
146 std::io::ErrorKind::InvalidData,
147 "USERNAME is empty — cannot apply Windows user-only DACL",
148 ));
149 }
150
151 let path_str = path.to_str().ok_or_else(|| {
152 std::io::Error::new(
153 std::io::ErrorKind::InvalidInput,
154 "path is not valid UTF-8 — cannot pass to icacls",
155 )
156 })?;
157
158 let output = Command::new("icacls")
163 .arg(path_str)
164 .arg("/inheritance:r")
165 .arg("/grant:r")
166 .arg(format!("{user_trimmed}:(F)"))
167 .output()?;
168
169 if !output.status.success() {
170 return Err(std::io::Error::other(format!(
171 "icacls failed ({}): {}",
172 output.status,
173 String::from_utf8_lossy(&output.stderr).trim()
174 )));
175 }
176 Ok(())
177}
178
179#[cfg(all(test, unix))]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn restrict_file_sets_0600_on_unix() {
185 use std::os::unix::fs::PermissionsExt;
186 let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
187 std::fs::create_dir_all(&tmp).unwrap();
188 let f = tmp.join("secret.bin");
189 std::fs::write(&f, b"sensitive").unwrap();
190
191 let mut perm = std::fs::metadata(&f).unwrap().permissions();
193 perm.set_mode(0o644);
194 std::fs::set_permissions(&f, perm).unwrap();
195
196 restrict_file_to_owner(&f).expect("restrict_file_to_owner succeeds");
197
198 let mode = std::fs::metadata(&f).unwrap().permissions().mode();
199 assert_eq!(mode & 0o777, 0o600);
200 let _ = std::fs::remove_dir_all(&tmp);
201 }
202
203 #[test]
204 fn restrict_dir_sets_0700_on_unix() {
205 use std::os::unix::fs::PermissionsExt;
206 let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
207 std::fs::create_dir_all(&tmp).unwrap();
208
209 let mut perm = std::fs::metadata(&tmp).unwrap().permissions();
210 perm.set_mode(0o755);
211 std::fs::set_permissions(&tmp, perm).unwrap();
212
213 restrict_dir_to_owner(&tmp).expect("restrict_dir_to_owner succeeds");
214
215 let mode = std::fs::metadata(&tmp).unwrap().permissions().mode();
216 assert_eq!(mode & 0o777, 0o700);
217 let _ = std::fs::remove_dir_all(&tmp);
218 }
219
220 #[test]
223 fn write_secret_file_is_0600_under_a_022_umask() {
224 use std::os::unix::fs::PermissionsExt;
225 let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
226 std::fs::create_dir_all(&tmp).unwrap();
227 let f = tmp.join("export.vtcbak");
228
229 let previous = unsafe { libc::umask(0o022) };
232 let result = write_secret_file(&f, b"sensitive");
233 unsafe { libc::umask(previous) };
234 result.expect("write_secret_file succeeds");
235
236 let mode = std::fs::metadata(&f).unwrap().permissions().mode();
237 assert_eq!(mode & 0o777, 0o600, "got {:o}", mode & 0o777);
238 assert_eq!(std::fs::read(&f).unwrap(), b"sensitive");
239 let _ = std::fs::remove_dir_all(&tmp);
240 }
241
242 #[test]
243 fn write_secret_file_refuses_an_existing_path() {
244 let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
245 std::fs::create_dir_all(&tmp).unwrap();
246 let f = tmp.join("export.vtcbak");
247 std::fs::write(&f, b"original").unwrap();
248
249 let err = write_secret_file(&f, b"replacement").unwrap_err();
250 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
251 assert_eq!(std::fs::read(&f).unwrap(), b"original");
253 let _ = std::fs::remove_dir_all(&tmp);
254 }
255
256 #[test]
257 fn write_secret_file_does_not_follow_a_symlink() {
258 let tmp = std::env::temp_dir().join(format!("vta-test-secure-{}", rand::random::<u32>()));
259 std::fs::create_dir_all(&tmp).unwrap();
260 let target = tmp.join("elsewhere");
261 let link = tmp.join("export.vtcbak");
262 std::os::unix::fs::symlink(&target, &link).unwrap();
263
264 let err = write_secret_file(&link, b"sensitive").unwrap_err();
265 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
266 assert!(!target.exists(), "the symlink target must not be created");
267 let _ = std::fs::remove_dir_all(&tmp);
268 }
269}