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