//! The [`CanonicalPathBuf`] primitive: a symlink-free canonical path that, on
//! Linux, is backed by a **live `O_PATH` handle pinning the exact inode** it
//! names — not merely a path string.

use std::{
    io,
    path::{Path, PathBuf},
};

#[cfg(target_os = "linux")]
use std::os::fd::{AsFd as _, AsRawFd as _, BorrowedFd, OwnedFd};

/// A canonical, symlink-free filesystem path.
///
/// On **Linux** this is not merely a path string: it also owns a live `O_PATH`
/// file descriptor pinning the exact inode the path named at construction time.
/// The descriptor — not the text — is the security-relevant identity, so
/// enforcement can prove the object living at the path *now* is still the one
/// that was captured, closing the classic time-of-check-to-time-of-use hole
/// where a verified path is swapped for a symlink before it is actually used.
/// On **Windows** it is a plain path string, but constrained by construction to
/// one of the two shapes a sandboxed WSL command can name: a Windows drive path
/// (`C:\...`, on NTFS) or a Linux-absolute path (`/...`, inside the WSL distro).
/// `\\wsl.localhost\...`, other UNC paths, and relative paths are rejected — a
/// sandboxed project is always local (WSL projects are remote and unsandboxed),
/// so a grant is either the project's own NTFS files or a WSL-native location.
/// On other platforms it is a plain canonical path (see the constructors for the
/// per-platform rationale).
///
/// Invariant: [`path`](Self::path) is an **absolute**, symlink-free realpath; on
/// Linux this is *proven* at construction (`resolve` reads it back from
/// `/proc/self/fd`, `from_canonical` re-verifies a claimed value against the
/// reopened fd). Both constructors reject a non-absolute input on the platforms
/// that resolve paths themselves (Linux/macOS): a relative path would otherwise
/// be resolved against Zed's *own* process working directory, silently pinning
/// an unpredictable location.
#[derive(Clone)]
pub(crate) struct CanonicalPathBuf {
    path: PathBuf,
    /// An `O_PATH` descriptor pinning the inode of the canonical target. Wrapped
    /// in an `Arc` only so the surrounding policy types can stay `Clone`;
    /// cloning shares the same underlying descriptor.
    #[cfg(target_os = "linux")]
    fd: std::sync::Arc<OwnedFd>,
}

impl CanonicalPathBuf {
    /// Resolve `path` to its canonical target, **following** symlinks to
    /// discover the real object it names.
    ///
    /// Following symlinks is intentional: the goal is to find the true target so
    /// it can be shown, persisted, and pinned. Protection against a *later* swap
    /// comes from persisting the resolved path and rebuilding with
    /// [`Self::from_canonical`], which verifies it.
    pub(crate) fn resolve(path: impl AsRef<Path>) -> io::Result<Self> {
        let path = path.as_ref();
        // A relative path would be resolved against Zed's own process working
        // directory by the `open`/`canonicalize` below, silently pinning an
        // unpredictable location. Every grant flows through this constructor, so
        // rejecting here fails closed for all of them at once.
        #[cfg(any(target_os = "linux", target_os = "macos"))]
        require_absolute(path)?;
        #[cfg(target_os = "windows")]
        require_windows_grant_shape(path)?;

        #[cfg(target_os = "macos")]
        {
            // `canonicalize_allowing_missing_leaf` resolves through the existing
            // parent so a not-yet-created leaf still yields the real path
            // Seatbelt will match against.
            Ok(Self {
                path: super::canonicalize_allowing_missing_leaf(path),
            })
        }
        #[cfg(target_os = "linux")]
        {
            use std::os::unix::fs::OpenOptionsExt as _;
            // `O_PATH` opens a handle that refers to the inode without granting
            // read/write on its contents, which is exactly what a bind source
            // needs. Symlinks are followed (no `O_NOFOLLOW`) so the fd pins the
            // real target; its canonical path is then read back from
            // `/proc/self/fd`.
            let file = std::fs::OpenOptions::new()
                .read(true)
                .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
                .open(path)?;
            let fd = OwnedFd::from(file);
            let path = std::fs::read_link(format!("/proc/self/fd/{}", fd.as_raw_fd()))?;
            Ok(Self {
                path,
                fd: std::sync::Arc::new(fd),
            })
        }
        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
        {
            Ok(Self {
                path: path.to_path_buf(),
            })
        }
    }

