cargo-cooldown 0.1.0

Cargo wrapper that enforces a cooldown window for freshly published crates on crates.io for improved supply chain security.
use std::process::Command;

use anyhow::Result;
use chrono::{DateTime, Duration, Utc};

use crate::registry::VersionMeta;

#[derive(Debug, Clone)]
pub struct Candidate {
    pub version: String,
    pub created_at: DateTime<Utc>,
}

pub fn filter_candidates(
    versions: Vec<VersionMeta>,
    minimum_minutes: u64,
    now: DateTime<Utc>,
) -> Vec<Candidate> {
    let cutoff = now - Duration::minutes(minimum_minutes as i64);
    let mut filtered: Vec<Candidate> = versions
        .into_iter()
        .filter(|meta| !meta.yanked)
        .filter(|meta| meta.created_at <= cutoff)
        .map(|meta| Candidate {
            version: meta.num,
            created_at: meta.created_at,
        })
        .collect();
    filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at));
    filtered
}

#[derive(Debug)]
pub enum PinOutcome {
    Applied,
    Rejected { stdout: String, stderr: String },
}

pub fn try_pin_precise(name: &str, current: &str, version: &str) -> Result<PinOutcome> {
    let spec = format!("{name}@{current}");
    let output = Command::new("cargo")
        .args(["update", "-p", &spec, "--precise", version])
        .output()?;
    if output.status.success() {
        Ok(PinOutcome::Applied)
    } else {
        Ok(PinOutcome::Rejected {
            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        })
    }
}

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

    #[test]
    fn filters_fresh_versions() {
        let now = Utc.with_ymd_and_hms(2024, 10, 1, 0, 0, 0).unwrap();
        let versions = vec![
            VersionMeta {
                created_at: Utc.with_ymd_and_hms(2024, 9, 30, 23, 50, 0).unwrap(),
                yanked: false,
                num: "1.2.3".into(),
            },
            VersionMeta {
                created_at: Utc.with_ymd_and_hms(2024, 9, 30, 22, 0, 0).unwrap(),
                yanked: false,
                num: "1.2.2".into(),
            },
            VersionMeta {
                created_at: Utc.with_ymd_and_hms(2024, 9, 30, 20, 0, 0).unwrap(),
                yanked: true,
                num: "1.2.1".into(),
            },
        ];
        let candidates = filter_candidates(versions, 30, now);
        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].version, "1.2.2");
    }
}