1use std::fs::{File, OpenOptions};
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14#[cfg(unix)]
16pub const FILE_MODE: u32 = 0o600;
17#[cfg(unix)]
19pub const DIR_MODE: u32 = 0o700;
20
21#[derive(Debug)]
23pub enum PrivateFsError {
24 SymlinkRefused(PathBuf),
26 Io(std::io::Error),
28}
29
30impl std::fmt::Display for PrivateFsError {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 PrivateFsError::SymlinkRefused(p) => {
34 write!(f, "refusing to write through symlink at {}", p.display())
35 }
36 PrivateFsError::Io(e) => write!(f, "private fs io error: {e}"),
37 }
38 }
39}
40
41impl std::error::Error for PrivateFsError {}
42
43impl From<std::io::Error> for PrivateFsError {
44 fn from(e: std::io::Error) -> Self {
45 PrivateFsError::Io(e)
46 }
47}
48
49impl From<PrivateFsError> for std::io::Error {
50 fn from(e: PrivateFsError) -> Self {
51 match e {
52 PrivateFsError::Io(io) => io,
53 other => std::io::Error::new(std::io::ErrorKind::PermissionDenied, other.to_string()),
54 }
55 }
56}
57
58fn refuse_symlink(path: &Path) -> Result<(), PrivateFsError> {
60 match std::fs::symlink_metadata(path) {
61 Ok(meta) if meta.file_type().is_symlink() => {
62 Err(PrivateFsError::SymlinkRefused(path.to_path_buf()))
63 }
64 _ => Ok(()),
65 }
66}
67
68pub fn ensure_private_dir(dir: &Path) -> Result<(), PrivateFsError> {
71 #[cfg(unix)]
72 {
73 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
74 std::fs::DirBuilder::new()
75 .recursive(true)
76 .mode(DIR_MODE)
77 .create(dir)?;
78 let meta = std::fs::metadata(dir)?;
80 if meta.permissions().mode() & 0o777 != DIR_MODE {
81 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(DIR_MODE))?;
82 }
83 }
84 #[cfg(not(unix))]
85 std::fs::create_dir_all(dir)?;
86 Ok(())
87}
88
89pub fn open_private_append(path: &Path) -> Result<File, PrivateFsError> {
94 refuse_symlink(path)?;
95 #[cfg(unix)]
96 {
97 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
98 let file = OpenOptions::new()
99 .create(true)
100 .append(true)
101 .mode(FILE_MODE)
102 .custom_flags(libc::O_NOFOLLOW)
103 .open(path)
104 .map_err(|e| {
105 if e.raw_os_error() == Some(libc::ELOOP) {
106 PrivateFsError::SymlinkRefused(path.to_path_buf())
107 } else {
108 PrivateFsError::Io(e)
109 }
110 })?;
111 let meta = file.metadata()?;
112 if meta.permissions().mode() & 0o777 != FILE_MODE {
113 file.set_permissions(std::fs::Permissions::from_mode(FILE_MODE))?;
114 }
115 Ok(file)
116 }
117 #[cfg(not(unix))]
118 {
119 Ok(OpenOptions::new().create(true).append(true).open(path)?)
120 }
121}
122
123pub fn write_atomic_private(path: &Path, data: &[u8]) -> Result<(), PrivateFsError> {
128 refuse_symlink(path)?;
129 let mut tmp_name = path
130 .file_name()
131 .ok_or_else(|| {
132 PrivateFsError::Io(std::io::Error::new(
133 std::io::ErrorKind::InvalidInput,
134 "path has no file name",
135 ))
136 })?
137 .to_os_string();
138 tmp_name.push(".tmp");
139 let tmp = path.with_file_name(tmp_name);
140 match std::fs::remove_file(&tmp) {
143 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
144 _ => {}
145 }
146 let result = (|| -> Result<(), PrivateFsError> {
147 #[cfg(unix)]
148 let mut file = {
149 use std::os::unix::fs::OpenOptionsExt;
150 OpenOptions::new()
151 .write(true)
152 .create_new(true)
153 .mode(FILE_MODE)
154 .open(&tmp)?
155 };
156 #[cfg(not(unix))]
157 let mut file = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
158 file.write_all(data)?;
159 file.sync_all()?;
160 drop(file);
161 refuse_symlink(path)?;
165 std::fs::rename(&tmp, path)?;
166 Ok(())
167 })();
168 if result.is_err() {
169 let _ = std::fs::remove_file(&tmp);
170 }
171 result
172}
173
174#[cfg(all(test, unix))]
179pub(crate) mod test_support {
180 pub(crate) struct UmaskGuard {
181 old: libc::mode_t,
182 }
183
184 impl UmaskGuard {
185 pub(crate) fn set(mask: libc::mode_t) -> Self {
186 Self {
187 old: unsafe { libc::umask(mask) },
188 }
189 }
190 }
191
192 impl Drop for UmaskGuard {
193 fn drop(&mut self) {
194 unsafe {
195 libc::umask(self.old);
196 }
197 }
198 }
199}
200
201#[cfg(all(test, unix))]
202mod tests {
203 use super::test_support::UmaskGuard;
204 use super::*;
205 use serial_test::serial;
206 use std::os::unix::fs::PermissionsExt;
207
208 fn mode_of(path: &Path) -> u32 {
209 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
210 }
211
212 #[test]
213 #[serial(umask)]
214 fn atomic_write_is_0600_and_dir_0700_under_permissive_umask() {
215 let _umask = UmaskGuard::set(0);
216 let tmp = tempfile::TempDir::new().unwrap();
217 let dir = tmp.path().join("private");
218 ensure_private_dir(&dir).unwrap();
219 let target = dir.join("state.json");
220 write_atomic_private(&target, b"{}").unwrap();
221 assert_eq!(mode_of(&dir), 0o700);
222 assert_eq!(mode_of(&target), 0o600);
223 }
224
225 #[test]
226 #[serial(umask)]
227 fn append_creates_0600_under_permissive_umask() {
228 let _umask = UmaskGuard::set(0);
229 let tmp = tempfile::TempDir::new().unwrap();
230 let target = tmp.path().join("log.jsonl");
231 let mut f = open_private_append(&target).unwrap();
232 f.write_all(b"line\n").unwrap();
233 assert_eq!(mode_of(&target), 0o600);
234 }
235
236 #[test]
237 fn append_refuses_symlink_with_typed_error() {
238 let tmp = tempfile::TempDir::new().unwrap();
239 let victim = tmp.path().join("victim");
240 std::fs::write(&victim, "").unwrap();
241 let link = tmp.path().join("link");
242 std::os::unix::fs::symlink(&victim, &link).unwrap();
243 match open_private_append(&link) {
244 Err(PrivateFsError::SymlinkRefused(p)) => assert_eq!(p, link),
245 other => panic!("expected SymlinkRefused, got {other:?}"),
246 }
247 }
248
249 #[test]
250 fn atomic_write_refuses_symlink_with_typed_error() {
251 let tmp = tempfile::TempDir::new().unwrap();
252 let victim = tmp.path().join("victim");
253 std::fs::write(&victim, "original").unwrap();
254 let link = tmp.path().join("link");
255 std::os::unix::fs::symlink(&victim, &link).unwrap();
256 match write_atomic_private(&link, b"new") {
257 Err(PrivateFsError::SymlinkRefused(p)) => assert_eq!(p, link),
258 other => panic!("expected SymlinkRefused, got {other:?}"),
259 }
260 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
261 }
262
263 #[test]
264 fn ensure_private_dir_repairs_broad_mode() {
265 let tmp = tempfile::TempDir::new().unwrap();
266 let dir = tmp.path().join("broad");
267 std::fs::create_dir(&dir).unwrap();
268 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
269 ensure_private_dir(&dir).unwrap();
270 assert_eq!(mode_of(&dir), 0o700);
271 }
272}
273
274#[cfg(unix)]
288#[derive(Debug)]
289pub struct ConfinedDir {
290 handle: File,
291}
292
293#[cfg(unix)]
294impl ConfinedDir {
295 pub fn create_root(path: &Path) -> std::io::Result<Self> {
299 use std::os::unix::fs::DirBuilderExt;
300 match std::fs::DirBuilder::new().mode(0o700).create(path) {
301 Ok(()) => {}
302 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
303 Err(e) => return Err(e),
304 }
305 let dir = Self::open_dir_nofollow_at_path(path)?;
306 dir.fchmod_private()?;
307 Ok(dir)
308 }
309
310 pub fn open_root(path: &Path) -> std::io::Result<Self> {
312 Self::open_dir_nofollow_at_path(path)
313 }
314
315 pub fn open_absolute_no_symlinks(path: &Path) -> std::io::Result<Self> {
330 let components = absolute_real_components(path)?;
331 let root = Self::open_dir_nofollow_at_path(Path::new("/"))?;
332 if components.is_empty() {
333 return Ok(root);
334 }
335 #[cfg(target_os = "linux")]
336 {
337 let joined = components.join("/");
338 match root.openat2_beneath(&joined, libc::O_RDONLY | libc::O_DIRECTORY) {
339 Ok(handle) => return Ok(Self { handle }),
340 Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => {} Err(e) => return Err(confinement_error(&joined, &e)),
342 }
343 }
344 let mut dir = root;
345 for component in &components {
346 dir = dir.open_child_dir_nofollow(component)?;
347 }
348 Ok(dir)
349 }
350
351 pub fn create_absolute_no_symlinks(path: &Path) -> std::io::Result<Self> {
357 let components = absolute_real_components(path)?;
358 let mut dir = Self::open_dir_nofollow_at_path(Path::new("/"))?;
359 for component in &components {
360 dir = match dir.open_child_dir_nofollow(component) {
361 Ok(next) => next,
362 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
363 let c_name = validated_component_cstring(component)?;
364 let rc = unsafe { libc::mkdirat(dir.fd(), c_name.as_ptr(), 0o700) };
365 if rc != 0 {
366 let err = std::io::Error::last_os_error();
367 if err.raw_os_error() != Some(libc::EEXIST) {
368 return Err(err);
369 }
370 }
371 dir.open_child_dir_nofollow(component)?
372 }
373 Err(e) => return Err(e),
374 };
375 }
376 dir.fchmod_private()?;
377 Ok(dir)
378 }
379
380 fn open_child_dir_nofollow(&self, name: &str) -> std::io::Result<Self> {
382 let c_name = validated_component_cstring(name)?;
383 let fd = unsafe {
384 libc::openat(
385 self.fd(),
386 c_name.as_ptr(),
387 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC,
388 )
389 };
390 if fd < 0 {
391 let err = std::io::Error::last_os_error();
392 return Err(confinement_error(name, &err));
393 }
394 use std::os::unix::io::FromRawFd;
395 Ok(Self {
396 handle: unsafe { File::from_raw_fd(fd) },
397 })
398 }
399
400 fn open_dir_nofollow_at_path(path: &Path) -> std::io::Result<Self> {
401 use std::os::unix::fs::OpenOptionsExt;
402 let handle = OpenOptions::new()
403 .read(true)
404 .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC)
405 .open(path)
406 .map_err(|e| confinement_error(path.to_string_lossy().as_ref(), &e))?;
407 Ok(Self { handle })
408 }
409
410 fn fchmod_private(&self) -> std::io::Result<()> {
414 let rc = unsafe { libc::fchmod(self.fd(), 0o700) };
415 if rc != 0 {
416 return Err(std::io::Error::last_os_error());
417 }
418 Ok(())
419 }
420
421 fn fd(&self) -> libc::c_int {
422 use std::os::unix::io::AsRawFd;
423 self.handle.as_raw_fd()
424 }
425
426 pub fn child_dir(&self, name: &str) -> std::io::Result<Self> {
430 let c_name = validated_component_cstring(name)?;
431 let rc = unsafe { libc::mkdirat(self.fd(), c_name.as_ptr(), 0o700) };
434 if rc != 0 {
435 let err = std::io::Error::last_os_error();
436 if err.raw_os_error() != Some(libc::EEXIST) {
437 return Err(err);
438 }
439 }
440 let fd = unsafe {
441 libc::openat(
442 self.fd(),
443 c_name.as_ptr(),
444 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC,
445 )
446 };
447 if fd < 0 {
448 let err = std::io::Error::last_os_error();
449 return Err(confinement_error(name, &err));
450 }
451 use std::os::unix::io::FromRawFd;
452 let dir = Self {
453 handle: unsafe { File::from_raw_fd(fd) },
454 };
455 dir.fchmod_private()?;
456 Ok(dir)
457 }
458
459 pub fn create_dirs(&self, components: &[String]) -> std::io::Result<Self> {
462 let mut dir = self.try_clone()?;
463 for component in components {
464 dir = dir.child_dir(component)?;
465 }
466 Ok(dir)
467 }
468
469 pub fn open_dirs(&self, components: &[String]) -> std::io::Result<Self> {
474 if components.is_empty() {
475 return self.try_clone();
476 }
477 for component in components {
478 let _ = validated_component_cstring(component)?;
479 }
480 #[cfg(target_os = "linux")]
481 {
482 let joined = components.join("/");
483 match self.openat2_beneath(&joined, libc::O_RDONLY | libc::O_DIRECTORY) {
484 Ok(handle) => return Ok(Self { handle }),
485 Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => {} Err(e) => return Err(confinement_error(&joined, &e)),
487 }
488 }
489 let mut dir = self.try_clone()?;
490 for component in components {
491 let c_name = validated_component_cstring(component)?;
492 let fd = unsafe {
493 libc::openat(
494 dir.fd(),
495 c_name.as_ptr(),
496 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC,
497 )
498 };
499 if fd < 0 {
500 let err = std::io::Error::last_os_error();
501 return Err(confinement_error(component, &err));
502 }
503 use std::os::unix::io::FromRawFd;
504 dir = Self {
505 handle: unsafe { File::from_raw_fd(fd) },
506 };
507 }
508 Ok(dir)
509 }
510
511 #[cfg(target_os = "linux")]
512 fn openat2_beneath(&self, rel: &str, flags: libc::c_int) -> std::io::Result<File> {
513 let c_rel = std::ffi::CString::new(rel)
514 .map_err(|_| std::io::Error::other("NUL in confined path"))?;
515 let mut how: libc::open_how = unsafe { std::mem::zeroed() };
516 how.flags = (flags | libc::O_CLOEXEC) as u64;
517 how.resolve = libc::RESOLVE_BENEATH | libc::RESOLVE_NO_SYMLINKS;
518 let fd = unsafe {
519 libc::syscall(
520 libc::SYS_openat2,
521 self.fd(),
522 c_rel.as_ptr(),
523 &mut how as *mut libc::open_how,
524 std::mem::size_of::<libc::open_how>(),
525 )
526 };
527 if fd < 0 {
528 return Err(std::io::Error::last_os_error());
529 }
530 use std::os::unix::io::FromRawFd;
531 Ok(unsafe { File::from_raw_fd(fd as libc::c_int) })
532 }
533
534 pub fn open_file(&self, components: &[String]) -> std::io::Result<File> {
542 let (name, dirs) = components
543 .split_last()
544 .ok_or_else(|| std::io::Error::other("empty confined file path"))?;
545 for component in components {
546 let _ = validated_component_cstring(component)?;
547 }
548 #[cfg(target_os = "linux")]
549 {
550 let joined = components.join("/");
551 match self.openat2_beneath(&joined, libc::O_RDONLY | libc::O_NOFOLLOW) {
552 Ok(file) => {
553 let meta = file.metadata()?;
554 if !meta.is_file() {
555 return Err(std::io::Error::other(format!(
556 "confined source {joined:?} is not a regular file"
557 )));
558 }
559 return Ok(file);
560 }
561 Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => {} Err(e) => return Err(confinement_error(&joined, &e)),
563 }
564 }
565 let dir = self.open_dirs(dirs)?;
566 let c_name = validated_component_cstring(name)?;
567 let fd = unsafe {
568 libc::openat(
569 dir.fd(),
570 c_name.as_ptr(),
571 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
572 )
573 };
574 if fd < 0 {
575 let err = std::io::Error::last_os_error();
576 return Err(confinement_error(name, &err));
577 }
578 use std::os::unix::io::FromRawFd;
579 let file = unsafe { File::from_raw_fd(fd) };
580 let meta = file.metadata()?;
581 if !meta.is_file() {
582 return Err(std::io::Error::other(format!(
583 "confined source {name:?} is not a regular file"
584 )));
585 }
586 Ok(file)
587 }
588
589 pub fn create_file(&self, name: &str) -> std::io::Result<File> {
593 let c_name = validated_component_cstring(name)?;
594 let fd = unsafe {
595 libc::openat(
596 self.fd(),
597 c_name.as_ptr(),
598 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
599 0o600 as libc::c_uint,
600 )
601 };
602 if fd < 0 {
603 let err = std::io::Error::last_os_error();
604 return Err(confinement_error(name, &err));
605 }
606 use std::os::unix::io::FromRawFd;
607 Ok(unsafe { File::from_raw_fd(fd) })
608 }
609
610 pub fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
619 use std::io::Write;
620 let c_final = validated_component_cstring(name)?;
621 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
623 let rc = unsafe {
624 libc::fstatat(
625 self.fd(),
626 c_final.as_ptr(),
627 &mut stat,
628 libc::AT_SYMLINK_NOFOLLOW,
629 )
630 };
631 if rc == 0 && (stat.st_mode & libc::S_IFMT) == libc::S_IFLNK {
632 return Err(std::io::Error::other(format!(
633 "confinement violation at {name:?}: refusing symlink write target"
634 )));
635 }
636
637 let tmp_name = format!("{name}.tmp");
638 match self.remove_file(&tmp_name) {
641 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
642 _ => {}
643 }
644 let mut file = self.create_file(&tmp_name)?;
645 file.write_all(data)?;
646 file.sync_all()?;
647 drop(file);
648
649 let c_tmp = validated_component_cstring(&tmp_name)?;
650 let rc = unsafe { libc::renameat(self.fd(), c_tmp.as_ptr(), self.fd(), c_final.as_ptr()) };
651 if rc != 0 {
652 let err = std::io::Error::last_os_error();
653 let _ = self.remove_file(&tmp_name);
654 return Err(err);
655 }
656 Ok(())
657 }
658
659 pub fn append_file(&self, name: &str) -> std::io::Result<File> {
664 let c_name = validated_component_cstring(name)?;
665 let fd = unsafe {
666 libc::openat(
667 self.fd(),
668 c_name.as_ptr(),
669 libc::O_WRONLY
670 | libc::O_APPEND
671 | libc::O_CREAT
672 | libc::O_NOFOLLOW
673 | libc::O_CLOEXEC,
674 0o600 as libc::c_uint,
675 )
676 };
677 if fd < 0 {
678 let err = std::io::Error::last_os_error();
679 return Err(confinement_error(name, &err));
680 }
681 use std::os::unix::io::FromRawFd;
682 let file = unsafe { File::from_raw_fd(fd) };
683 use std::os::unix::fs::PermissionsExt;
684 let meta = file.metadata()?;
685 if meta.permissions().mode() & 0o777 != FILE_MODE {
686 file.set_permissions(std::fs::Permissions::from_mode(FILE_MODE))?;
687 }
688 Ok(file)
689 }
690
691 pub fn remove_file(&self, name: &str) -> std::io::Result<()> {
692 let c_name = validated_component_cstring(name)?;
693 let rc = unsafe { libc::unlinkat(self.fd(), c_name.as_ptr(), 0) };
694 if rc != 0 {
695 return Err(std::io::Error::last_os_error());
696 }
697 Ok(())
698 }
699
700 fn try_clone(&self) -> std::io::Result<Self> {
701 Ok(Self {
702 handle: self.handle.try_clone()?,
703 })
704 }
705}
706
707#[cfg(unix)]
711#[derive(Debug, Clone, PartialEq, Eq)]
712pub struct ConfinedEntry {
713 pub name: String,
714 pub is_file: bool,
715 pub is_dir: bool,
716 pub mtime_unix_ms: Option<u64>,
720 pub byte_len: u64,
722}
723
724#[cfg(unix)]
725impl ConfinedDir {
726 pub fn entries(&self) -> std::io::Result<Vec<ConfinedEntry>> {
731 let duplicated = unsafe { libc::dup(self.fd()) };
732 if duplicated < 0 {
733 return Err(std::io::Error::last_os_error());
734 }
735 let directory = unsafe { libc::fdopendir(duplicated) };
736 if directory.is_null() {
737 let error = std::io::Error::last_os_error();
738 unsafe { libc::close(duplicated) };
739 return Err(error);
740 }
741
742 let mut entries = Vec::new();
743 loop {
744 let raw = unsafe { libc::readdir(directory) };
745 if raw.is_null() {
746 break;
747 }
748 let name_bytes = unsafe { std::ffi::CStr::from_ptr((*raw).d_name.as_ptr()) };
749 let Ok(name) = name_bytes.to_str() else {
750 continue;
751 };
752 if name == "." || name == ".." || validated_component_cstring(name).is_err() {
753 continue;
754 }
755 let c_name = validated_component_cstring(name)?;
756 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
757 let rc = unsafe {
758 libc::fstatat(
759 self.fd(),
760 c_name.as_ptr(),
761 &mut stat,
762 libc::AT_SYMLINK_NOFOLLOW,
763 )
764 };
765 if rc != 0 {
766 continue;
770 }
771 let kind = stat.st_mode & libc::S_IFMT;
772 #[allow(clippy::useless_conversion)]
773 let mtime_unix_ms = u64::try_from(stat.st_mtime)
774 .ok()
775 .map(|secs| secs * 1_000 + (stat.st_mtime_nsec as u64) / 1_000_000);
776 entries.push(ConfinedEntry {
777 name: name.to_string(),
778 is_file: kind == libc::S_IFREG,
779 is_dir: kind == libc::S_IFDIR,
780 mtime_unix_ms,
781 byte_len: u64::try_from(stat.st_size).unwrap_or_default(),
782 });
783 }
784 let close_rc = unsafe { libc::closedir(directory) }; if close_rc != 0 {
786 return Err(std::io::Error::last_os_error());
787 }
788 entries.sort_by(|a, b| a.name.cmp(&b.name));
789 Ok(entries)
790 }
791}
792
793#[cfg(unix)]
796fn absolute_real_components(path: &Path) -> std::io::Result<Vec<String>> {
797 if !path.is_absolute() {
798 return Err(std::io::Error::other(format!(
799 "strict no-symlink resolution requires an absolute path, got {path:?}"
800 )));
801 }
802 let mut out = Vec::new();
803 for component in path.components() {
804 match component {
805 std::path::Component::RootDir => {}
806 std::path::Component::Normal(os) => {
807 let name = os.to_str().ok_or_else(|| {
808 std::io::Error::other(format!("non-UTF8 path component in {path:?}"))
809 })?;
810 let _ = validated_component_cstring(name)?;
811 out.push(name.to_string());
812 }
813 other => {
814 return Err(std::io::Error::other(format!(
815 "refusing non-normal path component {other:?} in {path:?}"
816 )));
817 }
818 }
819 }
820 Ok(out)
821}
822
823#[cfg(unix)]
826fn validated_component_cstring(name: &str) -> std::io::Result<std::ffi::CString> {
827 if name.is_empty()
828 || name == "."
829 || name == ".."
830 || name.contains('/')
831 || name.contains('\\')
832 || name.contains('\0')
833 {
834 return Err(std::io::Error::other(format!(
835 "invalid confined path component: {name:?}"
836 )));
837 }
838 std::ffi::CString::new(name).map_err(|_| std::io::Error::other("NUL in path component"))
839}
840
841#[cfg(unix)]
844pub fn validated_relative_components(rel: &str) -> std::io::Result<Vec<String>> {
845 if rel.is_empty() || rel.starts_with('/') {
846 return Err(std::io::Error::other(format!(
847 "invalid confined relative path: {rel:?}"
848 )));
849 }
850 let mut out = Vec::new();
851 for component in rel.split('/') {
852 validated_component_cstring(component).map_err(|_| {
853 std::io::Error::other(format!("invalid confined relative path: {rel:?}"))
854 })?;
855 out.push(component.to_string());
856 }
857 Ok(out)
858}
859
860#[cfg(unix)]
861fn confinement_error(component: &str, err: &std::io::Error) -> std::io::Error {
862 if matches!(
863 err.raw_os_error(),
864 Some(libc::ELOOP) | Some(libc::ENOTDIR) | Some(libc::EEXIST) | Some(libc::EXDEV)
865 ) {
866 std::io::Error::other(format!(
867 "confinement violation at {component:?}: refusing symlink or \
868 non-directory in a confined destination ({err})"
869 ))
870 } else {
871 std::io::Error::new(err.kind(), format!("{component:?}: {err}"))
872 }
873}