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 durable_directory_sync_uses_held_handle_after_path_swap() {
265 let tmp = tempfile::TempDir::new().unwrap();
266 let root = tmp.path().canonicalize().unwrap();
267 let path = root.join("new/parent/sessions");
268 let handle = ConfinedDir::create_absolute_no_symlinks_durable(&path).unwrap();
269 assert_eq!(mode_of(&path), 0o700);
270 let held_path = root.join("held-sessions");
271 std::fs::rename(&path, &held_path).unwrap();
272 let victim = root.join("victim");
273 std::fs::create_dir(&victim).unwrap();
274 std::os::unix::fs::symlink(&victim, &path).unwrap();
275 handle.write_atomic("checkpoint.json", b"{}").unwrap();
276 handle.sync_all().unwrap();
277 assert_eq!(
278 std::fs::read(held_path.join("checkpoint.json")).unwrap(),
279 b"{}"
280 );
281 assert_eq!(std::fs::read_dir(&victim).unwrap().count(), 0);
282 assert!(ConfinedDir::create_absolute_no_symlinks_durable(&path).is_err());
283 }
284
285 #[test]
286 fn ensure_private_dir_repairs_broad_mode() {
287 let tmp = tempfile::TempDir::new().unwrap();
288 let dir = tmp.path().join("broad");
289 std::fs::create_dir(&dir).unwrap();
290 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
291 ensure_private_dir(&dir).unwrap();
292 assert_eq!(mode_of(&dir), 0o700);
293 }
294
295 struct ModeGuard(std::path::PathBuf);
297 impl ModeGuard {
298 fn set(path: &Path, mode: u32) -> Self {
299 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
300 Self(path.to_path_buf())
301 }
302 }
303 impl Drop for ModeGuard {
304 fn drop(&mut self) {
305 let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
306 }
307 }
308
309 fn dac_enforced() -> bool {
311 unsafe { libc::geteuid() != 0 }
312 }
313
314 #[cfg(target_os = "linux")]
315 #[test]
316 fn create_walks_through_traverse_only_ancestor() {
317 if !dac_enforced() {
318 return;
319 }
320 let tmp = tempfile::TempDir::new().unwrap();
321 let root = tmp.path().canonicalize().unwrap();
322 let gate = root.join("gate"); std::fs::create_dir_all(gate.join("inner")).unwrap();
324 let _g = ModeGuard::set(&gate, 0o111);
325 assert!(std::fs::read_dir(&gate).is_err(), "precondition: gate unreadable");
326
327 let sessions = gate.join("inner/sessions");
328 let handle = ConfinedDir::create_absolute_no_symlinks(&sessions).unwrap();
329 handle.write_atomic("s.json", b"{}").unwrap();
330 assert_eq!(std::fs::read(sessions.join("s.json")).unwrap(), b"{}");
331 assert_eq!(mode_of(&sessions), 0o700);
332 ConfinedDir::create_absolute_no_symlinks_durable(&gate.join("inner/durable")).unwrap();
335 }
336
337 #[cfg(target_os = "linux")]
338 #[test]
339 fn traverse_only_fallback_still_refuses_symlinks() {
340 if !dac_enforced() {
341 return;
342 }
343 let tmp = tempfile::TempDir::new().unwrap();
344 let root = tmp.path().canonicalize().unwrap();
345 let victim = root.join("victim");
346 std::fs::create_dir(&victim).unwrap();
347 let gate = root.join("gate");
348 std::fs::create_dir(&gate).unwrap();
349 std::os::unix::fs::symlink(&victim, gate.join("link")).unwrap();
350 let _g = ModeGuard::set(&gate, 0o111);
351 assert!(ConfinedDir::create_absolute_no_symlinks(&gate.join("link/sessions")).is_err());
352 assert_eq!(std::fs::read_dir(&victim).unwrap().count(), 0);
353 }
354
355 #[cfg(target_os = "linux")]
356 #[test]
357 fn durable_refuses_to_create_inside_unreadable_dir() {
358 if !dac_enforced() {
359 return;
360 }
361 let tmp = tempfile::TempDir::new().unwrap();
362 let root = tmp.path().canonicalize().unwrap();
363 let gate = root.join("gate");
364 std::fs::create_dir(&gate).unwrap();
365 let _g = ModeGuard::set(&gate, 0o311); let err = ConfinedDir::create_absolute_no_symlinks_durable(&gate.join("new/sessions"))
367 .unwrap_err();
368 assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
369 ConfinedDir::create_absolute_no_symlinks(&gate.join("other/sessions")).unwrap();
371 }
372
373 #[test]
374 fn unreadable_leaf_is_still_refused() {
375 if !dac_enforced() {
376 return;
377 }
378 let tmp = tempfile::TempDir::new().unwrap();
379 let root = tmp.path().canonicalize().unwrap();
380 let leaf = root.join("leaf");
381 std::fs::create_dir(&leaf).unwrap();
382 let _g = ModeGuard::set(&leaf, 0o111);
383 assert!(ConfinedDir::create_absolute_no_symlinks(&leaf).is_err());
384 }
385}
386
387#[cfg(unix)]
401#[derive(Debug)]
402pub struct ConfinedDir {
403 handle: File,
404}
405
406#[cfg(unix)]
407impl ConfinedDir {
408 pub fn create_root(path: &Path) -> std::io::Result<Self> {
412 use std::os::unix::fs::DirBuilderExt;
413 match std::fs::DirBuilder::new().mode(0o700).create(path) {
414 Ok(()) => {}
415 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
416 Err(e) => return Err(e),
417 }
418 let dir = Self::open_dir_nofollow_at_path(path)?;
419 dir.fchmod_private()?;
420 Ok(dir)
421 }
422
423 pub fn open_root(path: &Path) -> std::io::Result<Self> {
425 Self::open_dir_nofollow_at_path(path)
426 }
427
428 pub fn open_absolute_no_symlinks(path: &Path) -> std::io::Result<Self> {
443 let components = absolute_real_components(path)?;
444 let root = Self::open_dir_nofollow_at_path(Path::new("/"))?;
445 if components.is_empty() {
446 return Ok(root);
447 }
448 #[cfg(target_os = "linux")]
449 {
450 let joined = components.join("/");
451 match root.openat2_beneath(&joined, libc::O_RDONLY | libc::O_DIRECTORY) {
452 Ok(handle) => return Ok(Self { handle }),
453 Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => {} Err(e) => return Err(confinement_error(&joined, &e)),
455 }
456 }
457 let mut dir = root;
458 for component in &components {
459 dir = dir.open_child_dir_nofollow(component)?;
460 }
461 Ok(dir)
462 }
463
464 pub fn create_absolute_no_symlinks(path: &Path) -> std::io::Result<Self> {
470 Self::create_absolute_no_symlinks_impl(path, false)
471 }
472
473 pub fn create_absolute_no_symlinks_durable(path: &Path) -> std::io::Result<Self> {
477 Self::create_absolute_no_symlinks_impl(path, true)
478 }
479
480 fn create_absolute_no_symlinks_impl(path: &Path, durable: bool) -> std::io::Result<Self> {
481 let components = absolute_real_components(path)?;
482 let last = components.len().saturating_sub(1);
483 let mut dir = Self::open_dir_nofollow_at_path(Path::new("/"))?;
484 let mut dir_path_only = false;
487 for (i, component) in components.iter().enumerate() {
488 let mut created = false;
489 let (next, next_path_only) = match dir.open_child_dir_nofollow(component) {
490 Ok(next) => (next, false),
491 Err(e) if i < last && e.kind() == std::io::ErrorKind::PermissionDenied => {
499 (dir.open_child_dir_path_only(component)?, true)
500 }
501 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
502 let c_name = validated_component_cstring(component)?;
503 let rc = unsafe { libc::mkdirat(dir.fd(), c_name.as_ptr(), 0o700) };
504 if rc != 0 {
505 let err = std::io::Error::last_os_error();
506 if err.raw_os_error() != Some(libc::EEXIST) {
507 return Err(err);
508 }
509 }
510 created = true;
511 (dir.open_child_dir_nofollow(component)?, false)
512 }
513 Err(e) => return Err(e),
514 };
515 if durable {
516 if dir_path_only {
517 if created {
521 return Err(std::io::Error::new(
522 std::io::ErrorKind::PermissionDenied,
523 format!("{component:?}: cannot durably create inside an unreadable directory"),
524 ));
525 }
526 } else {
527 dir.sync_all()?;
528 }
529 }
530 dir = next;
531 dir_path_only = next_path_only;
532 }
533 dir.fchmod_private()?;
534 Ok(dir)
535 }
536
537 pub fn sync_all(&self) -> std::io::Result<()> {
540 self.handle.sync_all()
541 }
542
543 fn open_child_dir_nofollow(&self, name: &str) -> std::io::Result<Self> {
545 let c_name = validated_component_cstring(name)?;
546 let fd = unsafe {
547 libc::openat(
548 self.fd(),
549 c_name.as_ptr(),
550 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC,
551 )
552 };
553 if fd < 0 {
554 let err = std::io::Error::last_os_error();
555 return Err(confinement_error(name, &err));
556 }
557 use std::os::unix::io::FromRawFd;
558 Ok(Self {
559 handle: unsafe { File::from_raw_fd(fd) },
560 })
561 }
562
563 #[cfg(target_os = "linux")]
570 fn open_child_dir_path_only(&self, name: &str) -> std::io::Result<Self> {
571 let c_name = validated_component_cstring(name)?;
572 let fd = unsafe {
573 libc::openat(
574 self.fd(),
575 c_name.as_ptr(),
576 libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
577 )
578 };
579 if fd < 0 {
580 let err = std::io::Error::last_os_error();
581 return Err(confinement_error(name, &err));
582 }
583 use std::os::unix::io::FromRawFd;
584 Ok(Self {
585 handle: unsafe { File::from_raw_fd(fd) },
586 })
587 }
588
589 #[cfg(not(target_os = "linux"))]
591 fn open_child_dir_path_only(&self, name: &str) -> std::io::Result<Self> {
592 Err(std::io::Error::new(
593 std::io::ErrorKind::PermissionDenied,
594 format!("{name:?}: directory is not readable"),
595 ))
596 }
597
598 fn open_dir_nofollow_at_path(path: &Path) -> std::io::Result<Self> {
599 use std::os::unix::fs::OpenOptionsExt;
600 let handle = OpenOptions::new()
601 .read(true)
602 .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC)
603 .open(path)
604 .map_err(|e| confinement_error(path.to_string_lossy().as_ref(), &e))?;
605 Ok(Self { handle })
606 }
607
608 fn fchmod_private(&self) -> std::io::Result<()> {
612 let rc = unsafe { libc::fchmod(self.fd(), 0o700) };
613 if rc != 0 {
614 return Err(std::io::Error::last_os_error());
615 }
616 Ok(())
617 }
618
619 fn fd(&self) -> libc::c_int {
620 use std::os::unix::io::AsRawFd;
621 self.handle.as_raw_fd()
622 }
623
624 pub fn child_dir(&self, name: &str) -> std::io::Result<Self> {
628 let c_name = validated_component_cstring(name)?;
629 let rc = unsafe { libc::mkdirat(self.fd(), c_name.as_ptr(), 0o700) };
632 if rc != 0 {
633 let err = std::io::Error::last_os_error();
634 if err.raw_os_error() != Some(libc::EEXIST) {
635 return Err(err);
636 }
637 }
638 let fd = unsafe {
639 libc::openat(
640 self.fd(),
641 c_name.as_ptr(),
642 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC,
643 )
644 };
645 if fd < 0 {
646 let err = std::io::Error::last_os_error();
647 return Err(confinement_error(name, &err));
648 }
649 use std::os::unix::io::FromRawFd;
650 let dir = Self {
651 handle: unsafe { File::from_raw_fd(fd) },
652 };
653 dir.fchmod_private()?;
654 Ok(dir)
655 }
656
657 pub fn create_dirs(&self, components: &[String]) -> std::io::Result<Self> {
660 let mut dir = self.try_clone()?;
661 for component in components {
662 dir = dir.child_dir(component)?;
663 }
664 Ok(dir)
665 }
666
667 pub fn open_dirs(&self, components: &[String]) -> std::io::Result<Self> {
672 if components.is_empty() {
673 return self.try_clone();
674 }
675 for component in components {
676 let _ = validated_component_cstring(component)?;
677 }
678 #[cfg(target_os = "linux")]
679 {
680 let joined = components.join("/");
681 match self.openat2_beneath(&joined, libc::O_RDONLY | libc::O_DIRECTORY) {
682 Ok(handle) => return Ok(Self { handle }),
683 Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => {} Err(e) => return Err(confinement_error(&joined, &e)),
685 }
686 }
687 let mut dir = self.try_clone()?;
688 for component in components {
689 let c_name = validated_component_cstring(component)?;
690 let fd = unsafe {
691 libc::openat(
692 dir.fd(),
693 c_name.as_ptr(),
694 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC,
695 )
696 };
697 if fd < 0 {
698 let err = std::io::Error::last_os_error();
699 return Err(confinement_error(component, &err));
700 }
701 use std::os::unix::io::FromRawFd;
702 dir = Self {
703 handle: unsafe { File::from_raw_fd(fd) },
704 };
705 }
706 Ok(dir)
707 }
708
709 #[cfg(target_os = "linux")]
710 fn openat2_beneath(&self, rel: &str, flags: libc::c_int) -> std::io::Result<File> {
711 let c_rel = std::ffi::CString::new(rel)
712 .map_err(|_| std::io::Error::other("NUL in confined path"))?;
713 let mut how: libc::open_how = unsafe { std::mem::zeroed() };
714 how.flags = (flags | libc::O_CLOEXEC) as u64;
715 how.resolve = libc::RESOLVE_BENEATH | libc::RESOLVE_NO_SYMLINKS;
716 let fd = unsafe {
717 libc::syscall(
718 libc::SYS_openat2,
719 self.fd(),
720 c_rel.as_ptr(),
721 &mut how as *mut libc::open_how,
722 std::mem::size_of::<libc::open_how>(),
723 )
724 };
725 if fd < 0 {
726 return Err(std::io::Error::last_os_error());
727 }
728 use std::os::unix::io::FromRawFd;
729 Ok(unsafe { File::from_raw_fd(fd as libc::c_int) })
730 }
731
732 pub fn open_file(&self, components: &[String]) -> std::io::Result<File> {
740 let (name, dirs) = components
741 .split_last()
742 .ok_or_else(|| std::io::Error::other("empty confined file path"))?;
743 for component in components {
744 let _ = validated_component_cstring(component)?;
745 }
746 #[cfg(target_os = "linux")]
747 {
748 let joined = components.join("/");
749 match self.openat2_beneath(&joined, libc::O_RDONLY | libc::O_NOFOLLOW) {
750 Ok(file) => {
751 let meta = file.metadata()?;
752 if !meta.is_file() {
753 return Err(std::io::Error::other(format!(
754 "confined source {joined:?} is not a regular file"
755 )));
756 }
757 return Ok(file);
758 }
759 Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => {} Err(e) => return Err(confinement_error(&joined, &e)),
761 }
762 }
763 let dir = self.open_dirs(dirs)?;
764 let c_name = validated_component_cstring(name)?;
765 let fd = unsafe {
766 libc::openat(
767 dir.fd(),
768 c_name.as_ptr(),
769 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
770 )
771 };
772 if fd < 0 {
773 let err = std::io::Error::last_os_error();
774 return Err(confinement_error(name, &err));
775 }
776 use std::os::unix::io::FromRawFd;
777 let file = unsafe { File::from_raw_fd(fd) };
778 let meta = file.metadata()?;
779 if !meta.is_file() {
780 return Err(std::io::Error::other(format!(
781 "confined source {name:?} is not a regular file"
782 )));
783 }
784 Ok(file)
785 }
786
787 pub fn create_file(&self, name: &str) -> std::io::Result<File> {
791 let c_name = validated_component_cstring(name)?;
792 let fd = unsafe {
793 libc::openat(
794 self.fd(),
795 c_name.as_ptr(),
796 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
797 0o600 as libc::c_uint,
798 )
799 };
800 if fd < 0 {
801 let err = std::io::Error::last_os_error();
802 return Err(confinement_error(name, &err));
803 }
804 use std::os::unix::io::FromRawFd;
805 Ok(unsafe { File::from_raw_fd(fd) })
806 }
807
808 pub fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
817 use std::io::Write;
818 let c_final = validated_component_cstring(name)?;
819 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
821 let rc = unsafe {
822 libc::fstatat(
823 self.fd(),
824 c_final.as_ptr(),
825 &mut stat,
826 libc::AT_SYMLINK_NOFOLLOW,
827 )
828 };
829 if rc == 0 && (stat.st_mode & libc::S_IFMT) == libc::S_IFLNK {
830 return Err(std::io::Error::other(format!(
831 "confinement violation at {name:?}: refusing symlink write target"
832 )));
833 }
834
835 let tmp_name = format!("{name}.tmp");
836 match self.remove_file(&tmp_name) {
839 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
840 _ => {}
841 }
842 let mut file = self.create_file(&tmp_name)?;
843 file.write_all(data)?;
844 file.sync_all()?;
845 drop(file);
846
847 let c_tmp = validated_component_cstring(&tmp_name)?;
848 let rc = unsafe { libc::renameat(self.fd(), c_tmp.as_ptr(), self.fd(), c_final.as_ptr()) };
849 if rc != 0 {
850 let err = std::io::Error::last_os_error();
851 let _ = self.remove_file(&tmp_name);
852 return Err(err);
853 }
854 Ok(())
855 }
856
857 pub fn append_file(&self, name: &str) -> std::io::Result<File> {
862 let c_name = validated_component_cstring(name)?;
863 let fd = unsafe {
864 libc::openat(
865 self.fd(),
866 c_name.as_ptr(),
867 libc::O_WRONLY
868 | libc::O_APPEND
869 | libc::O_CREAT
870 | libc::O_NOFOLLOW
871 | libc::O_CLOEXEC,
872 0o600 as libc::c_uint,
873 )
874 };
875 if fd < 0 {
876 let err = std::io::Error::last_os_error();
877 return Err(confinement_error(name, &err));
878 }
879 use std::os::unix::io::FromRawFd;
880 let file = unsafe { File::from_raw_fd(fd) };
881 use std::os::unix::fs::PermissionsExt;
882 let meta = file.metadata()?;
883 if meta.permissions().mode() & 0o777 != FILE_MODE {
884 file.set_permissions(std::fs::Permissions::from_mode(FILE_MODE))?;
885 }
886 Ok(file)
887 }
888
889 pub fn remove_file(&self, name: &str) -> std::io::Result<()> {
890 let c_name = validated_component_cstring(name)?;
891 let rc = unsafe { libc::unlinkat(self.fd(), c_name.as_ptr(), 0) };
892 if rc != 0 {
893 return Err(std::io::Error::last_os_error());
894 }
895 Ok(())
896 }
897
898 fn try_clone(&self) -> std::io::Result<Self> {
899 Ok(Self {
900 handle: self.handle.try_clone()?,
901 })
902 }
903}
904
905#[cfg(unix)]
909#[derive(Debug, Clone, PartialEq, Eq)]
910pub struct ConfinedEntry {
911 pub name: String,
912 pub is_file: bool,
913 pub is_dir: bool,
914 pub mtime_unix_ms: Option<u64>,
918 pub byte_len: u64,
920}
921
922#[cfg(unix)]
923impl ConfinedDir {
924 pub fn entries(&self) -> std::io::Result<Vec<ConfinedEntry>> {
929 let duplicated = unsafe { libc::dup(self.fd()) };
930 if duplicated < 0 {
931 return Err(std::io::Error::last_os_error());
932 }
933 let directory = unsafe { libc::fdopendir(duplicated) };
934 if directory.is_null() {
935 let error = std::io::Error::last_os_error();
936 unsafe { libc::close(duplicated) };
937 return Err(error);
938 }
939
940 let mut entries = Vec::new();
941 loop {
942 let raw = unsafe { libc::readdir(directory) };
943 if raw.is_null() {
944 break;
945 }
946 let name_bytes = unsafe { std::ffi::CStr::from_ptr((*raw).d_name.as_ptr()) };
947 let Ok(name) = name_bytes.to_str() else {
948 continue;
949 };
950 if name == "." || name == ".." || validated_component_cstring(name).is_err() {
951 continue;
952 }
953 let c_name = validated_component_cstring(name)?;
954 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
955 let rc = unsafe {
956 libc::fstatat(
957 self.fd(),
958 c_name.as_ptr(),
959 &mut stat,
960 libc::AT_SYMLINK_NOFOLLOW,
961 )
962 };
963 if rc != 0 {
964 continue;
968 }
969 let kind = stat.st_mode & libc::S_IFMT;
970 #[allow(clippy::useless_conversion)]
971 let mtime_unix_ms = u64::try_from(stat.st_mtime)
972 .ok()
973 .map(|secs| secs * 1_000 + (stat.st_mtime_nsec as u64) / 1_000_000);
974 entries.push(ConfinedEntry {
975 name: name.to_string(),
976 is_file: kind == libc::S_IFREG,
977 is_dir: kind == libc::S_IFDIR,
978 mtime_unix_ms,
979 byte_len: u64::try_from(stat.st_size).unwrap_or_default(),
980 });
981 }
982 let close_rc = unsafe { libc::closedir(directory) }; if close_rc != 0 {
984 return Err(std::io::Error::last_os_error());
985 }
986 entries.sort_by(|a, b| a.name.cmp(&b.name));
987 Ok(entries)
988 }
989}
990
991#[cfg(unix)]
994fn absolute_real_components(path: &Path) -> std::io::Result<Vec<String>> {
995 if !path.is_absolute() {
996 return Err(std::io::Error::other(format!(
997 "strict no-symlink resolution requires an absolute path, got {path:?}"
998 )));
999 }
1000 let mut out = Vec::new();
1001 for component in path.components() {
1002 match component {
1003 std::path::Component::RootDir => {}
1004 std::path::Component::Normal(os) => {
1005 let name = os.to_str().ok_or_else(|| {
1006 std::io::Error::other(format!("non-UTF8 path component in {path:?}"))
1007 })?;
1008 let _ = validated_component_cstring(name)?;
1009 out.push(name.to_string());
1010 }
1011 other => {
1012 return Err(std::io::Error::other(format!(
1013 "refusing non-normal path component {other:?} in {path:?}"
1014 )));
1015 }
1016 }
1017 }
1018 Ok(out)
1019}
1020
1021#[cfg(unix)]
1024fn validated_component_cstring(name: &str) -> std::io::Result<std::ffi::CString> {
1025 if name.is_empty()
1026 || name == "."
1027 || name == ".."
1028 || name.contains('/')
1029 || name.contains('\\')
1030 || name.contains('\0')
1031 {
1032 return Err(std::io::Error::other(format!(
1033 "invalid confined path component: {name:?}"
1034 )));
1035 }
1036 std::ffi::CString::new(name).map_err(|_| std::io::Error::other("NUL in path component"))
1037}
1038
1039#[cfg(unix)]
1042pub fn validated_relative_components(rel: &str) -> std::io::Result<Vec<String>> {
1043 if rel.is_empty() || rel.starts_with('/') {
1044 return Err(std::io::Error::other(format!(
1045 "invalid confined relative path: {rel:?}"
1046 )));
1047 }
1048 let mut out = Vec::new();
1049 for component in rel.split('/') {
1050 validated_component_cstring(component).map_err(|_| {
1051 std::io::Error::other(format!("invalid confined relative path: {rel:?}"))
1052 })?;
1053 out.push(component.to_string());
1054 }
1055 Ok(out)
1056}
1057
1058#[cfg(unix)]
1059fn confinement_error(component: &str, err: &std::io::Error) -> std::io::Error {
1060 if matches!(
1061 err.raw_os_error(),
1062 Some(libc::ELOOP) | Some(libc::ENOTDIR) | Some(libc::EEXIST) | Some(libc::EXDEV)
1063 ) {
1064 std::io::Error::other(format!(
1065 "confinement violation at {component:?}: refusing symlink or \
1066 non-directory in a confined destination ({err})"
1067 ))
1068 } else {
1069 std::io::Error::new(err.kind(), format!("{component:?}: {err}"))
1070 }
1071}