Skip to main content

passless_rs/
util.rs

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(&current) {
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(&current, require_private)?;
261            }
262            Err(_) => {
263                create_single_dir(&current, 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}