syncable_cli/analyzer/dclint/rules/
dcl011.rs1use 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 let has_tag = image.contains(':');
32 let is_latest = image.ends_with(":latest");
33 let is_digest = image.contains('@'); 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 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); }
175}