vein_adapter/cache/
quarantine.rs

1//! Quarantine types and logic for supply chain protection.
2//!
3//! Implements a time buffer that delays new gem versions from appearing
4//! in the index, protecting against supply chain attacks like malicious
5//! gem releases that get yanked within hours.
6
7use chrono::{DateTime, Datelike, Duration, NaiveTime, Utc, Weekday};
8use serde::{Deserialize, Serialize};
9
10/// Status of a gem version in the quarantine system.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum VersionStatus {
14    /// In delay period - hidden from index but downloadable directly
15    #[default]
16    Quarantine,
17    /// Delay expired - visible in index and downloadable
18    Available,
19    /// Upstream removed it - hidden and blocked
20    Yanked,
21    /// Manual override - immediately available (e.g., critical security patch)
22    Pinned,
23}
24
25impl std::fmt::Display for VersionStatus {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Self::Quarantine => write!(f, "quarantine"),
29            Self::Available => write!(f, "available"),
30            Self::Yanked => write!(f, "yanked"),
31            Self::Pinned => write!(f, "pinned"),
32        }
33    }
34}
35
36impl std::str::FromStr for VersionStatus {
37    type Err = ();
38
39    fn from_str(s: &str) -> Result<Self, Self::Err> {
40        match s {
41            "quarantine" => Ok(Self::Quarantine),
42            "available" => Ok(Self::Available),
43            "yanked" => Ok(Self::Yanked),
44            "pinned" => Ok(Self::Pinned),
45            _ => Err(()),
46        }
47    }
48}
49
50/// A gem version with quarantine tracking information.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct GemVersion {
53    pub id: i64,
54    pub name: String,
55    pub version: String,
56    pub platform: Option<String>,
57    pub sha256: Option<String>,
58    /// When this version was first seen/published
59    pub published_at: DateTime<Utc>,
60    /// When this version becomes visible in the index
61    pub available_after: DateTime<Utc>,
62    pub status: VersionStatus,
63    /// Reason for current status (e.g., "auto", "pinned: CVE-2024-XXX")
64    pub status_reason: Option<String>,
65    /// Whether upstream has yanked this version
66    pub upstream_yanked: bool,
67    pub created_at: DateTime<Utc>,
68    pub updated_at: DateTime<Utc>,
69}
70
71/// Information about a quarantined version, used in HTTP headers.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct QuarantineInfo {
74    pub served_version: String,
75    pub requested_version: String,
76    pub available_after: DateTime<Utc>,
77    pub reason: String,
78    pub quarantined_versions: Vec<String>,
79}
80
81/// Statistics about the quarantine system.
82#[derive(Debug, Clone, Serialize, Deserialize, Default)]
83pub struct QuarantineStats {
84    pub total_quarantined: u64,
85    pub total_available: u64,
86    pub total_yanked: u64,
87    pub total_pinned: u64,
88    pub versions_releasing_today: u64,
89    pub versions_releasing_this_week: u64,
90}
91
92/// Policy configuration for delay calculation.
93/// This is a simplified version for the adapter crate - full config lives in main crate.
94#[derive(Debug, Clone)]
95pub struct DelayPolicy {
96    pub default_delay_days: u32,
97    pub skip_weekends: bool,
98    pub business_hours_only: bool,
99    pub release_hour_utc: u8,
100}
101
102impl Default for DelayPolicy {
103    fn default() -> Self {
104        Self {
105            default_delay_days: 3,
106            skip_weekends: true,
107            business_hours_only: true,
108            release_hour_utc: 9,
109        }
110    }
111}
112
113/// Calculate when a version should become available based on policy.
114///
115/// # Arguments
116/// * `published` - When the version was first seen
117/// * `policy` - Delay policy configuration
118///
119/// # Returns
120/// DateTime when the version should become visible in the index
121pub fn calculate_availability(published: DateTime<Utc>, policy: &DelayPolicy) -> DateTime<Utc> {
122    let mut available = published + Duration::days(i64::from(policy.default_delay_days));
123
124    if policy.skip_weekends {
125        // Push weekend releases to Monday
126        match available.weekday() {
127            Weekday::Sat => available += Duration::days(2),
128            Weekday::Sun => available += Duration::days(1),
129            _ => {}
130        }
131    }
132
133    if policy.business_hours_only {
134        // Set to configured hour (default 9:00 AM UTC)
135        if let Some(time) = NaiveTime::from_hms_opt(u32::from(policy.release_hour_utc), 0, 0) {
136            available = available.date_naive().and_time(time).and_utc();
137        }
138    }
139
140    available
141}
142
143/// Check if a version is currently available (visible in index).
144///
145/// # Arguments
146/// * `gem_version` - The version to check
147/// * `now` - Current time
148///
149/// # Returns
150/// `true` if the version should be visible in index responses
151pub fn is_version_available(gem_version: &GemVersion, now: DateTime<Utc>) -> bool {
152    match gem_version.status {
153        VersionStatus::Available | VersionStatus::Pinned => true,
154        VersionStatus::Yanked => false,
155        VersionStatus::Quarantine => now >= gem_version.available_after,
156    }
157}
158
159/// Check if a version should be served for direct download.
160/// Note: Even quarantined versions can be downloaded directly.
161///
162/// # Arguments
163/// * `gem_version` - The version to check
164///
165/// # Returns
166/// `true` if the version can be downloaded (only yanked versions are blocked)
167pub fn is_version_downloadable(gem_version: &GemVersion) -> bool {
168    gem_version.status != VersionStatus::Yanked
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use chrono::{TimeZone, Timelike};
175
176    #[test]
177    fn test_calculate_availability_basic() {
178        let published = Utc.with_ymd_and_hms(2025, 1, 6, 14, 0, 0).unwrap(); // Monday
179        let policy = DelayPolicy {
180            default_delay_days: 3,
181            skip_weekends: false,
182            business_hours_only: false,
183            release_hour_utc: 9,
184        };
185
186        let available = calculate_availability(published, &policy);
187        assert_eq!(available.weekday(), Weekday::Thu);
188    }
189
190    #[test]
191    fn test_calculate_availability_skip_weekends() {
192        // Thursday + 3 days = Sunday → should push to Monday
193        let published = Utc.with_ymd_and_hms(2025, 1, 9, 14, 0, 0).unwrap(); // Thursday
194        let policy = DelayPolicy {
195            default_delay_days: 3,
196            skip_weekends: true,
197            business_hours_only: false,
198            release_hour_utc: 9,
199        };
200
201        let available = calculate_availability(published, &policy);
202        assert_eq!(available.weekday(), Weekday::Mon);
203    }
204
205    #[test]
206    fn test_calculate_availability_business_hours() {
207        let published = Utc.with_ymd_and_hms(2025, 1, 6, 22, 0, 0).unwrap(); // Monday 10pm
208        let policy = DelayPolicy {
209            default_delay_days: 3,
210            skip_weekends: false,
211            business_hours_only: true,
212            release_hour_utc: 9,
213        };
214
215        let available = calculate_availability(published, &policy);
216        assert_eq!(available.hour(), 9);
217    }
218
219    #[test]
220    fn test_is_version_available() {
221        let now = Utc::now();
222
223        // Quarantined, not yet available
224        let quarantined = GemVersion {
225            id: 1,
226            name: "test".to_string(),
227            version: "1.0.0".to_string(),
228            platform: None,
229            sha256: None,
230            published_at: now - Duration::days(1),
231            available_after: now + Duration::days(2),
232            status: VersionStatus::Quarantine,
233            status_reason: None,
234            upstream_yanked: false,
235            created_at: now,
236            updated_at: now,
237        };
238        assert!(!is_version_available(&quarantined, now));
239
240        // Quarantined but time has passed
241        let expired_quarantine = GemVersion {
242            available_after: now - Duration::hours(1),
243            ..quarantined.clone()
244        };
245        assert!(is_version_available(&expired_quarantine, now));
246
247        // Available status
248        let available = GemVersion {
249            status: VersionStatus::Available,
250            ..quarantined.clone()
251        };
252        assert!(is_version_available(&available, now));
253
254        // Pinned status
255        let pinned = GemVersion {
256            status: VersionStatus::Pinned,
257            ..quarantined.clone()
258        };
259        assert!(is_version_available(&pinned, now));
260
261        // Yanked
262        let yanked = GemVersion {
263            status: VersionStatus::Yanked,
264            ..quarantined.clone()
265        };
266        assert!(!is_version_available(&yanked, now));
267    }
268
269    #[test]
270    fn test_is_version_downloadable() {
271        let now = Utc::now();
272
273        let base = GemVersion {
274            id: 1,
275            name: "test".to_string(),
276            version: "1.0.0".to_string(),
277            platform: None,
278            sha256: None,
279            published_at: now,
280            available_after: now + Duration::days(3),
281            status: VersionStatus::Quarantine,
282            status_reason: None,
283            upstream_yanked: false,
284            created_at: now,
285            updated_at: now,
286        };
287
288        // Quarantined versions can still be downloaded
289        assert!(is_version_downloadable(&base));
290
291        // Yanked versions cannot be downloaded
292        let yanked = GemVersion {
293            status: VersionStatus::Yanked,
294            ..base
295        };
296        assert!(!is_version_downloadable(&yanked));
297    }
298
299    #[test]
300    fn test_version_status_display() {
301        assert_eq!(VersionStatus::Quarantine.to_string(), "quarantine");
302        assert_eq!(VersionStatus::Available.to_string(), "available");
303        assert_eq!(VersionStatus::Yanked.to_string(), "yanked");
304        assert_eq!(VersionStatus::Pinned.to_string(), "pinned");
305    }
306
307    #[test]
308    fn test_version_status_from_str() {
309        assert_eq!(
310            "quarantine".parse::<VersionStatus>(),
311            Ok(VersionStatus::Quarantine)
312        );
313        assert_eq!(
314            "available".parse::<VersionStatus>(),
315            Ok(VersionStatus::Available)
316        );
317        assert_eq!("yanked".parse::<VersionStatus>(), Ok(VersionStatus::Yanked));
318        assert_eq!("pinned".parse::<VersionStatus>(), Ok(VersionStatus::Pinned));
319        assert!("invalid".parse::<VersionStatus>().is_err());
320    }
321}