securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;

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

use super::client::HfClient;

/// Persisted state for API compatibility tracking.
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiCompatState {
    pub api: ApiInfo,
    #[serde(default)]
    pub endpoints: HashMap<String, EndpointInfo>,
}

/// Top-level info about the HuggingFace OpenAPI spec.
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiInfo {
    pub openapi_sha256: String,
    pub last_checked: String,
    pub spec_version: String,
}

/// Expected fields and verification timestamp for a single endpoint.
#[derive(Debug, Serialize, Deserialize)]
pub struct EndpointInfo {
    pub expected_fields: Vec<String>,
    pub last_verified: String,
}

/// Result of an API compatibility check.
#[derive(Debug)]
pub enum ApiCompatLevel {
    /// Spec unchanged.
    Ok,
    /// Additive / non-breaking changes detected.
    Warn(Vec<String>),
    /// Breaking changes detected.
    Error(Vec<String>),
    /// No previous state on disk.
    FirstRun,
}

impl fmt::Display for ApiCompatLevel {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Ok => write!(f, "API compatible (spec unchanged)"),
            Self::Warn(msgs) => {
                write!(f, "API warnings:")?;
                for m in msgs {
                    write!(f, "\n  - {}", m)?;
                }
                Ok(())
            }
            Self::Error(msgs) => {
                write!(f, "API breaking changes:")?;
                for m in msgs {
                    write!(f, "\n  - {}", m)?;
                }
                Ok(())
            }
            Self::FirstRun => write!(f, "First run — API baseline recorded"),
        }
    }
}

// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------

fn compat_state_path() -> PathBuf {
    dirs::config_dir()
        .unwrap_or_else(|| PathBuf::from(".config"))
        .join("securegit")
        .join("hf_api_compat.toml")
}

// ---------------------------------------------------------------------------
// Load / save
// ---------------------------------------------------------------------------

/// Load the persisted compatibility state, returning `None` on any failure.
pub fn load_state() -> Option<ApiCompatState> {
    let path = compat_state_path();
    let contents = std::fs::read_to_string(path).ok()?;
    toml::from_str(&contents).ok()
}

/// Save the compatibility state to disk, creating parent directories as needed.
pub fn save_state(state: &ApiCompatState) -> Result<()> {
    let path = compat_state_path();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
    }
    let serialized =
        toml::to_string_pretty(state).context("Failed to serialize API compat state")?;
    std::fs::write(&path, serialized)
        .with_context(|| format!("Failed to write API compat state to {}", path.display()))?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Hashing
// ---------------------------------------------------------------------------

/// Compute the SHA-256 hash of a spec string, returned as a lowercase hex string.
pub fn hash_spec(spec: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(spec.as_bytes());
    hex::encode(hasher.finalize())
}

// ---------------------------------------------------------------------------
// Staleness
// ---------------------------------------------------------------------------

/// Returns `true` if `state.api.last_checked` is older than `max_age_hours` from now.
pub fn is_stale(state: &ApiCompatState, max_age_hours: u64) -> bool {
    let last = match chrono::DateTime::parse_from_rfc3339(&state.api.last_checked) {
        Ok(dt) => dt.with_timezone(&chrono::Utc),
        Err(_) => return true, // unparseable ⇒ treat as stale
    };
    let age = chrono::Utc::now().signed_duration_since(last);
    age > chrono::Duration::hours(max_age_hours as i64)
}

// ---------------------------------------------------------------------------
// Full API compat check
// ---------------------------------------------------------------------------