    /// Verify a *claimed* canonical `path`, proving the object now living there
    /// is the one that was approved.
    ///
    /// This is the load-bearing reconstruction for user-approved write grants,
    /// which must survive process restarts and therefore can't keep the
    /// approval-time fd alive.
    ///
    /// On **Linux** this opens an `O_PATH` fd with `O_NOFOLLOW`, rejects a
    /// symlink leaf (`S_IFLNK` check, [`io::ErrorKind::PermissionDenied`]), and
    /// requires the fd's real path to still equal `path` (also
    /// `PermissionDenied` otherwise) — so any component swapped for a symlink
    /// after approval fails closed. On **macOS/other** the claimed path is
    /// trusted verbatim: Seatbelt re-resolves the access path at syscall time
    /// (so a later swap is denied, not redirected), and WSL/other resolve
    /// elsewhere.
    pub(crate) fn from_canonical(path: PathBuf) -> io::Result<Self> {
        // Same absolute-path requirement as `resolve`: a persisted or
        // hand-authored relative grant must never be resolved against Zed's own
        // working directory (Linux) or emitted as a relative Seatbelt literal
        // (macOS).
        #[cfg(any(target_os = "linux", target_os = "macos"))]
        require_absolute(&path)?;
        #[cfg(target_os = "windows")]
        require_windows_grant_shape(&path)?;

        #[cfg(target_os = "macos")]
        {
            Ok(Self { path })
        }
        #[cfg(target_os = "linux")]
        {
            use std::os::unix::fs::OpenOptionsExt as _;
            // `O_NOFOLLOW` makes a symlink *leaf* open the symlink itself
            // (harmless with `O_PATH`) rather than its target, so we can detect
            // and reject it below; intermediate components are still traversed
            // and caught by the canonical-path comparison.
            let file = std::fs::OpenOptions::new()
                .read(true)
                .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW)
                .open(&path)?;
            let fd = OwnedFd::from(file);

            // Reject a symlink leaf outright: a grant must name a real directory,
            // and `readlink` of an `O_PATH|O_NOFOLLOW` fd on a symlink returns
            // the symlink's *own* path (equal to `path`), so the comparison
            // below wouldn't catch it.
            let stat = nix::sys::stat::fstat(fd.as_raw_fd()).map_err(io::Error::from)?;
            if stat.st_mode & libc::S_IFMT == libc::S_IFLNK {
                return Err(io::Error::new(
                    io::ErrorKind::PermissionDenied,
                    format!(
                        "sandbox write grant {} is a symlink, not a directory",
                        path.display()
                    ),
                ));
            }

            // Load-bearing: the pinned inode's real path must still be exactly
            // the approved canonical path. If any component became a symlink
            // after approval, the fd resolves elsewhere and this diverges.
            let current = std::fs::read_link(format!("/proc/self/fd/{}", fd.as_raw_fd()))?;
            if current != path {
                return Err(io::Error::new(
                    io::ErrorKind::PermissionDenied,
                    format!(
                        "sandbox write grant {} was redirected to {}",
                        path.display(),
                        current.display()
                    ),
                ));
            }

            Ok(Self {
                path,
                fd: std::sync::Arc::new(fd),
            })
        }
        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
        {
            Ok(Self { path })
        }
    }

    /// The canonical, symlink-free path.
    pub(crate) fn path(&self) -> &Path {
        &self.path
    }

    /// Consume this value, returning the canonical path.
    #[cfg_attr(target_os = "windows", expect(dead_code))]
    pub(crate) fn into_path(self) -> PathBuf {
        self.path
    }

    /// Linux: a borrowed handle to the pinned inode, for `fstat`-based identity
    /// checks.
    #[cfg(target_os = "linux")]
    pub(crate) fn fd(&self) -> BorrowedFd<'_> {
        self.fd.as_fd()
    }

    /// Linux: an independent `O_PATH` descriptor to the same pinned inode,
    /// duplicated (with `O_CLOEXEC`) so a validation server can own and send it
    /// over `SCM_RIGHTS` without affecting this value's descriptor.
    #[cfg(target_os = "linux")]
    pub(crate) fn dup_fd(&self) -> io::Result<OwnedFd> {
        self.fd.as_fd().try_clone_to_owned()
    }
}

/// Reject a non-absolute grant path. Gated to the platforms whose constructors
/// resolve paths against the process working directory; on WSL/other the path is
/// a namespace-specific form that `Path::is_absolute` would misjudge, and its
/// real resolution happens WSL-side (see `crate::windows_wsl`).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn require_absolute(path: &Path) -> io::Result<()> {
    if path.is_absolute() {
        Ok(())
    } else {
        Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("sandbox grant path {} is not absolute", path.display()),
        ))
    }
}

