//! Safe tarball extraction with defense-in-depth against path traversal.
//!
//! `extract_safe` takes any `impl Read` source and a target directory,
//! iterates entries manually, and rejects any entry that would escape the
//! target directory, is not a regular file or directory, or would be
//! written through a pre-planted symlink.
//!
//! This module has **no** dependency on HTTP, ureq, or `GithubClient`.
//! The split seam from the network layer is at the `impl Read` boundary.

#![allow(dead_code)]

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

use path_clean::PathClean;
use thiserror::Error;

/// Errors returned by tarball extraction operations.
#[derive(Debug, Error)]
pub enum Error {
    /// A tarball entry attempted to escape the target directory via `..`,
    /// absolute paths, or Windows prefix components.
    #[error("tarball path traversal attempt: {entry_path}")]
    Escape { entry_path: PathBuf },

    /// A tarball entry is not a regular file or directory (e.g. symlink,
    /// hardlink, character device, FIFO).
    #[error("unsupported tarball entry type '{kind}': {entry_path}")]
    UnsupportedEntry {
        entry_path: PathBuf,
        kind: &'static str,
    },

    /// A pre-existing symlink was found in the target tree before extraction.
    /// Extraction is refused to prevent symlink-following attacks.
    #[error("pre-existing symlink in target tree: {path}")]
    PrePlantedSymlink { path: PathBuf },

    /// Underlying I/O error during extraction.
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

/// Extract a `.tar.gz` archive from `source` into `target`, rejecting
/// any entry that would escape the target or is not a regular file/directory.
///
/// # Pre-extraction checks
///
/// Before iterating entries, this function:
/// 1. Canonicalizes `target` (caller must create it first).
/// 2. Walks every ancestor of `target` up to the filesystem root and
///    verifies none is a symlink.
/// 3. Lists every direct child of `target` and verifies none is a symlink.
///
/// # Per-entry checks
///
/// For each entry:
/// - Only `Regular` and `Directory` types are accepted; all others
///   (symlinks, hardlinks, devices, etc.) are rejected.
/// - Path components are scanned: `..`, `RootDir`, and `Prefix` are rejected.
/// - Absolute paths are rejected.
/// - A lexical prefix check confirms the resolved path stays within `target`
///   (case-insensitive on Windows).
pub fn extract_safe(source: impl Read, target: &Path) -> Result<(), Error> {
    // ── Pre-extraction symlink precondition ──────────────────────────
    let canon_target = target.canonicalize()?;

    // Check ancestors of target for symlinks.
    check_ancestors_not_symlinks(&canon_target)?;

    // Check direct children of target for symlinks.
    check_children_not_symlinks(&canon_target)?;

    // ── Extraction loop ──────────────────────────────────────────────
    let gz = flate2::read::GzDecoder::new(source);
    let mut archive = tar::Archive::new(gz);

    for entry_result in archive.entries()? {
        let mut entry = entry_result?;
        let header = entry.header();

        // Filter entry type: accept only Regular and Directory.
        let entry_type = header.entry_type();
        if !entry_type.is_file() && !entry_type.is_dir() {
            let kind = classify_entry_type(entry_type);
            let entry_path = entry.path()?.into_owned();
            return Err(Error::UnsupportedEntry { entry_path, kind });
        }

        // Read and validate the path.
        let raw_path = entry.path()?.into_owned();

        // Reject absolute paths.
        if raw_path.is_absolute() {
            return Err(Error::Escape {
                entry_path: raw_path,
            });
        }

        // Reject dangerous components.
        for component in raw_path.components() {
            match component {
                Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                    return Err(Error::Escape {
                        entry_path: raw_path,
                    });
                }
                _ => {}
            }
        }

        // Lexical prefix check: resolved path must stay within target.
        let resolved = canon_target.join(&raw_path).clean();
        if !starts_with_normalized(&resolved, &canon_target) {
            return Err(Error::Escape {
                entry_path: raw_path,
            });
        }

        // Extract the entry. tar's own guards are defense-in-depth.
        entry.unpack_in(&canon_target)?;
    }

    Ok(())
}

