Skip to main content

agent_core/core/
private_fs.rs

1//! Private filesystem helpers for sensitive application state.
2//!
3//! Policy (spec §5.4): directories `0700`, files and temp files `0600`,
4//! symlink-safe opens, and atomic create-with-mode → write → rename so no
5//! interval exists where a fresh file is broader than policy. Pre-existing
6//! broader modes are repaired (chmod) on the next write. On non-Unix
7//! platforms the helpers fall back to default platform semantics (symlink
8//! refusal is still enforced via `symlink_metadata`).
9
10use std::fs::{File, OpenOptions};
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14/// Policy mode for private files and temp files (Unix).
15#[cfg(unix)]
16pub const FILE_MODE: u32 = 0o600;
17/// Policy mode for private directories (Unix).
18#[cfg(unix)]
19pub const DIR_MODE: u32 = 0o700;
20
21/// Typed failure for private filesystem operations.
22#[derive(Debug)]
23pub enum PrivateFsError {
24    /// A symlink was planted at the target path — refusing to write through it.
25    SymlinkRefused(PathBuf),
26    /// Underlying I/O failure.
27    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
58/// Refuse to operate on a path if a symlink is planted there.
59fn 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
68/// Create `dir` (and missing parents) with mode `0700`, and repair the leaf
69/// directory to `0700` if a pre-existing one is broader than policy.
70pub 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        // Repair a pre-existing broader-mode leaf dir (spec §5.4).
79        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
89/// Open `path` for appending, creating it with mode `0600` (`O_NOFOLLOW` on
90/// Unix). A symlink at the target yields [`PrivateFsError::SymlinkRefused`].
91/// A pre-existing broader-mode file is repaired to `0600` via `fchmod` on the
92/// already-open handle (no path re-resolution).
93pub 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
123/// Atomically replace `path` with `data`: the temp file is created in the
124/// same directory with `create_new` + mode `0600` (never create-then-chmod,
125/// so no interval is broader than policy), written, then renamed over the
126/// target. A symlink at the target or temp path yields a typed refusal.
127pub 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    // Clear a stale temp file; `remove_file` unlinks a planted symlink itself,
141    // never its target, so `create_new` below cannot be redirected.
142    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        // Narrow the create→rename TOCTOU window: re-check the target just
162        // before rename (rename itself replaces a symlink rather than
163        // following it, so this is policy enforcement, not a traversal fix).
164        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/// Shared umask isolation for tests. umask is process-global, so every test
175/// that changes it must (a) hold the `#[serial(umask)]` serial_test key so no
176/// two umask-mutating tests overlap, and (b) restore the old mask on drop —
177/// even on panic — so sibling tests never observe a permissive mask.
178#[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// ─── CP-13 fix2: directory-handle-relative confined creation ─────────────────
275
276/// A held `O_DIRECTORY` handle beneath a trusted root. Every operation is
277/// RELATIVE to this handle (`openat`/`mkdirat`/`unlinkat`), each component
278/// is opened `O_NOFOLLOW`, and no path is ever re-resolved after a check —
279/// the check IS the open, so ancestor-symlink plants and concurrent
280/// component swaps fail closed (`ELOOP`/`ENOTDIR`) instead of escaping.
281///
282/// On Linux, multi-component descents additionally try `openat2` with
283/// `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS` for kernel-side atomic
284/// resolution, falling back to the component-by-component walk when the
285/// syscall is unavailable (`ENOSYS`). Unix-only by design: confined export
286/// fails closed on other platforms.
287#[cfg(unix)]
288#[derive(Debug)]
289pub struct ConfinedDir {
290    handle: File,
291}
292
293#[cfg(unix)]
294impl ConfinedDir {
295    /// Open the TRUSTED export root itself, creating it 0700 if missing.
296    /// The final component must not be a symlink (`O_NOFOLLOW`); the
297    /// root's own ancestors are the caller's trusted input.
298    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    /// Open an EXISTING trusted root without creating it.
311    pub fn open_root(path: &Path) -> std::io::Result<Self> {
312        Self::open_dir_nofollow_at_path(path)
313    }
314
315    /// Open an EXISTING absolute directory with EVERY component — ancestors
316    /// AND the final one — resolved handle-relatively and symlink-refusing
317    /// (fix2). Unlike [`Self::open_root`], whose pathname open lets the
318    /// kernel follow symlinked ANCESTORS, this walks from `/` (which cannot
319    /// be a symlink): Linux tries one atomic `openat2` with
320    /// `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS` from the root handle, and
321    /// the fallback opens each component `O_NOFOLLOW | O_DIRECTORY`. There
322    /// is no check-then-open race — a component swapped to a symlink at any
323    /// point fails the open itself (`ELOOP`/`ENOTDIR`).
324    ///
325    /// TRUSTED-ROOT SEMANTICS: nothing on the path is trusted; every
326    /// component must be a real directory. Operators whose base dir
327    /// legitimately sits behind ancestor symlinks (e.g. `/home` →
328    /// `var/home`) must point `SYNAPS_BASE_DIR` at the canonical path.
329    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) => {} // fall back
341                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    /// Like [`Self::open_absolute_no_symlinks`], but creates missing
352    /// directory components (mode 0700) during the walk and repairs the
353    /// LEAF to 0700 via `fchmod` on the opened handle — the handle-relative
354    /// counterpart of [`ensure_private_dir`]. Every existing component,
355    /// ancestor or final, must be a real (non-symlink) directory.
356    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    /// Open one existing DIRECT child directory, `O_NOFOLLOW | O_DIRECTORY`.
381    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    /// Repair this directory's mode to 0700 via `fchmod` on the OPENED
411    /// handle (no path re-resolution; immune to umask and to pre-existing
412    /// broad modes).
413    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    /// Open-or-create one DIRECT child directory (validated single
427    /// component), 0700, refusing symlinks even when swapped in between
428    /// operations.
429    pub fn child_dir(&self, name: &str) -> std::io::Result<Self> {
430        let c_name = validated_component_cstring(name)?;
431        // mkdirat: EEXIST is fine — the subsequent O_NOFOLLOW open decides
432        // whether the existing entry is an acceptable real directory.
433        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    /// Descend through validated relative directory components, creating
460    /// each as needed (0700). Component-by-component `O_NOFOLLOW` opens.
461    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    /// Descend through EXISTING validated relative directory components
470    /// without creating anything. Tries Linux `openat2` with
471    /// `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS` for the whole descent;
472    /// falls back to the component walk when unavailable.
473    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) => {} // fall back
486                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    /// Open a descendant FILE for reading via handle-relative resolution
535    /// (CP-13 fix3): Linux tries `openat2` with `RESOLVE_BENEATH |
536    /// RESOLVE_NO_SYMLINKS` over the whole validated relative path, falling
537    /// back to the component `O_NOFOLLOW` directory walk plus a final
538    /// `openat(O_RDONLY|O_NOFOLLOW)`. The opened handle must be a regular
539    /// file by handle metadata. No full-path re-open ever happens — an
540    /// ancestor swapped to a symlink after discovery is refused here.
541    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) => {} // fall back
562                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    /// Create a file (validated single component) `O_CREAT|O_EXCL|
590    /// O_NOFOLLOW` 0600 relative to this handle. An existing entry —
591    /// including any symlink, even one swapped in concurrently — fails.
592    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    /// Unlink a file (validated single component) relative to this handle.
611    /// Handle-relative atomic private write (fix2): `<name>.tmp` is created
612    /// `O_CREAT|O_EXCL|O_NOFOLLOW` 0600 relative to THIS handle, written,
613    /// fsynced, and `renameat`ed over `<name>` — `renameat` never follows
614    /// the target, so a planted symlink is replaced as a link, never
615    /// written through. A pre-existing symlink at the target is refused
616    /// with a typed error (policy parity with [`write_atomic_private`]);
617    /// one planted after the check merely gets atomically replaced.
618    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        // Refuse a pre-existing symlink target (typed policy refusal).
622        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        // Clear a stale temp (unlinkat removes a planted symlink itself,
639        // never its target), then create fresh with O_EXCL.
640        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    /// Handle-relative private append (fix2): open `<name>` for appending
660    /// relative to THIS handle with `O_NOFOLLOW`, creating it 0600; a
661    /// pre-existing broader-mode file is repaired via the opened handle —
662    /// parity with [`open_private_append`], minus the pathname resolution.
663    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/// One entry discovered from a held directory handle. Discovery never
708/// re-resolves the directory by path; callers still perform the authoritative
709/// open relative to the same handle, so entry replacement races fail closed.
710#[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    /// Modification time (ms since epoch) from the handle-relative
717    /// `fstatat(AT_SYMLINK_NOFOLLOW)` — of the entry ITSELF, never a
718    /// symlink target.
719    pub mtime_unix_ms: Option<u64>,
720    /// File length from the same handle-relative stat.
721    pub byte_len: u64,
722}
723
724#[cfg(unix)]
725impl ConfinedDir {
726    /// Enumerate direct children via `fdopendir(dup(dirfd))` + `readdir` and
727    /// classify each with handle-relative `fstatat(AT_SYMLINK_NOFOLLOW)`.
728    /// Symlinks are returned as neither file nor directory. Names that are
729    /// non-UTF8 or invalid confined components are omitted.
730    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                // Entry disappeared or changed during enumeration. The
767                // authoritative later open decides; there is nothing safe to
768                // report from this stale directory record.
769                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) }; // owns duplicated fd
785        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/// Split an ABSOLUTE path into validated real components for the strict
794/// no-symlink walks: rejects relative paths, `.`/`..`, and non-UTF8.
795#[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/// Validate one path component: non-empty, no separators, not `.`/`..`,
824/// no NUL. Returns it as a `CString` for the *at syscalls.
825#[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/// Split and validate a RELATIVE multi-component path (`a/b/c.jsonl`).
842/// Rejects absolute paths, `.`/`..`, empty components, and backslashes.
843#[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}