syncable_cli/analyzer/dclint/rules/
dcl003.rs1use std::collections::HashMap;
6
7use crate::analyzer::dclint::rules::{LintContext, Rule, SimpleRule, make_failure};
8use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
9
10const CODE: &str = "DCL003";
11const NAME: &str = "no-duplicate-exported-ports";
12const DESCRIPTION: &str = "Exported host ports must be unique across all services.";
13const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/no-duplicate-exported-ports-rule.md";
14
15pub fn rule() -> impl Rule {
16 SimpleRule::new(
17 CODE,
18 NAME,
19 Severity::Error,
20 RuleCategory::BestPractice,
21 DESCRIPTION,
22 URL,
23 check,
24 )
25}
26
27fn check(ctx: &LintContext) -> Vec<CheckFailure> {
28 let mut failures = Vec::new();
29 let mut exported_ports: HashMap<String, Vec<(String, String, u32)>> = HashMap::new();
31
32 for (service_name, service) in &ctx.compose.services {
33 for port in &service.ports {
34 if let Some(host_port) = port.host_port {
36 let key = if let Some(ip) = &port.host_ip {
37 format!("{}:{}", ip, host_port)
38 } else {
39 host_port.to_string()
41 };
42
43 exported_ports.entry(key).or_default().push((
44 service_name.clone(),
45 port.raw.clone(),
46 port.position.line,
47 ));
48 }
49 }
50 }
51
52 for (exported_port, usages) in exported_ports {
54 if usages.len() > 1 {
55 for (service_name, port_raw, line) in &usages {
56 let other_services: Vec<&str> = usages
57 .iter()
58 .filter(|(name, _, _)| name != service_name)
59 .map(|(name, _, _)| name.as_str())
60 .collect();
61
62 let message = format!(
63 "Port \"{}\" is exported by multiple services: \"{}\" and \"{}\".",
64 exported_port,
65 service_name,
66 other_services.join("\", \"")
67 );
68
69 failures.push(
70 make_failure(
71 &CODE.into(),
72 NAME,
73 Severity::Error,
74 RuleCategory::BestPractice,
75 message,
76 *line,
77 1,
78 false,
79 )
80 .with_data("exportedPort", exported_port.clone())
81 .with_data("serviceName", service_name.clone())
82 .with_data("portMapping", port_raw.clone()),
83 );
84 }
85 }
86 }
87
88 failures
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::analyzer::dclint::parser::parse_compose;
95
96 fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
97 let compose = parse_compose(yaml).unwrap();
98 let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
99 check(&ctx)
100 }
101
102 #[test]
103 fn test_no_violation_unique_ports() {
104 let yaml = r#"
105services:
106 web:
107 image: nginx
108 ports:
109 - "8080:80"
110 api:
111 image: node
112 ports:
113 - "3000:3000"
114"#;
115 assert!(check_yaml(yaml).is_empty());
116 }
117
118 #[test]
119 fn test_no_violation_same_container_port_different_host() {
120 let yaml = r#"
121services:
122 web:
123 image: nginx
124 ports:
125 - "8080:80"
126 api:
127 image: nginx
128 ports:
129 - "8081:80"
130"#;
131 assert!(check_yaml(yaml).is_empty());
132 }
133
134 #[test]
135 fn test_no_violation_container_only_ports() {
136 let yaml = r#"
137services:
138 web:
139 image: nginx
140 ports:
141 - 80
142 api:
143 image: node
144 ports:
145 - 80
146"#;
147 assert!(check_yaml(yaml).is_empty());
149 }
150
151 #[test]
152 fn test_violation_duplicate_host_ports() {
153 let yaml = r#"
154services:
155 web:
156 image: nginx
157 ports:
158 - "8080:80"
159 api:
160 image: node
161 ports:
162 - "8080:3000"
163"#;
164 let failures = check_yaml(yaml);
165 assert_eq!(failures.len(), 2); assert!(failures[0].message.contains("8080"));
167 }
168
169 #[test]
170 fn test_no_violation_different_interfaces() {
171 let yaml = r#"
172services:
173 web:
174 image: nginx
175 ports:
176 - "127.0.0.1:8080:80"
177 api:
178 image: node
179 ports:
180 - "192.168.1.1:8080:3000"
181"#;
182 assert!(check_yaml(yaml).is_empty());
184 }
185
186 #[test]
187 fn test_violation_same_interface_same_port() {
188 let yaml = r#"
189services:
190 web:
191 image: nginx
192 ports:
193 - "127.0.0.1:8080:80"
194 api:
195 image: node
196 ports:
197 - "127.0.0.1:8080:3000"
198"#;
199 let failures = check_yaml(yaml);
200 assert_eq!(failures.len(), 2);
201 assert!(failures[0].message.contains("127.0.0.1:8080"));
202 }
203
204 #[test]
205 fn test_multiple_duplicates() {
206 let yaml = r#"
207services:
208 web1:
209 image: nginx
210 ports:
211 - "8080:80"
212 web2:
213 image: nginx
214 ports:
215 - "8080:80"
216 web3:
217 image: nginx
218 ports:
219 - "8080:80"
220"#;
221 let failures = check_yaml(yaml);
222 assert_eq!(failures.len(), 3); }
224}