Skip to main content

ferrous_forge/rust_version/
parser.rs

1//! Release notes parser for extracting security updates and breaking changes
2//!
3//! Parses GitHub release notes to identify security advisories,
4//! breaking changes, and other important information.
5//!
6//! @task T024
7//! @epic T014
8
9use regex::Regex;
10use std::sync::OnceLock;
11
12/// Parsed release information
13#[derive(Debug, Clone)]
14pub struct ParsedRelease {
15    /// Version string
16    pub version: String,
17    /// Full release notes
18    pub full_notes: String,
19    /// Security advisories found
20    pub security_advisories: Vec<SecurityAdvisory>,
21    /// Breaking changes
22    pub breaking_changes: Vec<BreakingChange>,
23    /// New features
24    pub new_features: Vec<String>,
25    /// Performance improvements
26    pub performance_improvements: Vec<String>,
27    /// Bug fixes
28    pub bug_fixes: Vec<String>,
29}
30
31/// Security advisory information
32#[derive(Debug, Clone)]
33pub struct SecurityAdvisory {
34    /// Advisory ID (e.g., CVE number)
35    pub id: Option<String>,
36    /// Description of the vulnerability
37    pub description: String,
38    /// Severity level
39    pub severity: Severity,
40    /// Affected components
41    pub affected_components: Vec<String>,
42}
43
44/// Breaking change information
45#[derive(Debug, Clone)]
46pub struct BreakingChange {
47    /// Description of the change
48    pub description: String,
49    /// Migration guidance
50    pub migration: Option<String>,
51    /// Affected edition
52    pub affected_edition: Option<String>,
53}
54
55/// Severity level for security issues
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
57pub enum Severity {
58    /// Critical - immediate action required
59    Critical,
60    /// High - should update soon
61    High,
62    /// Medium - update when convenient
63    Medium,
64    /// Low - informational
65    Low,
66    /// Unknown severity
67    Unknown,
68}
69
70impl std::fmt::Display for Severity {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Critical => write!(f, "CRITICAL"),
74            Self::High => write!(f, "HIGH"),
75            Self::Medium => write!(f, "MEDIUM"),
76            Self::Low => write!(f, "LOW"),
77            Self::Unknown => write!(f, "UNKNOWN"),
78        }
79    }
80}
81
82/// Security keywords for detection
83#[allow(clippy::panic)] // Hardcoded regex patterns are programmer-verified
84fn security_keywords() -> &'static [Regex] {
85    static KEYWORDS: OnceLock<Vec<Regex>> = OnceLock::new();
86    KEYWORDS.get_or_init(|| {
87        vec![
88            Regex::new(r"(?i)security")
89                .unwrap_or_else(|_| panic!("Invalid regex pattern for security")),
90            Regex::new(r"(?i)vulnerability")
91                .unwrap_or_else(|_| panic!("Invalid regex pattern for vulnerability")),
92            Regex::new(r"CVE-\d{4}-\d+")
93                .unwrap_or_else(|_| panic!("Invalid regex pattern for CVE")),
94            Regex::new(r"(?i)exploit")
95                .unwrap_or_else(|_| panic!("Invalid regex pattern for exploit")),
96            Regex::new(r"(?i)buffer.?overflow")
97                .unwrap_or_else(|_| panic!("Invalid regex pattern for buffer overflow")),
98            Regex::new(r"(?i)memory.?safety")
99                .unwrap_or_else(|_| panic!("Invalid regex pattern for memory safety")),
100            Regex::new(r"(?i)unsound")
101                .unwrap_or_else(|_| panic!("Invalid regex pattern for unsound")),
102            Regex::new(r"(?i)undefined.?behavior")
103                .unwrap_or_else(|_| panic!("Invalid regex pattern for undefined behavior")),
104        ]
105    })
106}
107
108/// Breaking change keywords for detection
109#[allow(clippy::panic)] // Hardcoded regex patterns are programmer-verified
110fn breaking_keywords() -> &'static [Regex] {
111    static KEYWORDS: OnceLock<Vec<Regex>> = OnceLock::new();
112    KEYWORDS.get_or_init(|| {
113        vec![
114            Regex::new(r"(?i)breaking.?change")
115                .unwrap_or_else(|_| panic!("Invalid regex for breaking change")),
116            Regex::new(r"(?i)\[breaking\]")
117                .unwrap_or_else(|_| panic!("Invalid regex for [breaking]")),
118            Regex::new(r"(?i)incompatible")
119                .unwrap_or_else(|_| panic!("Invalid regex for incompatible")),
120            Regex::new(r"(?i)deprecated")
121                .unwrap_or_else(|_| panic!("Invalid regex for deprecated")),
122            Regex::new(r"(?i)removed").unwrap_or_else(|_| panic!("Invalid regex for removed")),
123        ]
124    })
125}
126
127/// Feature keywords
128#[allow(dead_code)]
129#[allow(clippy::panic)] // Hardcoded regex patterns are programmer-verified
130fn feature_keywords() -> &'static [Regex] {
131    static KEYWORDS: OnceLock<Vec<Regex>> = OnceLock::new();
132    KEYWORDS.get_or_init(|| {
133        vec![
134            Regex::new(r"(?i)new.?feature")
135                .unwrap_or_else(|_| panic!("Invalid regex for new feature")),
136            Regex::new(r"(?i)stabilized")
137                .unwrap_or_else(|_| panic!("Invalid regex for stabilized")),
138            Regex::new(r"(?i)added.?support")
139                .unwrap_or_else(|_| panic!("Invalid regex for added support")),
140        ]
141    })
142}
143
144/// Performance keywords
145#[allow(dead_code)]
146#[allow(clippy::panic)] // Hardcoded regex patterns are programmer-verified
147fn performance_keywords() -> &'static [Regex] {
148    static KEYWORDS: OnceLock<Vec<Regex>> = OnceLock::new();
149    KEYWORDS.get_or_init(|| {
150        vec![
151            Regex::new(r"(?i)performance")
152                .unwrap_or_else(|_| panic!("Invalid regex for performance")),
153            Regex::new(r"(?i)faster").unwrap_or_else(|_| panic!("Invalid regex for faster")),
154            Regex::new(r"(?i)optimized").unwrap_or_else(|_| panic!("Invalid regex for optimized")),
155            Regex::new(r"(?i)improved.?compile")
156                .unwrap_or_else(|_| panic!("Invalid regex for improved compile")),
157        ]
158    })
159}
160
161/// Parse release notes and extract structured information
162///
163/// # Examples
164///
165/// ```
166/// # use ferrous_forge::rust_version::parser::parse_release_notes;
167/// let notes = "Rust 1.70.0\n\nSecurity:\n- Fixed CVE-2023-1234 buffer overflow\n\nBreaking Changes:\n- Deprecated old API";
168/// let parsed = parse_release_notes("1.70.0", notes);
169/// assert_eq!(parsed.version, "1.70.0");
170/// assert!(!parsed.security_advisories.is_empty());
171/// ```
172pub fn parse_release_notes(version: &str, notes: &str) -> ParsedRelease {
173    let mut parsed = ParsedRelease {
174        version: version.to_string(),
175        full_notes: notes.to_string(),
176        security_advisories: Vec::new(),
177        breaking_changes: Vec::new(),
178        new_features: Vec::new(),
179        performance_improvements: Vec::new(),
180        bug_fixes: Vec::new(),
181    };
182
183    let lines: Vec<&str> = notes.lines().collect();
184    let mut current_section: Option<&str> = None;
185
186    for line in lines {
187        let trimmed = line.trim();
188
189        // Detect section headers
190        if trimmed.starts_with("#") || trimmed.ends_with(':') {
191            current_section = Some(trimmed.trim_start_matches('#').trim());
192            continue;
193        }
194
195        // Skip empty lines
196        if trimmed.is_empty() {
197            continue;
198        }
199
200        // Parse based on current section or content
201        if is_security_related(trimmed) {
202            if let Some(advisory) = parse_security_advisory(trimmed) {
203                parsed.security_advisories.push(advisory);
204            }
205        }
206
207        if is_breaking_change(trimmed) {
208            if let Some(change) = parse_breaking_change(trimmed) {
209                parsed.breaking_changes.push(change);
210            }
211        }
212
213        // Categorize by section if detected
214        if let Some(section) = current_section {
215            categorize_by_section(&mut parsed, section, trimmed);
216        }
217    }
218
219    parsed
220}
221
222/// Check if line contains security-related content
223fn is_security_related(line: &str) -> bool {
224    let lower = line.to_lowercase();
225    security_keywords().iter().any(|re| re.is_match(&lower))
226}
227
228/// Check if line indicates a breaking change
229fn is_breaking_change(line: &str) -> bool {
230    let lower = line.to_lowercase();
231    breaking_keywords().iter().any(|re| re.is_match(&lower))
232}
233
234/// Parse a security advisory from a line
235fn parse_security_advisory(line: &str) -> Option<SecurityAdvisory> {
236    let line_lower = line.to_lowercase();
237
238    // Extract CVE ID
239    let id = extract_cve_id(line);
240
241    // Determine severity
242    let severity = if line_lower.contains("critical") || line_lower.contains("severe") {
243        Severity::Critical
244    } else if line_lower.contains("high") {
245        Severity::High
246    } else if line_lower.contains("medium") || line_lower.contains("moderate") {
247        Severity::Medium
248    } else if line_lower.contains("low") {
249        Severity::Low
250    } else {
251        Severity::Unknown
252    };
253
254    // Extract description (remove bullet points and IDs)
255    let description = line.trim_start_matches(['-', '*', '•']).trim().to_string();
256
257    Some(SecurityAdvisory {
258        id,
259        description,
260        severity,
261        affected_components: Vec::new(),
262    })
263}
264
265/// Extract CVE ID from text
266fn extract_cve_id(text: &str) -> Option<String> {
267    // CVE pattern is hardcoded and validated - use unwrap_or with empty fallback
268    let re = Regex::new(r"CVE-\d{4}-\d+").unwrap_or_else(|_| {
269        // This should never happen with a hardcoded valid regex
270        Regex::new(r"$^").unwrap_or_else(|_| unreachable!())
271    });
272    re.find(text).map(|m| m.as_str().to_string())
273}
274
275/// Parse a breaking change from a line
276fn parse_breaking_change(line: &str) -> Option<BreakingChange> {
277    let description = line.trim_start_matches(['-', '*', '•']).trim().to_string();
278
279    // Try to detect migration guidance
280    let migration =
281        if line.to_lowercase().contains("use") || line.to_lowercase().contains("replace") {
282            Some(description.clone())
283        } else {
284            None
285        };
286
287    Some(BreakingChange {
288        description,
289        migration,
290        affected_edition: None,
291    })
292}
293
294/// Categorize content based on section header
295fn categorize_by_section(parsed: &mut ParsedRelease, section: &str, content: &str) {
296    let section_lower = section.to_lowercase();
297
298    if section_lower.contains("feature") || section_lower.contains("language") {
299        parsed.new_features.push(content.to_string());
300    } else if section_lower.contains("performance") || section_lower.contains("compile") {
301        parsed.performance_improvements.push(content.to_string());
302    } else if section_lower.contains("bug") || section_lower.contains("fix") {
303        parsed.bug_fixes.push(content.to_string());
304    }
305}
306
307/// Check if a version has critical security issues
308///
309/// # Examples
310///
311/// ```
312/// # use ferrous_forge::rust_version::parser::has_critical_security_issues;
313/// let notes = "Security: Fixed CRITICAL vulnerability";
314/// assert!(has_critical_security_issues(notes));
315/// ```
316pub fn has_critical_security_issues(notes: &str) -> bool {
317    let parsed = parse_release_notes("", notes);
318    parsed
319        .security_advisories
320        .iter()
321        .any(|a| a.severity == Severity::Critical)
322}
323
324/// Get security summary for a release
325///
326/// Returns a human-readable summary of security issues.
327///
328/// # Examples
329///
330/// ```
331/// # use ferrous_forge::rust_version::parser::get_security_summary;
332/// let notes = "CVE-2023-1234: Security fix\nCVE-2023-5678: Another fix";
333/// let summary = get_security_summary(notes);
334/// assert!(summary.contains("2 security"));
335/// ```
336pub fn get_security_summary(notes: &str) -> String {
337    let parsed = parse_release_notes("", notes);
338
339    if parsed.security_advisories.is_empty() {
340        return "No security advisories".to_string();
341    }
342
343    let critical_count = parsed
344        .security_advisories
345        .iter()
346        .filter(|a| a.severity == Severity::Critical)
347        .count();
348    let high_count = parsed
349        .security_advisories
350        .iter()
351        .filter(|a| a.severity == Severity::High)
352        .count();
353
354    let mut summary = format!("{} security advisory", parsed.security_advisories.len());
355    if parsed.security_advisories.len() > 1 {
356        summary.push('s');
357    }
358
359    if critical_count > 0 {
360        summary.push_str(&format!(", {} CRITICAL", critical_count));
361    }
362    if high_count > 0 {
363        summary.push_str(&format!(", {} HIGH", high_count));
364    }
365
366    summary
367}
368
369/// Check if current version is affected by security advisories
370///
371/// Compares current version against releases with security fixes.
372///
373/// # Arguments
374///
375/// * `current_version` - The currently installed Rust version
376/// * `releases` - List of recent releases to check
377///
378/// # Returns
379///
380/// Returns true if the current version is missing security updates.
381pub fn is_version_affected(
382    current_version: &str,
383    releases: &[crate::rust_version::GitHubRelease],
384) -> bool {
385    let Ok(current) = semver::Version::parse(current_version.trim_start_matches('v')) else {
386        return false;
387    };
388
389    for release in releases {
390        // Only check releases newer than current
391        if release.version > current {
392            let parsed = parse_release_notes(&release.tag_name, &release.body);
393            if !parsed.security_advisories.is_empty() {
394                return true;
395            }
396        }
397    }
398
399    false
400}
401
402#[cfg(test)]
403#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_parse_security_advisory() {
409        let line = "- Fixed CVE-2023-1234: Critical buffer overflow vulnerability";
410        let advisory = parse_security_advisory(line).unwrap();
411
412        assert_eq!(advisory.id, Some("CVE-2023-1234".to_string()));
413        assert_eq!(advisory.severity, Severity::Critical);
414        assert!(advisory.description.contains("buffer overflow"));
415    }
416
417    #[test]
418    fn test_extract_cve_id() {
419        assert_eq!(
420            extract_cve_id("Fixed CVE-2023-1234 issue"),
421            Some("CVE-2023-1234".to_string())
422        );
423        assert_eq!(extract_cve_id("No CVE here"), None);
424    }
425
426    #[test]
427    fn test_has_critical_security_issues() {
428        assert!(has_critical_security_issues(
429            "Security: Fixed CRITICAL vulnerability"
430        ));
431        assert!(!has_critical_security_issues("Added new feature"));
432    }
433
434    #[test]
435    fn test_get_security_summary() {
436        let notes = "CVE-2023-1234: High severity\nCVE-2023-5678: Critical severity";
437        let summary = get_security_summary(notes);
438
439        assert!(summary.contains("2 security"));
440        assert!(summary.contains("1 CRITICAL"));
441        assert!(summary.contains("1 HIGH"));
442    }
443
444    #[test]
445    fn test_is_security_related() {
446        assert!(is_security_related("Fixed security vulnerability"));
447        assert!(is_security_related("CVE-2023-1234 buffer overflow"));
448        assert!(!is_security_related("Added new feature"));
449    }
450
451    #[test]
452    fn test_is_breaking_change() {
453        assert!(is_breaking_change("[Breaking] Removed old API"));
454        assert!(is_breaking_change("Deprecated function"));
455        assert!(!is_breaking_change("Bug fix"));
456    }
457
458    #[test]
459    fn test_parse_breaking_change() {
460        let line = "- Deprecated std::mem::uninitialized()";
461        let change = parse_breaking_change(line).unwrap();
462
463        assert!(change.description.contains("uninitialized"));
464    }
465
466    #[test]
467    fn test_parse_release_notes_comprehensive() {
468        let notes = r#"Rust 1.70.0
469
470## Security
471- Fixed CVE-2023-1234: Critical buffer overflow (CVE-2023-1234)
472- Addressed CVE-2023-5678: HIGH severity memory safety issue
473
474## Breaking Changes
475- Deprecated old API
476
477## Language
478- Stabilized new features
479
480## Performance
481- Improved compile times
482"#;
483
484        let parsed = parse_release_notes("1.70.0", notes);
485
486        assert_eq!(parsed.version, "1.70.0");
487        assert_eq!(parsed.security_advisories.len(), 2);
488        assert_eq!(parsed.breaking_changes.len(), 1);
489        assert!(!parsed.new_features.is_empty());
490        assert!(!parsed.performance_improvements.is_empty());
491    }
492
493    #[test]
494    fn test_severity_ordering() {
495        assert!(Severity::Critical > Severity::High);
496        assert!(Severity::High > Severity::Medium);
497        assert!(Severity::Medium > Severity::Low);
498    }
499}