syncable_cli/analyzer/hadolint/rules/
dl3051.rs

1//! DL3051: Label `org.opencontainers.image.created` is empty or not a valid date
2//!
3//! The created label should contain a valid RFC3339 date.
4
5use crate::analyzer::hadolint::parser::instruction::Instruction;
6use crate::analyzer::hadolint::rules::{SimpleRule, simple_rule};
7use crate::analyzer::hadolint::shell::ParsedShell;
8use crate::analyzer::hadolint::types::Severity;
9
10pub fn rule() -> SimpleRule<impl Fn(&Instruction, Option<&ParsedShell>) -> bool + Send + Sync> {
11    simple_rule(
12        "DL3051",
13        Severity::Warning,
14        "Label `org.opencontainers.image.created` is empty or not a valid RFC3339 date.",
15        |instr, _shell| match instr {
16            Instruction::Label(pairs) => {
17                for (key, value) in pairs {
18                    if key == "org.opencontainers.image.created"
19                        && (value.is_empty() || !is_valid_rfc3339(value))
20                    {
21                        return false;
22                    }
23                }
24                true
25            }
26            _ => true,
27        },
28    )
29}
30
31fn is_valid_rfc3339(date: &str) -> bool {
32    // Basic RFC3339 validation (YYYY-MM-DDTHH:MM:SSZ or with timezone offset)
33    // Full format: 2023-01-15T14:30:00Z or 2023-01-15T14:30:00+00:00
34    if date.len() < 20 {
35        return false;
36    }
37
38    let chars: Vec<char> = date.chars().collect();
39
40    // Check date part
41    if chars.len() < 10 {
42        return false;
43    }
44
45    // YYYY-MM-DD
46    if !chars[0..4].iter().all(|c| c.is_ascii_digit()) {
47        return false;
48    }
49    if chars[4] != '-' {
50        return false;
51    }
52    if !chars[5..7].iter().all(|c| c.is_ascii_digit()) {
53        return false;
54    }
55    if chars[7] != '-' {
56        return false;
57    }
58    if !chars[8..10].iter().all(|c| c.is_ascii_digit()) {
59        return false;
60    }
61
62    // T separator
63    if chars.get(10) != Some(&'T') && chars.get(10) != Some(&'t') {
64        return false;
65    }
66
67    // HH:MM:SS
68    if chars.len() < 19 {
69        return false;
70    }
71    if !chars[11..13].iter().all(|c| c.is_ascii_digit()) {
72        return false;
73    }
74    if chars[13] != ':' {
75        return false;
76    }
77    if !chars[14..16].iter().all(|c| c.is_ascii_digit()) {
78        return false;
79    }
80    if chars[16] != ':' {
81        return false;
82    }
83    if !chars[17..19].iter().all(|c| c.is_ascii_digit()) {
84        return false;
85    }
86
87    // Timezone (Z or +/-HH:MM)
88    if chars.len() == 20 && chars[19] == 'Z' {
89        return true;
90    }
91
92    // Allow fractional seconds before timezone
93    let tz_start = if chars.get(19) == Some(&'.') {
94        // Find where fractional seconds end
95        let mut i = 20;
96        while i < chars.len() && chars[i].is_ascii_digit() {
97            i += 1;
98        }
99        i
100    } else {
101        19
102    };
103
104    if chars.len() > tz_start {
105        let tz_char = chars[tz_start];
106        if tz_char == 'Z' || tz_char == 'z' {
107            return true;
108        }
109        if (tz_char == '+' || tz_char == '-') && chars.len() >= tz_start + 6 {
110            return true;
111        }
112    }
113
114    false
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::analyzer::hadolint::config::HadolintConfig;
121    use crate::analyzer::hadolint::lint::{LintResult, lint};
122
123    fn lint_dockerfile(content: &str) -> LintResult {
124        lint(content, &HadolintConfig::default())
125    }
126
127    #[test]
128    fn test_valid_date() {
129        let result = lint_dockerfile(
130            "FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"2023-01-15T14:30:00Z\"",
131        );
132        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3051"));
133    }
134
135    #[test]
136    fn test_empty_date() {
137        let result =
138            lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"\"");
139        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3051"));
140    }
141
142    #[test]
143    fn test_invalid_date() {
144        let result = lint_dockerfile(
145            "FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"not-a-date\"",
146        );
147        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3051"));
148    }
149}