/// Check that no ancestor of `path` is a symlink.
fn check_ancestors_not_symlinks(path: &Path) -> Result<(), Error> {
    let mut current = path.to_path_buf();
    while let Some(parent) = current.parent() {
        if parent.as_os_str().is_empty() {
            break;
        }
        if parent.symlink_metadata()?.file_type().is_symlink() {
            return Err(Error::PrePlantedSymlink {
                path: parent.to_path_buf(),
            });
        }
        current = parent.to_path_buf();
    }
    Ok(())
}

/// Check that no direct child of `path` is a symlink.
fn check_children_not_symlinks(path: &Path) -> Result<(), Error> {
    if !path.is_dir() {
        return Ok(());
    }
    for entry in std::fs::read_dir(path)? {
        let entry = entry?;
        if entry.metadata()?.file_type().is_symlink() {
            return Err(Error::PrePlantedSymlink { path: entry.path() });
        }
    }
    Ok(())
}

/// Case-aware `starts_with` check. On Windows, normalizes to lowercase
/// for comparison. On Unix, uses byte comparison.
fn starts_with_normalized(path: &Path, prefix: &Path) -> bool {
    #[cfg(windows)]
    {
        let path_lower = path.to_string_lossy().to_lowercase();
        let prefix_lower = prefix.to_string_lossy().to_lowercase();
        path_lower.starts_with(&prefix_lower)
    }
    #[cfg(not(windows))]
    {
        path.starts_with(prefix)
    }
}

