syncable_cli/analyzer/dclint/rules/
dcl003.rs

1//! DCL003: no-duplicate-exported-ports
2//!
3//! Exported host ports must be unique across all services.
4
5use 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    // Map from exported port to list of (service_name, port_raw, line)
30    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            // Only check ports with a host port binding
35            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                    // Unbound ports conflict with any other unbound port on same port number
40                    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    // Report duplicates
53    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        // Container-only ports (no host binding) are not exported
148        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); // One per service
166        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        // Different interfaces are technically different bindings
183        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); // One per service
223    }
224}