use crate::compose::order::resolve_order;
use crate::compose::types::{ComposeFile, PortMapping, VolumeMount, VolumeType};
use crate::error::{ComposeError, Result};
use crate::ports::parse_ports;
pub fn validate_config(file: &ComposeFile) -> Result<()> {
if file.services.is_empty() {
return Err(ComposeError::Unsupported(
"no services defined in compose file".to_string(),
));
}
for (name, svc) in &file.services {
validate_service_name(name)?;
if svc.image.is_none() && svc.build.is_none() {
return Err(ComposeError::NoImageOrBuild(name.clone()));
}
validate_ports(name, &svc.ports)?;
validate_network_refs(name, file, svc)?;
validate_volume_refs(name, file, svc)?;
}
resolve_order(file)?;
Ok(())
}
fn validate_service_name(name: &str) -> Result<()> {
let ok = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if ok {
Ok(())
} else {
Err(ComposeError::Unsupported(format!(
"service name {name:?} is invalid: use only ASCII letters, digits, '.', '_', '-'"
)))
}
}
fn validate_ports(service: &str, ports: &[PortMapping]) -> Result<()> {
for parsed in parse_ports(ports)? {
if parsed.container_port == 0 || parsed.host_port == Some(0) {
return Err(ComposeError::InvalidPort(format!(
"service '{service}' has a port of 0; ports must be in 1-65535"
)));
}
}
Ok(())
}
fn validate_network_refs(
service: &str,
file: &ComposeFile,
svc: &crate::compose::types::Service,
) -> Result<()> {
for net in svc.networks.names() {
if !file.networks.contains_key(&net) {
return Err(ComposeError::Unsupported(format!(
"service '{service}' refers to undefined network '{net}'; declare it under the \
top-level 'networks:' key"
)));
}
}
Ok(())
}
fn validate_volume_refs(
service: &str,
file: &ComposeFile,
svc: &crate::compose::types::Service,
) -> Result<()> {
for mount in &svc.volumes {
let named = match mount {
VolumeMount::Short(s) => short_named_volume(s),
VolumeMount::Long {
volume_type: VolumeType::Volume,
source: Some(src),
..
} => Some(src.as_str()),
VolumeMount::Long { .. } => None,
};
if let Some(name) = named {
if !file.volumes.contains_key(name) {
return Err(ComposeError::Unsupported(format!(
"service '{service}' refers to undefined volume '{name}'; declare it under the \
top-level 'volumes:' key"
)));
}
}
}
Ok(())
}
fn short_named_volume(spec: &str) -> Option<&str> {
let (src, _rest) = spec.split_once(':')?;
if src.is_empty()
|| src.starts_with('/')
|| src.starts_with('.')
|| src.starts_with('~')
|| is_windows_drive(src)
{
return None;
}
Some(src)
}
fn is_windows_drive(src: &str) -> bool {
let b = src.as_bytes();
b.len() == 1 && b[0].is_ascii_alphabetic()
}
pub(super) fn validate(file: &ComposeFile) -> Result<()> {
validate_services(file)?;
validate_networks(file)?;
Ok(())
}
fn validate_services(file: &ComposeFile) -> Result<()> {
for (name, service) in &file.services {
let attached = service.networks.names();
if service.network_mode.is_some() {
if !attached.is_empty() {
return Err(ComposeError::Unsupported(format!(
"service '{name}' sets both 'network_mode' and 'networks', which are \
mutually exclusive; keep one"
)));
}
} else {
for net in &attached {
if net != "default" && !file.networks.contains_key(net) {
return Err(ComposeError::Unsupported(format!(
"service '{name}' refers to undefined network '{net}'; declare it \
under the top-level 'networks:' or mark it external"
)));
}
}
}
parse_ports(&service.ports)?;
}
Ok(())
}
fn validate_networks(file: &ComposeFile) -> Result<()> {
for (name, cfg) in &file.networks {
let Some(cfg) = cfg else { continue };
if cfg.external != Some(true) {
continue;
}
let mut conflicts = Vec::new();
if cfg.driver.is_some() {
conflicts.push("driver");
}
if !cfg.driver_opts.is_empty() {
conflicts.push("driver_opts");
}
if cfg.internal.is_some() {
conflicts.push("internal");
}
if cfg.attachable.is_some() {
conflicts.push("attachable");
}
if cfg.enable_ipv6.is_some() {
conflicts.push("enable_ipv6");
}
if cfg.enable_ipv4.is_some() {
conflicts.push("enable_ipv4");
}
if cfg.ipam.is_some() {
conflicts.push("ipam");
}
if !conflicts.is_empty() {
return Err(ComposeError::Unsupported(format!(
"network '{name}' is external but also sets {}; an external network is \
used as-is and these attributes cannot be applied to it",
conflicts.join(", ")
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{parse_str, parse_str_raw};
fn file(yaml: &str) -> ComposeFile {
parse_str_raw(yaml).unwrap()
}
fn validate_str(yaml: &str) -> crate::error::Result<()> {
let mut file: ComposeFile = parse_str(yaml).unwrap();
crate::compose::normalize_default_network(&mut file);
super::validate(&file)
}
#[test]
fn empty_services_is_rejected() {
let err = validate_config(&file("services: {}\n")).unwrap_err();
assert!(format!("{err}").contains("no services"));
assert!(validate_config(&ComposeFile::default()).is_err());
}
#[test]
fn missing_image_and_build_is_rejected() {
let err = validate_config(&file("services:\n web:\n ports: ['80:80']\n")).unwrap_err();
assert!(matches!(err, ComposeError::NoImageOrBuild(_)));
}
#[test]
fn valid_minimal_file_passes() {
validate_config(&file("services:\n web:\n image: nginx\n")).unwrap();
}
#[test]
fn out_of_range_port_is_rejected() {
let err = validate_config(&file(
"services:\n web:\n image: nginx\n ports: ['99999:80']\n",
))
.unwrap_err();
assert!(matches!(err, ComposeError::InvalidPort(_)));
}
#[test]
fn zero_port_is_rejected() {
let err = validate_config(&file(
"services:\n web:\n image: nginx\n ports: ['0:80']\n",
))
.unwrap_err();
assert!(matches!(err, ComposeError::InvalidPort(_)));
}
#[test]
fn undefined_named_volume_is_rejected() {
let err = validate_config(&file(
"services:\n web:\n image: nginx\n volumes: ['data:/x']\n",
))
.unwrap_err();
assert!(format!("{err}").contains("undefined volume 'data'"));
}
#[test]
fn declared_named_volume_passes() {
validate_config(&file(
"services:\n web:\n image: nginx\n volumes: ['data:/x']\nvolumes:\n data:\n",
))
.unwrap();
}
#[test]
fn bind_and_anonymous_volumes_are_not_flagged() {
validate_config(&file(
"services:\n web:\n image: nginx\n volumes:\n - ./host:/x\n - /abs:/y\n - /data\n",
))
.unwrap();
}
#[test]
fn undefined_network_is_rejected() {
let err = validate_config(&file(
"services:\n web:\n image: nginx\n networks: [backend]\n",
))
.unwrap_err();
assert!(format!("{err}").contains("undefined network 'backend'"));
}
#[test]
fn declared_network_passes() {
validate_config(&file(
"services:\n web:\n image: nginx\n networks: [backend]\nnetworks:\n backend:\n",
))
.unwrap();
}
#[test]
fn invalid_service_name_is_rejected() {
let err =
validate_config(&file("services:\n 'bad name':\n image: nginx\n")).unwrap_err();
assert!(format!("{err}").contains("service name"));
}
#[test]
fn dependency_cycle_is_rejected() {
let err = validate_config(&file(
"services:\n a:\n image: x\n depends_on: [b]\n b:\n image: y\n depends_on: [a]\n",
))
.unwrap_err();
assert!(matches!(err, ComposeError::CircularDependency(_)));
}
#[test]
fn dangling_required_dependency_is_rejected() {
let err = validate_config(&file(
"services:\n web:\n image: nginx\n depends_on: [ghost]\n",
))
.unwrap_err();
assert!(matches!(err, ComposeError::ServiceNotFound(_)));
}
#[test]
fn network_mode_with_networks_is_rejected() {
let yaml = "services:\n web:\n image: x\n network_mode: host\n networks: [front]\nnetworks:\n front:\n";
let err = validate_str(yaml).unwrap_err();
assert!(err.to_string().contains("mutually exclusive"), "got: {err}");
}
#[test]
fn network_mode_alone_is_accepted() {
let yaml = "services:\n web:\n image: x\n network_mode: host\n";
assert!(validate_str(yaml).is_ok());
}
#[test]
fn undefined_network_reference_is_rejected() {
let yaml = "services:\n web:\n image: x\n networks: [missing]\n";
let err = validate_str(yaml).unwrap_err();
assert!(
err.to_string().contains("undefined network 'missing'"),
"got: {err}"
);
}
#[test]
fn declared_network_reference_is_accepted() {
let yaml = "services:\n web:\n image: x\n networks: [front]\nnetworks:\n front:\n";
assert!(validate_str(yaml).is_ok());
}
#[test]
fn bare_service_default_network_is_accepted() {
let yaml = "services:\n web:\n image: x\n";
assert!(validate_str(yaml).is_ok());
}
#[test]
fn explicit_default_network_reference_is_accepted() {
let yaml = "services:\n web:\n image: x\n networks: [default]\n";
assert!(validate_str(yaml).is_ok());
}
#[test]
fn external_network_with_internal_is_rejected() {
let yaml = "services:\n web:\n image: x\n networks: [ext]\nnetworks:\n ext:\n external: true\n internal: true\n";
let err = validate_str(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("external"), "got: {msg}");
assert!(msg.contains("internal"), "got: {msg}");
}
#[test]
fn external_network_with_ipam_is_rejected() {
let yaml = "services:\n web:\n image: x\n networks: [ext]\nnetworks:\n ext:\n external: true\n ipam:\n config:\n - subnet: 10.0.0.0/24\n";
let err = validate_str(yaml).unwrap_err();
assert!(err.to_string().contains("ipam"), "got: {err}");
}
#[test]
fn plain_external_network_is_accepted() {
let yaml = "services:\n web:\n image: x\n networks: [ext]\nnetworks:\n ext:\n external: true\n";
assert!(validate_str(yaml).is_ok());
}
#[test]
fn external_network_with_name_only_is_accepted() {
let yaml = "services:\n web:\n image: x\n networks: [ext]\nnetworks:\n ext:\n external: true\n name: shared_net\n";
assert!(validate_str(yaml).is_ok());
}
#[test]
fn out_of_range_short_port_is_rejected() {
let yaml = "services:\n web:\n image: x\n ports: ['99999:80']\n";
assert!(validate_str(yaml).is_err());
}
#[test]
fn invalid_port_protocol_is_rejected() {
let yaml = "services:\n web:\n image: x\n ports: ['80/banana']\n";
assert!(validate_str(yaml).is_err());
}
}