//! Binary-identity fingerprint for the integrity lane (SEC-065).
//!
//! Why this module exists: SEC-014 pins a tool's *description* across two
//! catalog snapshots, but a server can also rug-pull by swapping the binary
//! behind a stdio command while leaving the catalog untouched (the
//! CVE-2025-54136 class: a trusted `command` whose underlying executable is
//! replaced after the user approved it). The catalog diff cannot see that, so
//! this lane records a fingerprint of the *resolved launch identity* into the
//! baseline and fails when it drifts.
//!
//! A [`ServerFingerprint`] is a black-box, deterministic record: for a stdio
//! server it is the resolved executable path, a SHA-256 of that file's
//! contents, and the args; for a URL server it is the resolved endpoint and
//! host. The comparison ([`ServerFingerprint::drift`]) classifies what moved so
//! the integrity lane can raise SEC-065 with concrete evidence, mirroring the
//! SEC-014 before/after evidence shape.

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

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

/// The pinned launch identity of a single MCP server.
///
/// Serializable so it can ride inside the baseline file (see
/// [`crate::baseline::TimingBaseline`]) and survive a round-trip. Two variants
/// because the thing worth pinning differs by transport: a stdio server is
/// pinned by the binary on disk, a URL server by the endpoint it resolves to.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum ServerFingerprint {
    /// A stdio server: the resolved binary plus its launch args.
    Stdio {
        /// The literal `command` the suite declared, before PATH resolution.
        /// Kept so a report can show what the user wrote next to where it
        /// resolved.
        command: String,
        /// The absolute path the command resolved to via PATH, when it could be
        /// resolved. `None` means the command was not found on PATH at capture
        /// time; the content hash is then also `None`.
        resolved_path: Option<String>,
        /// Hex SHA-256 of the resolved binary's file contents, when the file
        /// could be read. `None` mirrors a `None` `resolved_path` or an
        /// unreadable file.
        content_hash: Option<String>,
        /// The argv tail (everything after `command`). Order is significant, so
        /// a reordered launch is treated as drift.
        args: Vec<String>,
    },
    /// A URL server: the endpoint and its host.
    Url {
        /// The resolved endpoint the runner would POST to.
        endpoint: String,
        /// The host component of `endpoint`, captured separately so a
        /// host-level swap (the headline SSRF/rug-pull signal) reads clearly in
        /// the drift evidence even when the path also changed.
        host: Option<String>,
    },
}

impl ServerFingerprint {
    /// Compute a stdio fingerprint from a command and its args.
    ///
    /// Resolves `command` against `PATH` (an absolute or `./`-relative command
    /// is taken as-is), then hashes the resolved file's contents with SHA-256.
    /// A command that cannot be resolved, or a file that cannot be read, yields
    /// a fingerprint with `None` for the path/hash rather than an error: a
    /// missing binary is itself a signal the baseline should record, and the
    /// drift check treats "was present, now absent" as drift.
    pub fn stdio(command: &str, args: &[String]) -> Self {
        let resolved = resolve_on_path(command);
        let content_hash = resolved.as_deref().and_then(hash_file);
        ServerFingerprint::Stdio {
            command: command.to_string(),
            resolved_path: resolved.map(|p| p.to_string_lossy().into_owned()),
            content_hash,
            args: args.to_vec(),
        }
    }

    /// Compute a URL fingerprint from a resolved endpoint.
    ///
    /// The host is parsed off the endpoint with a small, dependency-free split
    /// (scheme, authority, path) rather than pulling a URL crate, since the
    /// integrity lane only needs the authority to flag a host swap.
    pub fn url(endpoint: &str) -> Self {
        ServerFingerprint::Url {
            endpoint: endpoint.to_string(),
            host: host_of(endpoint),
        }
    }