/// Map tar entry type to a human-readable label.
fn classify_entry_type(t: tar::EntryType) -> &'static str {
    match t {
        tar::EntryType::Symlink => "symlink",
        tar::EntryType::Link => "hardlink",
        tar::EntryType::Char => "char device",
        tar::EntryType::Block => "block device",
        tar::EntryType::Fifo => "fifo",
        tar::EntryType::GNUSparse => "sparse",
        _ => "unknown",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;
    use tempfile::TempDir;

    /// Build a .tar.gz in memory with the given entries.
    /// Each entry is (path, content_bytes, is_dir).
    fn build_tar_gz(entries: &[(&str, &[u8], bool)]) -> Vec<u8> {
        let mut builder = tar::Builder::new(Vec::new());
        for &(path, content, is_dir) in entries {
            if is_dir {
                let mut header = tar::Header::new_gnu();
                header.set_entry_type(tar::EntryType::Directory);
                header.set_size(0);
                header.set_mode(0o755);
                header.set_cksum();
                builder
                    .append_data(&mut header, path, &[] as &[u8])
                    .unwrap();
            } else {
                let mut header = tar::Header::new_gnu();
                header.set_entry_type(tar::EntryType::Regular);
                header.set_size(content.len() as u64);
                header.set_mode(0o644);
                header.set_cksum();
                builder.append_data(&mut header, path, content).unwrap();
            }
        }
        let tar_bytes = builder.into_inner().unwrap();

        // Compress with gzip.
        use flate2::write::GzEncoder;
        use flate2::Compression;
        use std::io::Write;
        let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
        encoder.write_all(&tar_bytes).unwrap();
        encoder.finish().unwrap()
    }

    /// Build a .tar.gz with a symlink entry.
    fn build_tar_gz_with_symlink(link_name: &str, target: &str) -> Vec<u8> {
        let mut builder = tar::Builder::new(Vec::new());
        let mut header = tar::Header::new_gnu();
        header.set_entry_type(tar::EntryType::Symlink);
        header.set_size(0);
        header.set_mode(0o777);
        header.set_cksum();
        builder.append_link(&mut header, link_name, target).unwrap();
        let tar_bytes = builder.into_inner().unwrap();

        use flate2::write::GzEncoder;
        use flate2::Compression;
        use std::io::Write;
        let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
        encoder.write_all(&tar_bytes).unwrap();
        encoder.finish().unwrap()
    }

    /// Build a .tar.gz with a hardlink entry.
    fn build_tar_gz_with_hardlink(link_name: &str, target: &str) -> Vec<u8> {
        let mut builder = tar::Builder::new(Vec::new());
        let mut header = tar::Header::new_gnu();
        header.set_entry_type(tar::EntryType::Link);
        header.set_size(0);
        header.set_mode(0o644);
        header.set_cksum();
        builder.append_link(&mut header, link_name, target).unwrap();
        let tar_bytes = builder.into_inner().unwrap();

        use flate2::write::GzEncoder;
        use flate2::Compression;
        use std::io::Write;
        let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
        encoder.write_all(&tar_bytes).unwrap();
        encoder.finish().unwrap()
    }

    /// Build a .tar.gz with a single file at a forged path.
    ///
    /// The tar crate's builder validates paths and rejects `..` and absolute
    /// paths. To test our extraction guards, we need to forge a tarball with
    /// a malicious path at the raw byte level. This builds a valid tar entry
    /// with a safe path, then patches the path bytes in the raw tar data
    /// before gzip-compressing.
    fn build_tar_gz_with_forged_path(malicious_path: &str) -> Vec<u8> {
        // Build a tar with a placeholder path.
        let placeholder = "PLACEHOLDER_PATH_FOR_FORGING";
        assert!(
            malicious_path.len() <= placeholder.len(),
            "malicious path too long for placeholder"
        );

        let content = b"evil";
        let mut builder = tar::Builder::new(Vec::new());
        let mut header = tar::Header::new_gnu();
        header.set_entry_type(tar::EntryType::Regular);
        header.set_size(content.len() as u64);
        header.set_mode(0o644);
        header.set_cksum();
        builder
            .append_data(&mut header, placeholder, &content[..])
            .unwrap();
        let mut tar_bytes = builder.into_inner().unwrap();

        // Find and replace the placeholder path in the raw tar bytes.
        // The path lives in the first 100 bytes of the 512-byte header.
        if let Some(pos) = tar_bytes
            .windows(placeholder.len())
            .position(|w| w == placeholder.as_bytes())
        {
            // Zero out the path field first.
            for b in &mut tar_bytes[pos..pos + placeholder.len()] {
                *b = 0;
            }
            // Write the malicious path.
            tar_bytes[pos..pos + malicious_path.len()].copy_from_slice(malicious_path.as_bytes());

            // Recompute the header checksum (bytes 148..156).
            // The checksum is the sum of all header bytes treating the
            // checksum field itself as 8 spaces (0x20).
            let header_start = pos - (pos % 512); // Align to 512-byte block.
            let mut sum: u64 = 0;
            for (i, &b) in tar_bytes[header_start..header_start + 512]
                .iter()
                .enumerate()
            {
                if (148..156).contains(&i) {
                    sum += 0x20u64; // Checksum field treated as spaces.
                } else {
                    sum += b as u64;
                }
            }
            let cksum = format!("{sum:06o}\0 ");
            tar_bytes[header_start + 148..header_start + 156].copy_from_slice(cksum.as_bytes());
        }

        // Compress with gzip.
        use flate2::write::GzEncoder;
        use flate2::Compression;
        use std::io::Write;
        let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
        encoder.write_all(&tar_bytes).unwrap();
        encoder.finish().unwrap()
    }

    // ── Happy path ───────────────────────────────────────────────────

    #[test]
    fn extracts_regular_files_to_target() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("dest");
        std::fs::create_dir_all(&target).unwrap();

        let gz = build_tar_gz(&[
            ("core/", b"", true),
            ("core/manifest.json", b"{\"version\":\"0.1\"}", false),
        ]);

        extract_safe(Cursor::new(gz), &target).unwrap();

        let manifest = target.join("core/manifest.json");
        assert!(manifest.exists(), "manifest.json should exist");
        assert_eq!(
            std::fs::read_to_string(manifest).unwrap(),
            "{\"version\":\"0.1\"}"
        );
    }

    #[test]
    fn extracts_nested_directories() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("dest");
        std::fs::create_dir_all(&target).unwrap();

        let gz = build_tar_gz(&[
            ("core/", b"", true),
            ("core/skills/", b"", true),
            ("core/skills/query-installation/", b"", true),
            ("core/skills/query-installation/SKILL.md", b"# Query", false),
        ]);

        extract_safe(Cursor::new(gz), &target).unwrap();

        let file = target.join("core/skills/query-installation/SKILL.md");
        assert!(file.exists());
        assert_eq!(std::fs::read_to_string(file).unwrap(), "# Query");
    }

    #[test]
    fn empty_tarball_succeeds() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("dest");
        std::fs::create_dir_all(&target).unwrap();

        let gz = build_tar_gz(&[]);
        extract_safe(Cursor::new(gz), &target).unwrap();
        // No panic, no error — target remains empty.
    }

    // ── Path traversal rejection ─────────────────────────────────────

    #[test]
    fn rejects_dotdot_path_traversal() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("dest");
        std::fs::create_dir_all(&target).unwrap();

        let gz = build_tar_gz_with_forged_path("../../etc/passwd");
        let err = extract_safe(Cursor::new(gz), &target).unwrap_err();
        assert!(
            matches!(err, Error::Escape { .. }),
            "Expected Escape, got: {err:?}"
        );
    }

    #[test]
    fn rejects_absolute_path() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("dest");
        std::fs::create_dir_all(&target).unwrap();

        let gz = build_tar_gz_with_forged_path("/tmp/evil");
        let err = extract_safe(Cursor::new(gz), &target).unwrap_err();
        assert!(
            matches!(err, Error::Escape { .. }),
            "Expected Escape, got: {err:?}"
        );
    }

    // ── Entry type rejection ─────────────────────────────────────────

    #[test]
    fn rejects_symlink_entry() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("dest");
        std::fs::create_dir_all(&target).unwrap();

        let gz = build_tar_gz_with_symlink("evil-link", "/etc/passwd");
        let err = extract_safe(Cursor::new(gz), &target).unwrap_err();
        match err {
            Error::UnsupportedEntry { kind, .. } => assert_eq!(kind, "symlink"),
            other => panic!("Expected UnsupportedEntry(symlink), got: {other:?}"),
        }
    }

    #[test]
    fn rejects_hardlink_entry() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("dest");
        std::fs::create_dir_all(&target).unwrap();

        let gz = build_tar_gz_with_hardlink("evil-link", "core/manifest.json");
        let err = extract_safe(Cursor::new(gz), &target).unwrap_err();
        match err {
            Error::UnsupportedEntry { kind, .. } => assert_eq!(kind, "hardlink"),
            other => panic!("Expected UnsupportedEntry(hardlink), got: {other:?}"),
        }
    }

    // ── Pre-planted symlink precondition ─────────────────────────────

    // Note: creating directory symlinks on Windows requires elevated privileges.
    // These tests use file symlinks which work without elevation on Windows 10+
    // with Developer Mode enabled, but are gated by a runtime capability check.

    #[test]
    fn rejects_pre_planted_symlink_child() {
        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("dest");
        std::fs::create_dir_all(&target).unwrap();

        let decoy = tmp.path().join("decoy");
        std::fs::create_dir_all(&decoy).unwrap();

        // Plant a symlink as a direct child of target.
        let symlink_path = target.join("evil");
        #[cfg(unix)]
        std::os::unix::fs::symlink(&decoy, &symlink_path).unwrap();
        #[cfg(windows)]
        {
            if std::os::windows::fs::symlink_dir(&decoy, &symlink_path).is_err() {
                // Symlink creation requires Developer Mode on Windows.
                // Skip this test if we can't create symlinks.
                eprintln!("Skipping: symlink creation requires Developer Mode");
                return;
            }
        }

        let gz = build_tar_gz(&[("core/manifest.json", b"data", false)]);
        let err = extract_safe(Cursor::new(gz), &target).unwrap_err();
        assert!(
            matches!(err, Error::PrePlantedSymlink { .. }),
            "Expected PrePlantedSymlink, got: {err:?}"
        );
    }

    // ── Error Display tests ──────────────────────────────────────────

    #[test]
    fn escape_error_names_path() {
        let err = Error::Escape {
            entry_path: PathBuf::from("../../etc/passwd"),
        };
        let display = format!("{err}");
        assert!(display.contains("../../etc/passwd"));
    }

    #[test]
    fn unsupported_entry_names_kind_and_path() {
        let err = Error::UnsupportedEntry {
            entry_path: PathBuf::from("evil-link"),
            kind: "symlink",
        };
        let display = format!("{err}");
        assert!(display.contains("symlink"));
        assert!(display.contains("evil-link"));
    }

    #[test]
    fn pre_planted_symlink_error_names_path() {
        let err = Error::PrePlantedSymlink {
            path: PathBuf::from("/some/path"),
        };
        let display = format!("{err}");
        assert!(display.contains("/some/path"));
    }
}
