1use std::ffi::CString;
2use std::fs::{self, File};
3use std::io::{self, Write};
4use std::os::unix::io::FromRawFd;
5use std::path::{Path, PathBuf};
6
7pub fn bytes_to_hex(bytes: &[u8]) -> String {
8 bytes.iter().map(|b| format!("{:02x}", b)).collect()
9}
10
11fn current_uid() -> u32 {
12 unsafe { libc::getuid() }
13}
14
15fn validate_regular_file_fd(fd: i32) -> io::Result<()> {
16 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
17 if unsafe { libc::fstat(fd, &mut stat) } != 0 {
18 return Err(io::Error::last_os_error());
19 }
20 if (stat.st_mode & libc::S_IFMT) != libc::S_IFREG {
21 return Err(io::Error::new(
22 io::ErrorKind::InvalidInput,
23 "path is not a regular file",
24 ));
25 }
26 let uid = current_uid();
27 if stat.st_uid != uid && stat.st_uid != 0 {
28 return Err(io::Error::new(
29 io::ErrorKind::PermissionDenied,
30 "file owned by unexpected user",
31 ));
32 }
33 let mode = stat.st_mode & 0o7777;
34 if mode & 0o077 != 0 {
35 return Err(io::Error::new(
36 io::ErrorKind::PermissionDenied,
37 "file has insecure permissions",
38 ));
39 }
40 Ok(())
41}
42
43pub fn create_secure_file<P: AsRef<Path>>(path: P) -> io::Result<File> {
44 let path = path.as_ref();
45 let c_path = path_to_cstring(path)?;
46
47 let flags = libc::O_WRONLY | libc::O_CREAT | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_TRUNC;
48 let fd = unsafe { libc::open(c_path.as_ptr(), flags, 0o600u32 as libc::mode_t) };
49 if fd < 0 {
50 return Err(io::Error::last_os_error());
51 }
52
53 if let Err(e) = validate_regular_file_fd(fd) {
54 unsafe { libc::close(fd) };
55 return Err(e);
56 }
57
58 Ok(unsafe { File::from_raw_fd(fd) })
59}
60
61#[allow(dead_code)]
62pub fn write_secure_file<P: AsRef<Path>>(path: P, data: &[u8]) -> io::Result<()> {
63 let mut file = create_secure_file(path)?;
64 file.write_all(data)
65}
66
67pub fn open_dir_fd<P: AsRef<Path>>(path: P) -> io::Result<i32> {
68 let path = path.as_ref();
69 let c_path = path_to_cstring(path)?;
70
71 let mut lstat_buf: libc::stat = unsafe { std::mem::zeroed() };
72 if unsafe { libc::lstat(c_path.as_ptr(), &mut lstat_buf) } != 0 {
73 return Err(io::Error::last_os_error());
74 }
75 if (lstat_buf.st_mode & libc::S_IFMT) == libc::S_IFLNK {
76 return Err(io::Error::new(
77 io::ErrorKind::InvalidInput,
78 "path is a symbolic link",
79 ));
80 }
81
82 let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
83 let fd = unsafe { libc::open(c_path.as_ptr(), flags) };
84 if fd < 0 {
85 return Err(io::Error::last_os_error());
86 }
87
88 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
89 if unsafe { libc::fstat(fd, &mut stat) } != 0 {
90 let err = io::Error::last_os_error();
91 unsafe { libc::close(fd) };
92 return Err(err);
93 }
94 if (stat.st_mode & libc::S_IFMT) != libc::S_IFDIR {
95 unsafe { libc::close(fd) };
96 return Err(io::Error::new(
97 io::ErrorKind::InvalidInput,
98 "path is not a directory",
99 ));
100 }
101 let uid = current_uid();
102 if stat.st_uid != uid && stat.st_uid != 0 {
103 unsafe { libc::close(fd) };
104 return Err(io::Error::new(
105 io::ErrorKind::PermissionDenied,
106 "directory owned by unexpected user",
107 ));
108 }
109 let mode = stat.st_mode & 0o7777;
110 if mode & 0o077 != 0 {
111 unsafe { libc::close(fd) };
112 return Err(io::Error::new(
113 io::ErrorKind::PermissionDenied,
114 "directory has insecure permissions",
115 ));
116 }
117
118 Ok(fd)
119}
120
121pub fn atomic_write_in_dir<P: AsRef<Path>>(dir: P, filename: &str, data: &[u8]) -> io::Result<()> {
122 let dir = dir.as_ref();
123 let dir_fd = open_dir_fd(dir)?;
124
125 let tmp_name = format!(".tmp.{}.{}", std::process::id(), filename);
126 let c_tmp = CString::new(tmp_name.as_bytes())
127 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid temp filename"))?;
128 let c_final = CString::new(filename.as_bytes())
129 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid filename"))?;
130
131 let flags = libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC;
132 let tmp_fd = unsafe { libc::openat(dir_fd, c_tmp.as_ptr(), flags, 0o600u32 as libc::mode_t) };
133 if tmp_fd < 0 {
134 let err = io::Error::last_os_error();
135 unsafe { libc::close(dir_fd) };
136 return Err(err);
137 }
138
139 let result = (|| -> io::Result<()> {
140 let mut file = unsafe { File::from_raw_fd(tmp_fd) };
141 file.write_all(data)?;
142 file.sync_all()?;
143 Ok(())
144 })();
145
146 if let Err(e) = result {
147 unsafe {
148 libc::unlinkat(dir_fd, c_tmp.as_ptr(), 0);
149 libc::close(dir_fd);
150 }
151 return Err(e);
152 }
153
154 let rename_ret = unsafe { libc::renameat(dir_fd, c_tmp.as_ptr(), dir_fd, c_final.as_ptr()) };
155 if rename_ret != 0 {
156 let err = io::Error::last_os_error();
157 unsafe {
158 libc::unlinkat(dir_fd, c_tmp.as_ptr(), 0);
159 libc::close(dir_fd);
160 }
161 return Err(err);
162 }
163
164 unsafe { libc::close(dir_fd) };
165 Ok(())
166}
167
168fn path_to_cstring(path: &Path) -> io::Result<CString> {
169 let bytes = path.as_os_str().as_encoded_bytes();
170 CString::new(bytes)
171 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains null byte"))
172}
173
174fn validate_existing_dir(path: &Path, require_private: bool) -> io::Result<()> {
175 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
176 let c_path = path_to_cstring(path)?;
177 if unsafe { libc::lstat(c_path.as_ptr(), &mut stat) } != 0 {
178 return Err(io::Error::last_os_error());
179 }
180 if (stat.st_mode & libc::S_IFMT) == libc::S_IFLNK {
181 return Err(io::Error::new(
182 io::ErrorKind::InvalidInput,
183 format!("symlink detected at {}", path.display()),
184 ));
185 }
186 if (stat.st_mode & libc::S_IFMT) != libc::S_IFDIR {
187 return Err(io::Error::new(
188 io::ErrorKind::InvalidInput,
189 format!("path component {} is not a directory", path.display()),
190 ));
191 }
192 let uid = current_uid();
193 if stat.st_uid != uid && stat.st_uid != 0 {
194 return Err(io::Error::new(
195 io::ErrorKind::PermissionDenied,
196 format!(
197 "path component {} owned by uid {} but current uid is {}",
198 path.display(),
199 stat.st_uid,
200 uid
201 ),
202 ));
203 }
204 let mode = stat.st_mode & 0o7777;
205 let disallowed_mode = if require_private { 0o077 } else { 0o022 };
206 if uid == stat.st_uid && mode & disallowed_mode != 0 {
207 return Err(io::Error::new(
208 io::ErrorKind::PermissionDenied,
209 format!(
210 "path component {} has insecure permissions {:o}",
211 path.display(),
212 mode
213 ),
214 ));
215 }
216 Ok(())
217}
218
219fn create_single_dir(path: &Path, require_private: bool) -> io::Result<()> {
220 let c_path = path_to_cstring(path)?;
221 let ret = unsafe { libc::mkdir(c_path.as_ptr(), 0o700) };
222 if ret != 0 {
223 let err = io::Error::last_os_error();
224 if err.kind() == io::ErrorKind::AlreadyExists {
225 validate_existing_dir(path, require_private)?;
226 return Ok(());
227 }
228 return Err(err);
229 }
230 validate_existing_dir(path, require_private)
231}
232
233pub fn create_secure_dir_all<P: AsRef<Path>>(path: P) -> io::Result<()> {
234 let path = path.as_ref();
235 let mut current = PathBuf::new();
236
237 let mut components = path.components().peekable();
238 while let Some(component) = components.next() {
239 current.push(component);
240 let require_private = components.peek().is_none();
241
242 if current == Path::new("/") {
243 continue;
244 }
245
246 match fs::symlink_metadata(¤t) {
247 Ok(meta) => {
248 if meta.file_type().is_symlink() {
249 return Err(io::Error::new(
250 io::ErrorKind::InvalidInput,
251 format!("symlink detected at {}", current.display()),
252 ));
253 }
254 if !meta.is_dir() {
255 return Err(io::Error::new(
256 io::ErrorKind::InvalidInput,
257 format!("path component {} is not a directory", current.display()),
258 ));
259 }
260 validate_existing_dir(¤t, require_private)?;
261 }
262 Err(_) => {
263 create_single_dir(¤t, require_private)?;
264 }
265 }
266 }
267
268 Ok(())
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use std::fs;
275 use std::os::unix::fs::{PermissionsExt, symlink};
276 use tempfile::tempdir;
277
278 #[test]
279 fn test_bytes_to_hex() {
280 assert_eq!(bytes_to_hex(&[]), "");
281 assert_eq!(bytes_to_hex(&[0x00]), "00");
282 assert_eq!(bytes_to_hex(&[0xff]), "ff");
283 assert_eq!(bytes_to_hex(&[0x01, 0x23, 0x45, 0x67]), "01234567");
284 assert_eq!(bytes_to_hex(&[0xab, 0xcd, 0xef]), "abcdef");
285 }
286
287 #[test]
288 fn test_create_secure_file() {
289 let dir = tempdir().expect("Failed to create temp dir");
290 let file_path = dir.path().join("test_file");
291
292 let file = create_secure_file(&file_path).expect("Failed to create secure file");
293 drop(file);
294
295 assert!(file_path.exists());
296
297 let metadata = fs::metadata(&file_path).expect("Failed to get metadata");
298 let mode = metadata.permissions().mode();
299 assert_eq!(mode & 0o777, 0o600, "File should have 0o600 permissions");
300 }
301
302 #[test]
303 fn test_write_secure_file() {
304 let dir = tempdir().expect("Failed to create temp dir");
305 let file_path = dir.path().join("test_file");
306
307 write_secure_file(&file_path, b"test data").expect("Failed to write secure file");
308
309 assert!(file_path.exists());
310
311 let metadata = fs::metadata(&file_path).expect("Failed to get metadata");
312 let mode = metadata.permissions().mode();
313 assert_eq!(mode & 0o777, 0o600, "File should have 0o600 permissions");
314
315 let contents = fs::read(&file_path).expect("Failed to read file");
316 assert_eq!(contents, b"test data");
317 }
318
319 #[test]
320 fn test_create_secure_dir_all() {
321 let dir = tempdir().expect("Failed to create temp dir");
322 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
323 let nested_path = dir.path().join("a/b/c");
324
325 create_secure_dir_all(&nested_path).expect("Failed to create secure directories");
326
327 assert!(nested_path.exists());
328
329 let metadata = fs::metadata(&nested_path).expect("Failed to get metadata");
330 assert!(metadata.is_dir());
331 let mode = metadata.permissions().mode();
332 assert_eq!(
333 mode & 0o777,
334 0o700,
335 "Directory a/b/c should have 0o700 permissions"
336 );
337 }
338
339 #[test]
340 fn test_create_secure_dir_all_existing_secure() {
341 let dir = tempdir().expect("Failed to create temp dir");
342 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
343 let path = dir.path().join("existing_dir");
344
345 fs::create_dir(&path).expect("Failed to create directory");
346 fs::set_permissions(&path, fs::Permissions::from_mode(0o700))
347 .expect("Failed to set permissions");
348
349 create_secure_dir_all(&path).expect("Should succeed for secure existing dir");
350 }
351
352 #[test]
353 fn test_create_secure_dir_all_accepts_non_writable_ancestor() {
354 let dir = tempdir().expect("Failed to create temp dir");
355 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
356 let ancestor = dir.path().join("ancestor");
357 let target = ancestor.join("passless");
358 fs::create_dir(&ancestor).unwrap();
359 fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o755)).unwrap();
360
361 create_secure_dir_all(&target).expect("non-writable ancestors should be accepted");
362
363 assert_eq!(
364 fs::metadata(&target).unwrap().permissions().mode() & 0o777,
365 0o700
366 );
367 }
368
369 #[test]
370 fn test_create_secure_file_rejects_symlink() {
371 let dir = tempdir().expect("Failed to create temp dir");
372 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
373 let real_file = dir.path().join("real_file");
374 let link_file = dir.path().join("link_file");
375
376 fs::write(&real_file, b"original").unwrap();
377 symlink(&real_file, &link_file).unwrap();
378
379 let result = create_secure_file(&link_file);
380 assert!(result.is_err(), "create_secure_file must reject symlinks");
381 }
382
383 #[test]
384 fn test_create_secure_dir_all_rejects_symlink_component() {
385 let dir = tempdir().expect("Failed to create temp dir");
386 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
387 let real_sub = dir.path().join("real_sub");
388 let link_sub = dir.path().join("link_sub");
389 fs::create_dir(&real_sub).unwrap();
390 fs::set_permissions(&real_sub, fs::Permissions::from_mode(0o700)).unwrap();
391 symlink(&real_sub, &link_sub).unwrap();
392
393 let target = link_sub.join("nested");
394 let result = create_secure_dir_all(&target);
395 assert!(result.is_err(), "must reject symlink in path components");
396 assert!(result.unwrap_err().to_string().contains("symlink"));
397 }
398
399 #[test]
400 fn test_create_secure_dir_all_rejects_insecure_existing_dir() {
401 let dir = tempdir().expect("Failed to create temp dir");
402 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
403 let insecure = dir.path().join("insecure");
404 fs::create_dir(&insecure).unwrap();
405 fs::set_permissions(&insecure, fs::Permissions::from_mode(0o755)).unwrap();
406
407 let result = create_secure_dir_all(&insecure);
408 assert!(
409 result.is_err(),
410 "must reject existing dir with insecure permissions"
411 );
412 assert!(result.unwrap_err().to_string().contains("insecure"));
413 }
414
415 #[test]
416 fn test_create_secure_dir_all_rejects_concurrent_symlink_replacement() {
417 let dir = tempdir().expect("Failed to create temp dir");
418 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
419 let real_a = dir.path().join("real_a");
420 let real_b = dir.path().join("real_b");
421 fs::create_dir(&real_a).unwrap();
422 fs::set_permissions(&real_a, fs::Permissions::from_mode(0o700)).unwrap();
423 fs::create_dir(&real_b).unwrap();
424 fs::set_permissions(&real_b, fs::Permissions::from_mode(0o700)).unwrap();
425
426 let target = dir.path().join("target");
427 symlink(&real_b, &target).unwrap();
428
429 let result = create_secure_dir_all(&target);
430 assert!(result.is_err());
431 assert!(result.unwrap_err().to_string().contains("symlink"));
432 }
433
434 #[test]
435 fn test_atomic_write_in_dir() {
436 let dir = tempdir().expect("Failed to create temp dir");
437 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
438
439 atomic_write_in_dir(dir.path(), "test.bin", b"credential data")
440 .expect("atomic write should succeed");
441
442 let contents = fs::read(dir.path().join("test.bin")).unwrap();
443 assert_eq!(contents, b"credential data");
444
445 let meta = fs::metadata(dir.path().join("test.bin")).unwrap();
446 assert_eq!(meta.permissions().mode() & 0o777, 0o600);
447 }
448
449 #[test]
450 fn test_atomic_write_overwrites_existing() {
451 let dir = tempdir().expect("Failed to create temp dir");
452 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
453
454 atomic_write_in_dir(dir.path(), "test.bin", b"first").unwrap();
455 atomic_write_in_dir(dir.path(), "test.bin", b"second").unwrap();
456
457 let contents = fs::read(dir.path().join("test.bin")).unwrap();
458 assert_eq!(contents, b"second");
459 }
460
461 #[test]
462 fn test_open_dir_fd_rejects_symlink() {
463 let dir = tempdir().expect("Failed to create temp dir");
464 let real = dir.path().join("real");
465 fs::create_dir(&real).unwrap();
466 fs::set_permissions(&real, fs::Permissions::from_mode(0o700)).unwrap();
467
468 let link = dir.path().join("link");
469 symlink(&real, &link).unwrap();
470
471 let result = open_dir_fd(&link);
472 assert!(result.is_err());
473 }
474
475 #[test]
476 fn test_open_dir_fd_rejects_insecure() {
477 let dir = tempdir().expect("Failed to create temp dir");
478 let insecure = dir.path().join("insecure");
479 fs::create_dir(&insecure).unwrap();
480 fs::set_permissions(&insecure, fs::Permissions::from_mode(0o755)).unwrap();
481
482 let result = open_dir_fd(&insecure);
483 assert!(result.is_err());
484 assert!(result.unwrap_err().to_string().contains("insecure"));
485 }
486}