    /// Classify how `current` drifted from `self` (the approved baseline).
    ///
    /// Returns the set of concrete changes, empty when the two fingerprints are
    /// identical. A cross-transport change (a stdio baseline now reached over a
    /// URL, say) is reported as a single [`FingerprintChange::TransportChanged`]
    /// rather than a field-by-field diff, since none of the fields line up.
    pub fn drift(&self, current: &ServerFingerprint) -> Vec<FingerprintChange> {
        match (self, current) {
            (
                ServerFingerprint::Stdio {
                    resolved_path: old_path,
                    content_hash: old_hash,
                    args: old_args,
                    ..
                },
                ServerFingerprint::Stdio {
                    resolved_path: new_path,
                    content_hash: new_hash,
                    args: new_args,
                    ..
                },
            ) => stdio_drift(old_path, new_path, old_hash, new_hash, old_args, new_args),
            (
                ServerFingerprint::Url {
                    endpoint: old_endpoint,
                    host: old_host,
                },
                ServerFingerprint::Url {
                    endpoint: new_endpoint,
                    host: new_host,
                },
            ) => url_drift(old_endpoint, new_endpoint, old_host, new_host),
            // The transport itself changed; the fields do not align, so this is
            // one coarse change rather than a misleading per-field diff.
            _ => vec![FingerprintChange::TransportChanged],
        }
    }
}

/// One concrete way a [`ServerFingerprint`] drifted from its baseline.
///
/// The integrity lane turns each change into SEC-065 evidence, so the variants
/// carry the before/after values a reviewer needs to see what moved.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FingerprintChange {
    /// The resolved binary path changed (or appeared/disappeared).
    PathChanged {
        /// The baseline's resolved path, `<unresolved>` when it was absent.
        old: String,
        /// The current resolved path, `<unresolved>` when it is absent.
        new: String,
    },
    /// The binary's content hash changed: the headline binary-swap signal.
    ContentHashChanged {
        /// The baseline content hash, `<none>` when it was absent.
        old: String,
        /// The current content hash, `<none>` when it is absent.
        new: String,
    },
    /// The launch args changed (added, removed, or reordered).
    ArgsChanged {
        /// The baseline argv tail, space-joined.
        old: String,
        /// The current argv tail, space-joined.
        new: String,
    },
    /// A URL server's resolved endpoint changed.
    EndpointChanged {
        /// The baseline endpoint.
        old: String,
        /// The current endpoint.
        new: String,
    },
    /// A URL server's host changed: a redirect to a different origin.
    HostChanged {
        /// The baseline host, `<unknown>` when it could not be parsed.
        old: String,
        /// The current host, `<unknown>` when it could not be parsed.
        new: String,
    },
    /// The transport kind itself changed between baseline and current.
    TransportChanged,
}

/// Build the list of stdio drift changes between a baseline and a current
/// fingerprint. Pulled out so [`ServerFingerprint::drift`] stays short.
fn stdio_drift(
    old_path: &Option<String>,
    new_path: &Option<String>,
    old_hash: &Option<String>,
    new_hash: &Option<String>,
    old_args: &[String],
    new_args: &[String],
) -> Vec<FingerprintChange> {
    let mut out = Vec::new();
    if old_path != new_path {
        out.push(FingerprintChange::PathChanged {
            old: option_or(old_path, "<unresolved>"),
            new: option_or(new_path, "<unresolved>"),
        });
    }
    if old_hash != new_hash {
        out.push(FingerprintChange::ContentHashChanged {
            old: option_or(old_hash, "<none>"),
            new: option_or(new_hash, "<none>"),
        });
    }
    if old_args != new_args {
        out.push(FingerprintChange::ArgsChanged {
            old: old_args.join(" "),
            new: new_args.join(" "),
        });
    }
    out
}

/// Build the list of URL drift changes between a baseline and a current
/// fingerprint.
fn url_drift(
    old_endpoint: &str,
    new_endpoint: &str,
    old_host: &Option<String>,
    new_host: &Option<String>,
) -> Vec<FingerprintChange> {
    let mut out = Vec::new();
    if old_host != new_host {
        out.push(FingerprintChange::HostChanged {
            old: option_or(old_host, "<unknown>"),
            new: option_or(new_host, "<unknown>"),
        });
    }
    if old_endpoint != new_endpoint {
        out.push(FingerprintChange::EndpointChanged {
            old: old_endpoint.to_string(),
            new: new_endpoint.to_string(),
        });
    }
    out
}