/// Fetch the OpenAPI spec, compare with the stored baseline, and return the
/// compatibility level.  Always updates the stored state on success.
pub async fn check_api_compat(client: &HfClient) -> Result<ApiCompatLevel> {
    let spec = client
        .fetch_openapi_spec()
        .await
        .context("Failed to fetch OpenAPI spec for compat check")?;

    let new_hash = hash_spec(&spec);
    let now = chrono::Utc::now().to_rfc3339();

    // Try to extract the OpenAPI version from the JSON spec.
    let spec_version = serde_json::from_str::<serde_json::Value>(&spec)
        .ok()
        .and_then(|v| {
            v.get("openapi")
                .or_else(|| v.get("info").and_then(|i| i.get("version")))
                .and_then(|v| v.as_str().map(String::from))
        })
        .unwrap_or_else(|| "unknown".to_string());

    let previous = load_state();

    let level = match previous {
        None => {
            // First run — record baseline.
            let state = ApiCompatState {
                api: ApiInfo {
                    openapi_sha256: new_hash,
                    last_checked: now,
                    spec_version,
                },
                endpoints: default_endpoint_expectations(),
            };
            save_state(&state)?;
            ApiCompatLevel::FirstRun
        }
        Some(prev) => {
            let hash_changed = prev.api.openapi_sha256 != new_hash;
            let version_changed = prev.api.spec_version != spec_version;

            let level = if hash_changed {
                ApiCompatLevel::Error(vec![format!(
                    "OpenAPI spec hash changed: {} -> {}",
                    prev.api.openapi_sha256, new_hash
                )])
            } else if version_changed {
                ApiCompatLevel::Warn(vec![format!(
                    "Spec version changed: {} -> {}",
                    prev.api.spec_version, spec_version
                )])
            } else {
                ApiCompatLevel::Ok
            };

            // Always update stored state.
            let state = ApiCompatState {
                api: ApiInfo {
                    openapi_sha256: new_hash,
                    last_checked: now,
                    spec_version,
                },
                endpoints: prev.endpoints,
            };
            save_state(&state)?;

            level
        }
    };

    Ok(level)
}

// ---------------------------------------------------------------------------
// Response field verification
// ---------------------------------------------------------------------------

/// Verify that the expected fields for `endpoint_name` are present in
/// `response`.  Updates the `last_verified` timestamp in the mutable state.
pub fn verify_response_fields(
    endpoint_name: &str,
    response: &serde_json::Value,
    state: &mut ApiCompatState,
) -> ApiCompatLevel {
    let ep = match state.endpoints.get(endpoint_name) {
        Some(ep) => ep,
        None => return ApiCompatLevel::Ok, // no expectations registered
    };

    // Determine the object to inspect: if the response is an array, use the
    // first element; otherwise use the response itself.
    let obj = match response {
        serde_json::Value::Array(arr) => arr.first().and_then(|v| v.as_object()),
        serde_json::Value::Object(_) => response.as_object(),
        _ => None,
    };

    let missing: Vec<String> = match obj {
        Some(map) => ep
            .expected_fields
            .iter()
            .filter(|f| !map.contains_key(f.as_str()))
            .cloned()
            .collect(),
        None => ep.expected_fields.clone(), // no object at all ⇒ everything missing
    };

    // Update last_verified regardless of outcome.
    let now = chrono::Utc::now().to_rfc3339();
    if let Some(ep_mut) = state.endpoints.get_mut(endpoint_name) {
        ep_mut.last_verified = now;
    }

    if missing.is_empty() {
        ApiCompatLevel::Ok
    } else {
        ApiCompatLevel::Error(
            missing
                .iter()
                .map(|f| format!("missing field '{}' in endpoint '{}'", f, endpoint_name))
                .collect(),
        )
    }
}

// ---------------------------------------------------------------------------
// Default expectations
// ---------------------------------------------------------------------------

fn default_endpoint_expectations() -> HashMap<String, EndpointInfo> {
    let now = chrono::Utc::now().to_rfc3339();
    let mut map = HashMap::new();

    map.insert(
        "models_info".to_string(),
        EndpointInfo {
            expected_fields: vec![
                "id".into(),
                "modelId".into(),
                "sha".into(),
                "pipeline_tag".into(),
                "tags".into(),
                "downloads".into(),
                "likes".into(),
            ],
            last_verified: now.clone(),
        },
    );

    map.insert(
        "models_search".to_string(),
        EndpointInfo {
            expected_fields: vec!["modelId".into(), "downloads".into(), "likes".into()],
            last_verified: now.clone(),
        },
    );

    map.insert(
        "tree".to_string(),
        EndpointInfo {
            expected_fields: vec!["type".into(), "oid".into(), "size".into(), "path".into()],
            last_verified: now,
        },
    );

    map
}