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 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    /// Restores a directory's mode on drop so TempDir cleanup can list it.
296    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    /// Root bypasses DAC, so permission-shaped tests prove nothing as root.
310    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"); // like a 0711 multi-tenant `instances/`
323        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        // Durable variant: nothing is created INSIDE the unreadable gate, so the
333        // walk only needs to sync readable parents.
334        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); // writable + searchable, not readable
366        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        // Non-durable callers may still create there.
370        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// ─── CP-13 fix2: directory-handle-relative confined creation ─────────────────
388
389/// A held `O_DIRECTORY` handle beneath a trusted root. Every operation is
390/// RELATIVE to this handle (`openat`/`mkdirat`/`unlinkat`), each component
391/// is opened `O_NOFOLLOW`, and no path is ever re-resolved after a check —
392/// the check IS the open, so ancestor-symlink plants and concurrent
393/// component swaps fail closed (`ELOOP`/`ENOTDIR`) instead of escaping.
394///
395/// On Linux, multi-component descents additionally try `openat2` with
396/// `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS` for kernel-side atomic
397/// resolution, falling back to the component-by-component walk when the
398/// syscall is unavailable (`ENOSYS`). Unix-only by design: confined export
399/// fails closed on other platforms.
400#[cfg(unix)]
401#[derive(Debug)]
402pub struct ConfinedDir {
403    handle: File,
404}
405
406#[cfg(unix)]
407impl ConfinedDir {
408    /// Open the TRUSTED export root itself, creating it 0700 if missing.
409    /// The final component must not be a symlink (`O_NOFOLLOW`); the
410    /// root's own ancestors are the caller's trusted input.
411    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    /// Open an EXISTING trusted root without creating it.
424    pub fn open_root(path: &Path) -> std::io::Result<Self> {
425        Self::open_dir_nofollow_at_path(path)
426    }
427
428    /// Open an EXISTING absolute directory with EVERY component — ancestors
429    /// AND the final one — resolved handle-relatively and symlink-refusing
430    /// (fix2). Unlike [`Self::open_root`], whose pathname open lets the
431    /// kernel follow symlinked ANCESTORS, this walks from `/` (which cannot
432    /// be a symlink): Linux tries one atomic `openat2` with
433    /// `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS` from the root handle, and
434    /// the fallback opens each component `O_NOFOLLOW | O_DIRECTORY`. There
435    /// is no check-then-open race — a component swapped to a symlink at any
436    /// point fails the open itself (`ELOOP`/`ENOTDIR`).
437    ///
438    /// TRUSTED-ROOT SEMANTICS: nothing on the path is trusted; every
439    /// component must be a real directory. Operators whose base dir
440    /// legitimately sits behind ancestor symlinks (e.g. `/home` →
441    /// `var/home`) must point `SYNAPS_BASE_DIR` at the canonical path.
442    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) => {} // fall back
454                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    /// Like [`Self::open_absolute_no_symlinks`], but creates missing
465    /// directory components (mode 0700) during the walk and repairs the
466    /// LEAF to 0700 via `fchmod` on the opened handle — the handle-relative
467    /// counterpart of [`ensure_private_dir`]. Every existing component,
468    /// ancestor or final, must be a real (non-symlink) directory.
469    pub fn create_absolute_no_symlinks(path: &Path) -> std::io::Result<Self> {
470        Self::create_absolute_no_symlinks_impl(path, false)
471    }
472
473    /// Durable variant: sync each parent handle so newly created directory
474    /// entries (including on retry after a failed sync) survive a crash.
475    /// The caller must still sync the leaf after publishing files in it.
476    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        // `dir` was reached through a traverse-only ancestor fallback: it is an
485        // `O_PATH` handle, valid as a `*at` dirfd but not readable/syncable.
486        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                // A traverse-only ANCESTOR (e.g. 0711, as multi-tenant layouts use
492                // to hide sibling names): descending needs only search
493                // permission, but `O_RDONLY` also demands read. Descend with
494                // `O_PATH | O_DIRECTORY | O_NOFOLLOW` instead — still handle-
495                // relative and still symlink-refusing (a symlink is not a
496                // directory → ENOTDIR). The LEAF must stay a real handle
497                // (fchmod/sync/file ops), so it never takes this path.
498                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                    // An O_PATH handle cannot be fsync'ed. Syncing an ancestor we
518                    // did not modify buys nothing, but a NEW entry in an
519                    // unreadable directory cannot be made durable: refuse.
520                    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    /// Sync this opened directory, without re-resolving its pathname. An
538    /// atomic file rename is not a durable commit until this succeeds.
539    pub fn sync_all(&self) -> std::io::Result<()> {
540        self.handle.sync_all()
541    }
542
543    /// Open one existing DIRECT child directory, `O_NOFOLLOW | O_DIRECTORY`.
544    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    /// Open one existing DIRECT child directory for TRAVERSAL only:
564    /// `O_PATH | O_DIRECTORY | O_NOFOLLOW` needs search permission on the
565    /// parent chain but not read on the child, and still refuses a symlinked
566    /// component (a symlink is not a directory → `ENOTDIR`). The handle is a
567    /// valid `*at` dirfd but cannot be read, fsync'ed or fchmod'ed — callers
568    /// use it only to descend.
569    #[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    /// No `O_PATH` here: a traverse-only ancestor stays a permission error.
590    #[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    /// Repair this directory's mode to 0700 via `fchmod` on the OPENED
609    /// handle (no path re-resolution; immune to umask and to pre-existing
610    /// broad modes).
611    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    /// Open-or-create one DIRECT child directory (validated single
625    /// component), 0700, refusing symlinks even when swapped in between
626    /// operations.
627    pub fn child_dir(&self, name: &str) -> std::io::Result<Self> {
628        let c_name = validated_component_cstring(name)?;
629        // mkdirat: EEXIST is fine — the subsequent O_NOFOLLOW open decides
630        // whether the existing entry is an acceptable real directory.
631        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    /// Descend through validated relative directory components, creating
658    /// each as needed (0700). Component-by-component `O_NOFOLLOW` opens.
659    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    /// Descend through EXISTING validated relative directory components
668    /// without creating anything. Tries Linux `openat2` with
669    /// `RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS` for the whole descent;
670    /// falls back to the component walk when unavailable.
671    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) => {} // fall back
684                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    /// Open a descendant FILE for reading via handle-relative resolution
733    /// (CP-13 fix3): Linux tries `openat2` with `RESOLVE_BENEATH |
734    /// RESOLVE_NO_SYMLINKS` over the whole validated relative path, falling
735    /// back to the component `O_NOFOLLOW` directory walk plus a final
736    /// `openat(O_RDONLY|O_NOFOLLOW)`. The opened handle must be a regular
737    /// file by handle metadata. No full-path re-open ever happens — an
738    /// ancestor swapped to a symlink after discovery is refused here.
739    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) => {} // fall back
760                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    /// Create a file (validated single component) `O_CREAT|O_EXCL|
788    /// O_NOFOLLOW` 0600 relative to this handle. An existing entry —
789    /// including any symlink, even one swapped in concurrently — fails.
790    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    /// Unlink a file (validated single component) relative to this handle.
809    /// Handle-relative atomic private write (fix2): `<name>.tmp` is created
810    /// `O_CREAT|O_EXCL|O_NOFOLLOW` 0600 relative to THIS handle, written,
811    /// fsynced, and `renameat`ed over `<name>` — `renameat` never follows
812    /// the target, so a planted symlink is replaced as a link, never
813    /// written through. A pre-existing symlink at the target is refused
814    /// with a typed error (policy parity with [`write_atomic_private`]);
815    /// one planted after the check merely gets atomically replaced.
816    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        // Refuse a pre-existing symlink target (typed policy refusal).
820        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        // Clear a stale temp (unlinkat removes a planted symlink itself,
837        // never its target), then create fresh with O_EXCL.
838        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    /// Handle-relative private append (fix2): open `<name>` for appending
858    /// relative to THIS handle with `O_NOFOLLOW`, creating it 0600; a
859    /// pre-existing broader-mode file is repaired via the opened handle —
860    /// parity with [`open_private_append`], minus the pathname resolution.
861    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/// One entry discovered from a held directory handle. Discovery never
906/// re-resolves the directory by path; callers still perform the authoritative
907/// open relative to the same handle, so entry replacement races fail closed.
908#[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    /// Modification time (ms since epoch) from the handle-relative
915    /// `fstatat(AT_SYMLINK_NOFOLLOW)` — of the entry ITSELF, never a
916    /// symlink target.
917    pub mtime_unix_ms: Option<u64>,
918    /// File length from the same handle-relative stat.
919    pub byte_len: u64,
920}
921
922#[cfg(unix)]
923impl ConfinedDir {
924    /// Enumerate direct children via `fdopendir(dup(dirfd))` + `readdir` and
925    /// classify each with handle-relative `fstatat(AT_SYMLINK_NOFOLLOW)`.
926    /// Symlinks are returned as neither file nor directory. Names that are
927    /// non-UTF8 or invalid confined components are omitted.
928    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                // Entry disappeared or changed during enumeration. The
965                // authoritative later open decides; there is nothing safe to
966                // report from this stale directory record.
967                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) }; // owns duplicated fd
983        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/// Split an ABSOLUTE path into validated real components for the strict
992/// no-symlink walks: rejects relative paths, `.`/`..`, and non-UTF8.
993#[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/// Validate one path component: non-empty, no separators, not `.`/`..`,
1022/// no NUL. Returns it as a `CString` for the *at syscalls.
1023#[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/// Split and validate a RELATIVE multi-component path (`a/b/c.jsonl`).
1040/// Rejects absolute paths, `.`/`..`, empty components, and backslashes.
1041#[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}