/// Render an optional value, falling back to `placeholder` when absent.
fn option_or(value: &Option<String>, placeholder: &str) -> String {
    value.clone().unwrap_or_else(|| placeholder.to_string())
}

/// Resolve a command to an absolute path the way a shell would.
///
/// An absolute path, or one containing a path separator (`./server`,
/// `bin/server`), is returned as-is when it exists. A bare command name is
/// looked up across the `PATH` entries, returning the first existing match.
/// Returns `None` when nothing matches, which the caller records as an
/// unresolved fingerprint rather than an error.
fn resolve_on_path(command: &str) -> Option<PathBuf> {
    if command.contains(std::path::MAIN_SEPARATOR) || command.contains('/') {
        let direct = PathBuf::from(command);
        return direct.is_file().then_some(direct);
    }
    let path_var = std::env::var_os("PATH")?;
    std::env::split_paths(&path_var)
        .map(|dir| dir.join(command))
        .find(|candidate| candidate.is_file())
}

/// Hash a file's contents with SHA-256, returning the hex digest.
///
/// Returns `None` when the file cannot be read (permissions, a TOCTOU removal,
/// a directory) so a transient read failure records as "no hash" rather than
/// aborting the capture.
fn hash_file(path: &Path) -> Option<String> {
    let bytes = std::fs::read(path).ok()?;
    let mut hasher = Sha256::new();
    hasher.update(&bytes);
    Some(hex(&hasher.finalize()))
}

/// Parse the host (authority minus userinfo and port) out of a URL string.
///
/// A dependency-free split: drop the scheme, take the authority up to the first
/// `/`, `?`, or `#`, then strip any `user@` prefix and `:port` suffix. Returns
/// `None` when there is no authority to read, which the drift check treats as an
/// unknown host rather than a match.
fn host_of(url: &str) -> Option<String> {
    let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
    let authority = after_scheme
        .split(['/', '?', '#'])
        .next()
        .unwrap_or(after_scheme);
    let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
    // An IPv6 literal is bracketed; keep the brackets and the address, drop a
    // trailing `:port` only when it is outside the brackets.
    let host = if let Some(stripped) = host_port.strip_prefix('[') {
        stripped.split_once(']').map_or(host_port, |(h, _)| h)
    } else {
        host_port.split(':').next().unwrap_or(host_port)
    };
    (!host.is_empty()).then(|| host.to_string())
}

