//! Write sandbox (plan §14): every wiki write is confined to the wiki dir, and
//! source reads are confined to the repo. Paths that try to escape are rejected.

use anyhow::{bail, Result};
use std::path::{Component, Path, PathBuf};

/// Join `rel` under `base`, rejecting absolute paths and `..` escapes.
pub fn safe_join(base: &Path, rel: &str) -> Result<PathBuf> {
    let candidate = Path::new(rel);
    if candidate.is_absolute() {
        bail!("absolute paths are not allowed: {rel}");
    }
    let mut result = base.to_path_buf();
    for comp in candidate.components() {
        match comp {
            Component::Normal(c) => result.push(c),
            Component::CurDir => {}
            Component::ParentDir => bail!("path escapes the allowed directory: {rel}"),
            _ => bail!("unsupported path component in: {rel}"),
        }
    }
    Ok(result)
}

/// A write boundary anchored at the wiki dir.
#[derive(Debug, Clone)]
pub struct Sandbox {
    pub wiki_dir: PathBuf,
}

impl Sandbox {
    pub fn new(wiki_dir: &Path) -> Sandbox {
        Sandbox {
            wiki_dir: wiki_dir.to_path_buf(),
        }
    }

    pub fn resolve(&self, rel: &str) -> Result<PathBuf> {
        safe_join(&self.wiki_dir, rel)
    }

    pub fn write(&self, rel: &str, content: &str) -> Result<()> {
        let path = self.resolve(rel)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
            // Defense in depth: after creating the parent, verify the
            // symlink-resolved parent is still within the symlink-resolved wiki
            // dir, so a symlinked subdir can't redirect a write outside it.
            if let (Ok(base), Ok(real_parent)) = (
                std::fs::canonicalize(&self.wiki_dir),
                std::fs::canonicalize(parent),
            ) {
                if !real_parent.starts_with(&base) {
                    bail!("refusing to write outside the wiki directory: {rel}");
                }
            }
        }
        std::fs::write(&path, content)?;
        Ok(())
    }

    pub fn read(&self, rel: &str) -> Result<String> {
        let path = self.resolve(rel)?;
        Ok(std::fs::read_to_string(path)?)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_parent_escape_and_absolute() {
        let base = Path::new("/tmp/wiki");
        assert!(safe_join(base, "../secret").is_err());
        assert!(safe_join(base, "a/../../b").is_err());
        assert!(safe_join(base, "/etc/passwd").is_err());
    }

    #[test]
    fn joins_normal_paths() {
        let p = safe_join(Path::new("/tmp/wiki"), "modules/auth.md").unwrap();
        assert!(p.ends_with("modules/auth.md"));
    }

    #[test]
    fn write_read_roundtrip_within_sandbox() {
        let dir = std::env::temp_dir().join(format!("hephaes-sbtest-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let sb = Sandbox::new(&dir);
        sb.write("a/b.md", "hello").unwrap();
        assert_eq!(sb.read("a/b.md").unwrap(), "hello");
        assert!(sb.write("../escape.md", "x").is_err());
        std::fs::remove_dir_all(&dir).ok();
    }
}
