syncable_cli/analyzer/dclint/rules/
dcl011.rs

1//! DCL011: service-image-require-explicit-tag
2//!
3//! Service images should have explicit version tags.
4
5use crate::analyzer::dclint::rules::{LintContext, Rule, SimpleRule, make_failure};
6use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
7
8const CODE: &str = "DCL011";
9const NAME: &str = "service-image-require-explicit-tag";
10const DESCRIPTION: &str = "Service images should have explicit version tags.";
11const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/service-image-require-explicit-tag-rule.md";
12
13pub fn rule() -> impl Rule {
14    SimpleRule::new(
15        CODE,
16        NAME,
17        Severity::Warning,
18        RuleCategory::BestPractice,
19        DESCRIPTION,
20        URL,
21        check,
22    )
23}
24
25fn check(ctx: &LintContext) -> Vec<CheckFailure> {
26    let mut failures = Vec::new();
27
28    for (service_name, service) in &ctx.compose.services {
29        if let Some(image) = &service.image {
30            // Check if image has a tag
31            let has_tag = image.contains(':');
32            let is_latest = image.ends_with(":latest");
33            let is_digest = image.contains('@'); // sha256 digest
34
35            if !has_tag && !is_digest {
36                let line = service
37                    .image_pos
38                    .map(|p| p.line)
39                    .unwrap_or(service.position.line);
40
41                let message = format!(
42                    "Image \"{}\" in service \"{}\" does not have an explicit tag. Use a specific version tag for reproducible builds.",
43                    image, service_name
44                );
45
46                failures.push(
47                    make_failure(
48                        &CODE.into(),
49                        NAME,
50                        Severity::Warning,
51                        RuleCategory::BestPractice,
52                        message,
53                        line,
54                        1,
55                        false,
56                    )
57                    .with_data("serviceName", service_name.clone())
58                    .with_data("image", image.clone()),
59                );
60            } else if is_latest {
61                let line = service
62                    .image_pos
63                    .map(|p| p.line)
64                    .unwrap_or(service.position.line);
65
66                let message = format!(
67                    "Image \"{}\" in service \"{}\" uses the `latest` tag. Use a specific version tag for reproducible builds.",
68                    image, service_name
69                );
70
71                failures.push(
72                    make_failure(
73                        &CODE.into(),
74                        NAME,
75                        Severity::Warning,
76                        RuleCategory::BestPractice,
77                        message,
78                        line,
79                        1,
80                        false,
81                    )
82                    .with_data("serviceName", service_name.clone())
83                    .with_data("image", image.clone()),
84                );
85            }
86        }
87    }
88
89    failures
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::analyzer::dclint::parser::parse_compose;
96
97    fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
98        let compose = parse_compose(yaml).unwrap();
99        let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
100        check(&ctx)
101    }
102
103    #[test]
104    fn test_no_violation_explicit_tag() {
105        let yaml = r#"
106services:
107  web:
108    image: nginx:1.25
109  db:
110    image: postgres:15-alpine
111"#;
112        assert!(check_yaml(yaml).is_empty());
113    }
114
115    #[test]
116    fn test_no_violation_digest() {
117        let yaml = r#"
118services:
119  web:
120    image: nginx@sha256:abc123def456
121"#;
122        assert!(check_yaml(yaml).is_empty());
123    }
124
125    #[test]
126    fn test_violation_no_tag() {
127        let yaml = r#"
128services:
129  web:
130    image: nginx
131"#;
132        let failures = check_yaml(yaml);
133        assert_eq!(failures.len(), 1);
134        assert!(failures[0].message.contains("nginx"));
135        assert!(failures[0].message.contains("explicit tag"));
136    }
137
138    #[test]
139    fn test_violation_latest_tag() {
140        let yaml = r#"
141services:
142  web:
143    image: nginx:latest
144"#;
145        let failures = check_yaml(yaml);
146        assert_eq!(failures.len(), 1);
147        assert!(failures[0].message.contains("latest"));
148    }
149
150    #[test]
151    fn test_no_violation_no_image() {
152        let yaml = r#"
153services:
154  web:
155    build: .
156"#;
157        // Services with only build and no image are fine
158        assert!(check_yaml(yaml).is_empty());
159    }
160
161    #[test]
162    fn test_multiple_violations() {
163        let yaml = r#"
164services:
165  web:
166    image: nginx
167  db:
168    image: postgres:latest
169  cache:
170    image: redis:7
171"#;
172        let failures = check_yaml(yaml);
173        assert_eq!(failures.len(), 2); // nginx (no tag) and postgres:latest
174    }
175}