/// Lowercase hex encoding of a byte slice, matching the other SHA-256 call
/// sites in the crate.
fn hex(bytes: &[u8]) -> String {
    use std::fmt::Write;
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        // Writing to a String cannot fail; the result is discarded
        // intentionally.
        let _ = write!(out, "{byte:02x}");
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write as _;

    fn temp_binary(contents: &[u8]) -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("fake-server");
        let mut f = std::fs::File::create(&path).unwrap();
        f.write_all(contents).unwrap();
        (dir, path)
    }

    #[test]
    fn stdio_fingerprint_of_same_binary_is_stable() {
        let (_dir, path) = temp_binary(b"#!/bin/sh\necho hi\n");
        let cmd = path.to_string_lossy().into_owned();
        let a = ServerFingerprint::stdio(&cmd, &["--flag".into()]);
        let b = ServerFingerprint::stdio(&cmd, &["--flag".into()]);
        assert_eq!(
            a, b,
            "the same binary and args must fingerprint identically"
        );
        // And the content hash is populated for a readable file.
        match a {
            ServerFingerprint::Stdio { content_hash, .. } => assert!(content_hash.is_some()),
            ServerFingerprint::Url { .. } => unreachable!(),
        }
    }

    #[test]
    fn stdio_drift_detects_a_changed_content_hash() {
        let (_dir_a, path_a) = temp_binary(b"original payload");
        let baseline = ServerFingerprint::stdio(&path_a.to_string_lossy(), &[]);
        // Overwrite the same path with different bytes: a binary swap in place.
        std::fs::write(&path_a, b"malicious payload").unwrap();
        let current = ServerFingerprint::stdio(&path_a.to_string_lossy(), &[]);
        let changes = baseline.drift(&current);
        assert!(
            changes
                .iter()
                .any(|c| matches!(c, FingerprintChange::ContentHashChanged { .. })),
            "a rewritten binary must report a content-hash change, got {changes:?}"
        );
    }

    #[test]
    fn stdio_drift_is_empty_when_nothing_changed() {
        let (_dir, path) = temp_binary(b"stable");
        let cmd = path.to_string_lossy().into_owned();
        let baseline = ServerFingerprint::stdio(&cmd, &["-p".into()]);
        let current = ServerFingerprint::stdio(&cmd, &["-p".into()]);
        assert!(baseline.drift(&current).is_empty());
    }

    #[test]
    fn stdio_drift_detects_changed_args() {
        let (_dir, path) = temp_binary(b"stable");
        let cmd = path.to_string_lossy().into_owned();
        let baseline = ServerFingerprint::stdio(&cmd, &["--safe".into()]);
        let current = ServerFingerprint::stdio(&cmd, &["--danger".into()]);
        let changes = baseline.drift(&current);
        assert!(changes
            .iter()
            .any(|c| matches!(c, FingerprintChange::ArgsChanged { .. })));
    }

    #[test]
    fn unresolved_command_records_no_path_or_hash() {
        let fp = ServerFingerprint::stdio("definitely-not-a-real-binary-xyzzy", &[]);
        match fp {
            ServerFingerprint::Stdio {
                resolved_path,
                content_hash,
                ..
            } => {
                assert!(resolved_path.is_none());
                assert!(content_hash.is_none());
            }
            ServerFingerprint::Url { .. } => unreachable!(),
        }
    }

    #[test]
    fn url_fingerprint_parses_host_and_detects_host_swap() {
        let baseline = ServerFingerprint::url("https://api.example.com/mcp");
        let current = ServerFingerprint::url("https://evil.example.net/mcp");
        let changes = baseline.drift(&current);
        assert!(changes
            .iter()
            .any(|c| matches!(c, FingerprintChange::HostChanged { .. })));
    }

    #[test]
    fn url_fingerprint_is_stable_for_the_same_endpoint() {
        let a = ServerFingerprint::url("https://api.example.com:8443/mcp");
        let b = ServerFingerprint::url("https://api.example.com:8443/mcp");
        assert_eq!(a, b);
        assert!(a.drift(&b).is_empty());
    }

    #[test]
    fn host_of_strips_userinfo_and_port() {
        assert_eq!(
            host_of("https://u:p@host.example:9000/x"),
            Some("host.example".into())
        );
        assert_eq!(host_of("http://[::1]:8080/path"), Some("::1".into()));
        assert_eq!(host_of("not a url"), Some("not a url".into()));
    }

    #[test]
    fn cross_transport_change_is_reported_as_transport_changed() {
        let stdio = ServerFingerprint::stdio("server", &[]);
        let url = ServerFingerprint::url("https://api.example.com/mcp");
        assert_eq!(stdio.drift(&url), vec![FingerprintChange::TransportChanged]);
    }

    #[test]
    fn fingerprint_round_trips_through_yaml() {
        let fp = ServerFingerprint::Stdio {
            command: "uvx".into(),
            resolved_path: Some("/usr/bin/uvx".into()),
            content_hash: Some("abcd".into()),
            args: vec!["mcp-server".into()],
        };
        let yaml = serde_yaml::to_string(&fp).unwrap();
        let back: ServerFingerprint = serde_yaml::from_str(&yaml).unwrap();
        assert_eq!(fp, back);
    }
}