/// Windows: enforce that a stored grant path is one of the two shapes a
/// sandboxed WSL command can name — a Windows drive path (`C:\...` or `\\?\C:\...`,
/// on NTFS) or a Linux-absolute path (`/...`, inside the WSL distro). Everything
/// else (notably `\\wsl.localhost\...` and other UNC paths, and relative paths)
/// is rejected, so an invalid grant shape can't be represented as a
/// [`CanonicalPathBuf`]. `Path::is_absolute` isn't used: it would reject a
/// perfectly valid Linux-absolute grant like `/home/me` on Windows.
#[cfg(target_os = "windows")]
fn require_windows_grant_shape(path: &Path) -> io::Result<()> {
    let text = path.to_string_lossy();
    // Linux-absolute (WSL): exactly one leading '/'.
    let is_wsl = text.starts_with('/') && !text.starts_with("//");
    // Windows drive (NTFS): `X:...`, optionally behind the `\\?\` verbatim prefix.
    let drive = text.strip_prefix(r"\\?\").unwrap_or(&text);
    let drive = drive.as_bytes();
    let is_windows_drive = drive.len() >= 2 && drive[0].is_ascii_alphabetic() && drive[1] == b':';
    if is_wsl || is_windows_drive {
        Ok(())
    } else {
        Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!(
                "sandbox grant path {} is neither a Windows drive path (`C:\\...`) \
                 nor a WSL absolute path (`/...`)",
                path.display()
            ),
        ))
    }
}

impl PartialEq for CanonicalPathBuf {
    /// Two values are equal when they refer to the **same filesystem object**:
    /// the inode behind the `O_PATH` fd on Linux, the canonical path on
    /// macOS/other — never merely equal path text where an fd is available. This
    /// is what lets policy bookkeeping dedupe "the same location named two
    /// different ways" and refuse to treat "two different objects that happen to
    /// share a path string" as one.
    fn eq(&self, other: &Self) -> bool {
        #[cfg(target_os = "linux")]
        {
            match (
                linux_fd_identity(self.fd.as_raw_fd()),
                linux_fd_identity(other.fd.as_raw_fd()),
            ) {
                (Some(a), Some(b)) => a == b,
                // An `fstat` on an `O_PATH` fd we own should never fail; if it
                // somehow does we can't prove identity, so report "not equal"
                // (the safe answer) and leave a trace.
                _ => {
                    log::error!(
                        "failed to fstat an O_PATH descriptor while comparing sandbox locations"
                    );
                    false
                }
            }
        }
        #[cfg(not(target_os = "linux"))]
        {
            // Canonicalization is a bijection on real paths, so equal canonical
            // paths mean the same directory/file.
            self.path == other.path
        }
    }
}

impl Eq for CanonicalPathBuf {}

/// The `(device, inode)` pair behind an `O_PATH` descriptor, used to decide
/// whether two [`CanonicalPathBuf`]s (or their parents) refer to the same
/// filesystem object.
#[cfg(target_os = "linux")]
pub(crate) fn linux_fd_identity(fd: std::os::fd::RawFd) -> Option<(u64, u64)> {
    let stat = nix::sys::stat::fstat(fd).ok()?;
    Some((stat.st_dev as u64, stat.st_ino as u64))
}

#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
    use super::CanonicalPathBuf;
    use std::path::PathBuf;

    #[test]
    fn constructors_reject_relative_paths() {
        // A relative grant would otherwise be resolved against Zed's own working
        // directory; both constructors must fail closed before touching the fs.
        // (`CanonicalPathBuf` is deliberately not `Debug`, so match rather than
        // `expect_err`.)
        match CanonicalPathBuf::resolve("relative/dir") {
            Ok(_) => panic!("resolve must reject a relative path"),
            Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput),
        }
        match CanonicalPathBuf::from_canonical(PathBuf::from("relative/dir")) {
            Ok(_) => panic!("from_canonical must reject a relative path"),
            Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput),
        }
    }
}

#[cfg(all(test, target_os = "windows"))]
mod windows_tests {
    use super::CanonicalPathBuf;
    use std::path::PathBuf;

    #[test]
    fn constructors_enforce_windows_grant_shape() {
        // Accepted: Windows drive paths (NTFS) and WSL absolute paths.
        for ok in [
            r"C:\Users\me",
            r"\\?\C:\Users\me",
            "/home/me/proj",
            "/mnt/c/Users/me",
        ] {
            assert!(
                CanonicalPathBuf::from_canonical(PathBuf::from(ok)).is_ok(),
                "{ok} should be accepted"
            );
        }
        // Rejected: `\\wsl.localhost\...`, other UNC, and relative paths.
        for bad in [
            r"\\wsl.localhost\Ubuntu\home\me",
            r"\\wsl$\Ubuntu\home\me",
            r"\\server\share\dir",
            r"relative\dir",
            "also/relative",
        ] {
            match CanonicalPathBuf::from_canonical(PathBuf::from(bad)) {
                Ok(_) => panic!("{bad} should be rejected"),
                Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput),
            }
        }
    }
}
