1use std::collections::HashMap;
4
5use a3s_acl::{Block, Document, Lexer, Token, Value};
6use thiserror::Error;
7
8use super::diagnostic::{child_path, pointer_segment, ComposeDiagnostic};
9use super::interpolation::interpolate_compose_scalar;
10use super::schema::{
11 DEPENDS_ON_FIELDS, HEALTHCHECK_FIELDS, NETWORK_FIELDS, SERVICE_FIELDS, SERVICE_NETWORK_FIELDS,
12 VOLUME_FIELDS,
13};
14use super::{
15 ComposeConfig, ComposeDiagnosticCode, DependsOn, DependsOnCondition, DnsConfig, EnvVars,
16 HealthcheckConfig, Labels, NetworkDeclaration, ServiceConfig, ServiceNetworkConfig,
17 ServiceNetworks, StringOrList, VolumeDeclaration,
18};
19
20#[derive(Debug, Clone, PartialEq, Eq, Error)]
22#[error("{diagnostic}")]
23pub struct ComposeAclError {
24 diagnostic: ComposeDiagnostic,
25}
26
27impl ComposeAclError {
28 fn invalid(message: impl Into<String>) -> Self {
29 Self {
30 diagnostic: ComposeDiagnostic::new(ComposeDiagnosticCode::InvalidValue, "/", message),
31 }
32 }
33
34 fn syntax(message: impl Into<String>) -> Self {
35 Self {
36 diagnostic: ComposeDiagnostic::new(ComposeDiagnosticCode::Syntax, "/", message),
37 }
38 }
39
40 fn interpolation(message: impl Into<String>) -> Self {
41 Self {
42 diagnostic: ComposeDiagnostic::new(ComposeDiagnosticCode::Interpolation, "/", message),
43 }
44 }
45
46 fn unsupported_field(path: impl Into<String>, field: &str) -> Self {
47 Self {
48 diagnostic: ComposeDiagnostic::unsupported_field(path, field),
49 }
50 }
51
52 fn unsupported_value(path: impl Into<String>, message: impl Into<String>) -> Self {
53 Self {
54 diagnostic: ComposeDiagnostic::new(
55 ComposeDiagnosticCode::UnsupportedValue,
56 path,
57 message,
58 ),
59 }
60 }
61
62 pub fn diagnostic(&self) -> &ComposeDiagnostic {
64 &self.diagnostic
65 }
66}
67
68pub(super) fn parse_compose_acl(
69 source: &str,
70 environment: &HashMap<String, String>,
71) -> Result<ComposeConfig, ComposeAclError> {
72 validate_balanced_braces(source)?;
73 let mut document = a3s_acl::parse(source)
74 .map_err(|error| ComposeAclError::syntax(format!("invalid A3S ACL: {error}")))?;
75 interpolate_document_values(&mut document, environment)?;
76 resolve_environment_calls(&mut document, environment)?;
77 convert_document(document)
78}
79
80fn validate_balanced_braces(source: &str) -> Result<(), ComposeAclError> {
81 let mut depth = 0usize;
82 for token in Lexer::new(source).tokenize() {
83 match token.token {
84 Token::LeftBrace => depth += 1,
85 Token::RightBrace if depth == 0 => {
86 return Err(ComposeAclError::syntax(
87 "compose ACL contains an unmatched closing brace",
88 ));
89 }
90 Token::RightBrace => depth -= 1,
91 _ => {}
92 }
93 }
94 if depth != 0 {
95 return Err(ComposeAclError::syntax(
96 "compose ACL contains an unclosed block or object",
97 ));
98 }
99 Ok(())
100}
101
102fn interpolate_document_values(
103 document: &mut Document,
104 environment: &HashMap<String, String>,
105) -> Result<(), ComposeAclError> {
106 for block in &mut document.blocks {
107 interpolate_block_values(block, environment)?;
108 }
109 Ok(())
110}
111
112fn interpolate_block_values(
113 block: &mut Block,
114 environment: &HashMap<String, String>,
115) -> Result<(), ComposeAclError> {
116 for (name, value) in &mut block.attributes {
117 if block.name == "service" && name == "secret_environment" {
120 continue;
121 }
122 interpolate_value(value, environment)?;
123 }
124 for nested in &mut block.blocks {
125 interpolate_block_values(nested, environment)?;
126 }
127 Ok(())
128}
129
130fn interpolate_value(
131 value: &mut Value,
132 environment: &HashMap<String, String>,
133) -> Result<(), ComposeAclError> {
134 match value {
135 Value::String(text) => {
136 *text = interpolate_compose_scalar(text, environment).map_err(|error| {
137 ComposeAclError::interpolation(format!("invalid Compose interpolation: {error}"))
138 })?;
139 }
140 Value::List(values) | Value::Call(_, values) => {
141 for value in values {
142 interpolate_value(value, environment)?;
143 }
144 }
145 Value::Object(entries) => {
146 for (_, value) in entries {
147 interpolate_value(value, environment)?;
148 }
149 }
150 Value::Number(_) | Value::Bool(_) | Value::Null => {}
151 }
152 Ok(())
153}
154
155fn resolve_environment_calls(
156 document: &mut Document,
157 environment: &HashMap<String, String>,
158) -> Result<(), ComposeAclError> {
159 for block in &mut document.blocks {
160 resolve_block_environment(block, environment)?;
161 }
162 Ok(())
163}
164
165fn resolve_block_environment(
166 block: &mut Block,
167 environment: &HashMap<String, String>,
168) -> Result<(), ComposeAclError> {
169 for (name, value) in &mut block.attributes {
170 if block.name == "service" && name == "secret_environment" {
173 continue;
174 }
175 resolve_value_environment(value, environment)?;
176 }
177 for nested in &mut block.blocks {
178 resolve_block_environment(nested, environment)?;
179 }
180 Ok(())
181}
182
183fn resolve_value_environment(
184 value: &mut Value,
185 environment: &HashMap<String, String>,
186) -> Result<(), ComposeAclError> {
187 match value {
188 Value::Call(name, arguments) => {
189 if name != "env" {
190 return Err(ComposeAclError::unsupported_value(
191 "/",
192 format!("unsupported ACL function {name:?}; only env(\"NAME\") is supported"),
193 ));
194 }
195 let [Value::String(variable)] = arguments.as_slice() else {
196 return Err(ComposeAclError::invalid(
197 "env() must receive exactly one string environment variable name",
198 ));
199 };
200 let resolved = environment.get(variable).cloned().ok_or_else(|| {
201 ComposeAclError::invalid(format!(
202 "environment variable {variable:?} referenced by env() is not set"
203 ))
204 })?;
205 *value = Value::String(resolved);
206 }
207 Value::List(values) => {
208 for value in values {
209 resolve_value_environment(value, environment)?;
210 }
211 }
212 Value::Object(entries) => {
213 for (_, value) in entries {
214 resolve_value_environment(value, environment)?;
215 }
216 }
217 Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => {}
218 }
219 Ok(())
220}
221
222fn convert_document(document: Document) -> Result<ComposeConfig, ComposeAclError> {
223 let mut services = HashMap::new();
224 let mut volumes = HashMap::new();
225 let mut networks = HashMap::new();
226
227 for block in document.blocks {
228 match block.name.as_str() {
229 "service" => {
230 let name = named_block_label(&block, "service")?;
231 validate_compose_name("service", &name)?;
232 let config = parse_service(&block, &name)?;
233 if services.insert(name.clone(), config).is_some() {
234 return Err(ComposeAclError::invalid(format!(
235 "duplicate service block {name:?}"
236 )));
237 }
238 }
239 "volume" => {
240 let name = named_block_label(&block, "volume")?;
241 validate_compose_name("volume", &name)?;
242 let path = format!("/volumes/{}", pointer_segment(&name));
243 validate_plain_block(&block, VOLUME_FIELDS, &path)?;
244 let declaration = VolumeDeclaration {
245 driver: optional_string(&block, "driver", &path)?,
246 };
247 if volumes.insert(name.clone(), Some(declaration)).is_some() {
248 return Err(ComposeAclError::invalid(format!(
249 "duplicate volume block {name:?}"
250 )));
251 }
252 }
253 "network" => {
254 let name = named_block_label(&block, "network")?;
255 validate_compose_name("network", &name)?;
256 let path = format!("/networks/{}", pointer_segment(&name));
257 validate_plain_block(&block, NETWORK_FIELDS, &path)?;
258 let declaration = NetworkDeclaration {
259 driver: optional_string(&block, "driver", &path)?,
260 };
261 if networks.insert(name.clone(), Some(declaration)).is_some() {
262 return Err(ComposeAclError::invalid(format!(
263 "duplicate network block {name:?}"
264 )));
265 }
266 }
267 name => {
268 return Err(ComposeAclError::unsupported_field(
269 format!("/{}", pointer_segment(name)),
270 name,
271 ));
272 }
273 }
274 }
275
276 if services.is_empty() {
277 return Err(ComposeAclError::invalid(
278 "compose.acl must contain at least one service block",
279 ));
280 }
281
282 Ok(ComposeConfig {
283 version: None,
284 services,
285 volumes,
286 networks,
287 })
288}
289
290fn parse_service(block: &Block, name: &str) -> Result<ServiceConfig, ComposeAclError> {
291 let path = format!("/services/{}", pointer_segment(name));
292 validate_attributes(block, SERVICE_FIELDS, &path)?;
293 let healthcheck = parse_service_healthcheck(block, &path)?;
294
295 Ok(ServiceConfig {
296 image: optional_string(block, "image", &path)?,
297 entrypoint: optional_string_or_list(block, "entrypoint", &path)?,
298 command: optional_string_or_list(block, "command", &path)?,
299 environment: optional_env_vars(block, "environment", &path)?,
300 env_file: optional_string_or_list(block, "env_file", &path)?.unwrap_or_default(),
301 secret_environment: optional_string_map(block, "secret_environment", &path)?,
302 ports: optional_string_list(block, "ports", &path)?.unwrap_or_default(),
303 volumes: optional_string_list(block, "volumes", &path)?.unwrap_or_default(),
304 depends_on: optional_depends_on(block, "depends_on", &path)?,
305 networks: optional_service_networks(block, "networks", &path)?,
306 cpus: optional_integer(block, "cpus", &path)?,
307 mem_limit: optional_string(block, "mem_limit", &path)?,
308 restart: optional_string(block, "restart", &path)?,
309 dns: optional_dns(block, "dns", &path)?,
310 tmpfs: optional_string_or_list(block, "tmpfs", &path)?.unwrap_or_default(),
311 cap_add: optional_string_list(block, "cap_add", &path)?.unwrap_or_default(),
312 cap_drop: optional_string_list(block, "cap_drop", &path)?.unwrap_or_default(),
313 privileged: optional_bool(block, "privileged", &path)?.unwrap_or(false),
314 labels: optional_labels(block, "labels", &path)?,
315 healthcheck,
316 working_dir: optional_string(block, "working_dir", &path)?,
317 hostname: optional_string(block, "hostname", &path)?,
318 extra_hosts: optional_string_or_list(block, "extra_hosts", &path)?.unwrap_or_default(),
319 })
320}
321
322fn parse_service_healthcheck(
323 service: &Block,
324 service_path: &str,
325) -> Result<Option<HealthcheckConfig>, ComposeAclError> {
326 let mut nested_healthcheck = None;
327 for nested in &service.blocks {
328 if nested.name != "healthcheck" {
329 return Err(ComposeAclError::unsupported_field(
330 child_path(service_path, &nested.name),
331 &nested.name,
332 ));
333 }
334 if nested_healthcheck.replace(nested).is_some() {
335 return Err(ComposeAclError::invalid(format!(
336 "{service_path} contains more than one healthcheck block"
337 )));
338 }
339 }
340
341 let attribute_healthcheck = service.attributes.get("healthcheck");
342 if attribute_healthcheck.is_some() && nested_healthcheck.is_some() {
343 return Err(ComposeAclError::invalid(format!(
344 "{service_path} declares healthcheck both as an attribute and a block"
345 )));
346 }
347
348 if let Some(value) = attribute_healthcheck {
349 return parse_healthcheck(value, service_path).map(Some);
350 }
351 if let Some(block) = nested_healthcheck {
352 return parse_healthcheck_block(block, service_path).map(Some);
353 }
354 Ok(None)
355}
356
357fn parse_healthcheck(
358 value: &Value,
359 service_path: &str,
360) -> Result<HealthcheckConfig, ComposeAclError> {
361 let path = child_path(service_path, "healthcheck");
362 let Value::Object(entries) = value else {
363 return Err(ComposeAclError::invalid(format!(
364 "{path} must be an object or a healthcheck block"
365 )));
366 };
367 let fields = object_fields(entries, HEALTHCHECK_FIELDS, &path)?;
368 parse_healthcheck_fields(&fields, &path)
369}
370
371fn parse_healthcheck_block(
372 block: &Block,
373 service_path: &str,
374) -> Result<HealthcheckConfig, ComposeAclError> {
375 let path = child_path(service_path, "healthcheck");
376 if !block.labels.is_empty() {
377 return Err(ComposeAclError::invalid(format!(
378 "{path} block cannot have labels"
379 )));
380 }
381 validate_plain_block(block, HEALTHCHECK_FIELDS, &path)?;
382 let fields = block
383 .attributes
384 .iter()
385 .map(|(name, value)| (name.as_str(), value))
386 .collect::<HashMap<_, _>>();
387 parse_healthcheck_fields(&fields, &path)
388}
389
390fn parse_healthcheck_fields(
391 fields: &HashMap<&str, &Value>,
392 path: &str,
393) -> Result<HealthcheckConfig, ComposeAclError> {
394 Ok(HealthcheckConfig {
395 test: fields
396 .get("test")
397 .map(|value| string_or_list_value(value, &format!("{path}.test")))
398 .transpose()?
399 .unwrap_or_default(),
400 disable: fields
401 .get("disable")
402 .map(|value| bool_value(value, &format!("{path}.disable")))
403 .transpose()?
404 .unwrap_or(false),
405 interval: fields
406 .get("interval")
407 .map(|value| string_value(value, &format!("{path}.interval")))
408 .transpose()?,
409 timeout: fields
410 .get("timeout")
411 .map(|value| string_value(value, &format!("{path}.timeout")))
412 .transpose()?,
413 retries: fields
414 .get("retries")
415 .map(|value| integer_value(value, &format!("{path}.retries")))
416 .transpose()?,
417 start_period: fields
418 .get("start_period")
419 .map(|value| string_value(value, &format!("{path}.start_period")))
420 .transpose()?,
421 })
422}
423
424fn named_block_label(block: &Block, kind: &str) -> Result<String, ComposeAclError> {
425 let [name] = block.labels.as_slice() else {
426 return Err(ComposeAclError::invalid(format!(
427 "{kind} blocks require exactly one string label"
428 )));
429 };
430 Ok(name.clone())
431}
432
433fn validate_compose_name(kind: &str, name: &str) -> Result<(), ComposeAclError> {
434 let mut bytes = name.bytes();
435 let valid = bytes
436 .next()
437 .is_some_and(|byte| byte.is_ascii_alphanumeric())
438 && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
439 if !valid {
440 return Err(ComposeAclError::invalid(format!(
441 "{kind} name {name:?} must start with an ASCII letter or digit and contain only letters, digits, '.', '_', or '-'"
442 )));
443 }
444 Ok(())
445}
446
447fn validate_plain_block(
448 block: &Block,
449 attributes: &[&str],
450 path: &str,
451) -> Result<(), ComposeAclError> {
452 if !block.blocks.is_empty() {
453 return Err(ComposeAclError::invalid(format!(
454 "{path} cannot contain nested blocks"
455 )));
456 }
457 validate_attributes(block, attributes, path)
458}
459
460fn validate_attributes(block: &Block, allowed: &[&str], path: &str) -> Result<(), ComposeAclError> {
461 let mut unknown = block
462 .attributes
463 .keys()
464 .filter(|field| !allowed.contains(&field.as_str()))
465 .cloned()
466 .collect::<Vec<_>>();
467 unknown.sort();
468 if !unknown.is_empty() {
469 let first = &unknown[0];
470 return Err(ComposeAclError {
471 diagnostic: ComposeDiagnostic::new(
472 ComposeDiagnosticCode::UnsupportedField,
473 child_path(path, first),
474 format!("unsupported Compose field(s): {}", unknown.join(", ")),
475 ),
476 });
477 }
478 Ok(())
479}
480
481fn optional_string(
482 block: &Block,
483 field: &str,
484 path: &str,
485) -> Result<Option<String>, ComposeAclError> {
486 block
487 .attributes
488 .get(field)
489 .map(|value| string_value(value, &format!("{path}.{field}")))
490 .transpose()
491}
492
493fn string_value(value: &Value, path: &str) -> Result<String, ComposeAclError> {
494 value
495 .as_str()
496 .map(str::to_owned)
497 .ok_or_else(|| ComposeAclError::invalid(format!("{path} must be a string")))
498}
499
500fn optional_string_list(
501 block: &Block,
502 field: &str,
503 path: &str,
504) -> Result<Option<Vec<String>>, ComposeAclError> {
505 block
506 .attributes
507 .get(field)
508 .map(|value| string_list_value(value, &format!("{path}.{field}")))
509 .transpose()
510}
511
512fn string_list_value(value: &Value, path: &str) -> Result<Vec<String>, ComposeAclError> {
513 let Value::List(values) = value else {
514 return Err(ComposeAclError::invalid(format!(
515 "{path} must be a list of strings"
516 )));
517 };
518 values
519 .iter()
520 .map(|value| string_value(value, path))
521 .collect()
522}
523
524fn optional_string_or_list(
525 block: &Block,
526 field: &str,
527 path: &str,
528) -> Result<Option<StringOrList>, ComposeAclError> {
529 block
530 .attributes
531 .get(field)
532 .map(|value| string_or_list_value(value, &format!("{path}.{field}")))
533 .transpose()
534}
535
536fn string_or_list_value(value: &Value, path: &str) -> Result<StringOrList, ComposeAclError> {
537 match value {
538 Value::String(value) => Ok(StringOrList::Single(value.clone())),
539 Value::List(_) => string_list_value(value, path).map(StringOrList::List),
540 _ => Err(ComposeAclError::invalid(format!(
541 "{path} must be a string or a list of strings"
542 ))),
543 }
544}
545
546fn optional_integer<T>(block: &Block, field: &str, path: &str) -> Result<Option<T>, ComposeAclError>
547where
548 T: TryFrom<u64>,
549{
550 block
551 .attributes
552 .get(field)
553 .map(|value| integer_value(value, &format!("{path}.{field}")))
554 .transpose()
555}
556
557fn integer_value<T>(value: &Value, path: &str) -> Result<T, ComposeAclError>
558where
559 T: TryFrom<u64>,
560{
561 let Value::Number(number) = value else {
562 return Err(ComposeAclError::invalid(format!(
563 "{path} must be a nonnegative integer"
564 )));
565 };
566 if !number.is_finite() || *number < 0.0 || number.fract() != 0.0 || *number > u64::MAX as f64 {
567 return Err(ComposeAclError::invalid(format!(
568 "{path} must be a nonnegative integer"
569 )));
570 }
571 T::try_from(*number as u64)
572 .map_err(|_| ComposeAclError::invalid(format!("{path} is out of range")))
573}
574
575fn optional_bool(block: &Block, field: &str, path: &str) -> Result<Option<bool>, ComposeAclError> {
576 block
577 .attributes
578 .get(field)
579 .map(|value| bool_value(value, &format!("{path}.{field}")))
580 .transpose()
581}
582
583fn bool_value(value: &Value, path: &str) -> Result<bool, ComposeAclError> {
584 value
585 .as_bool()
586 .ok_or_else(|| ComposeAclError::invalid(format!("{path} must be a boolean")))
587}
588
589fn optional_env_vars(block: &Block, field: &str, path: &str) -> Result<EnvVars, ComposeAclError> {
590 let Some(value) = block.attributes.get(field) else {
591 return Ok(EnvVars::Empty);
592 };
593 match value {
594 Value::List(_) => string_list_value(value, &format!("{path}.{field}")).map(EnvVars::List),
595 Value::Object(entries) => {
596 string_map_value(entries, &format!("{path}.{field}")).map(EnvVars::Map)
597 }
598 _ => Err(ComposeAclError::invalid(format!(
599 "{path}.{field} must be an object or a list of KEY=value strings"
600 ))),
601 }
602}
603
604fn optional_labels(block: &Block, field: &str, path: &str) -> Result<Labels, ComposeAclError> {
605 let Some(value) = block.attributes.get(field) else {
606 return Ok(Labels::Empty);
607 };
608 match value {
609 Value::List(_) => string_list_value(value, &format!("{path}.{field}")).map(Labels::List),
610 Value::Object(entries) => {
611 string_map_value(entries, &format!("{path}.{field}")).map(Labels::Map)
612 }
613 _ => Err(ComposeAclError::invalid(format!(
614 "{path}.{field} must be an object or a list of label strings"
615 ))),
616 }
617}
618
619fn optional_string_map(
620 block: &Block,
621 field: &str,
622 path: &str,
623) -> Result<HashMap<String, String>, ComposeAclError> {
624 let Some(value) = block.attributes.get(field) else {
625 return Ok(HashMap::new());
626 };
627 let Value::Object(entries) = value else {
628 return Err(ComposeAclError::invalid(format!(
629 "{path}.{field} must be an object of string references"
630 )));
631 };
632 string_map_value(entries, &format!("{path}.{field}"))
633}
634
635fn string_map_value(
636 entries: &[(String, Value)],
637 path: &str,
638) -> Result<HashMap<String, String>, ComposeAclError> {
639 let mut output = HashMap::new();
640 for (key, value) in entries {
641 let value = string_value(value, &format!("{path}.{key}"))?;
642 if output.insert(key.clone(), value).is_some() {
643 return Err(ComposeAclError::invalid(format!(
644 "{path} contains duplicate key {key:?}"
645 )));
646 }
647 }
648 Ok(output)
649}
650
651fn optional_dns(block: &Block, field: &str, path: &str) -> Result<DnsConfig, ComposeAclError> {
652 let Some(value) = block.attributes.get(field) else {
653 return Ok(DnsConfig::Empty);
654 };
655 match value {
656 Value::String(value) => Ok(DnsConfig::Single(value.clone())),
657 Value::List(_) => string_list_value(value, &format!("{path}.{field}")).map(DnsConfig::List),
658 _ => Err(ComposeAclError::invalid(format!(
659 "{path}.{field} must be a string or a list of strings"
660 ))),
661 }
662}
663
664fn optional_depends_on(
665 block: &Block,
666 field: &str,
667 path: &str,
668) -> Result<DependsOn, ComposeAclError> {
669 let Some(value) = block.attributes.get(field) else {
670 return Ok(DependsOn::Empty);
671 };
672 let field_path = child_path(path, field);
673 match value {
674 Value::List(_) => string_list_value(value, &field_path).map(DependsOn::List),
675 Value::Object(entries) => {
676 let mut dependencies = HashMap::new();
677 for (name, value) in entries {
678 validate_compose_name("dependency service", name)?;
679 let condition = match value {
680 Value::Null => "service_started".to_string(),
681 Value::Object(fields) => {
682 let dependency_path = child_path(&field_path, name);
683 let fields = object_fields(fields, DEPENDS_ON_FIELDS, &dependency_path)?;
684 fields
685 .get("condition")
686 .map(|value| {
687 string_value(value, &child_path(&dependency_path, "condition"))
688 })
689 .transpose()?
690 .unwrap_or_else(|| "service_started".to_string())
691 }
692 _ => {
693 return Err(ComposeAclError::invalid(format!(
694 "{} must be an object or null",
695 child_path(&field_path, name)
696 )));
697 }
698 };
699 if !matches!(
700 condition.as_str(),
701 "service_started" | "service_healthy" | "service_completed_successfully"
702 ) {
703 return Err(ComposeAclError::unsupported_value(
704 child_path(&child_path(&field_path, name), "condition"),
705 format!("unsupported depends_on condition {condition:?}"),
706 ));
707 }
708 if dependencies
709 .insert(name.clone(), DependsOnCondition { condition })
710 .is_some()
711 {
712 return Err(ComposeAclError::invalid(format!(
713 "{field_path} contains duplicate service {name:?}"
714 )));
715 }
716 }
717 Ok(DependsOn::Map(dependencies))
718 }
719 _ => Err(ComposeAclError::invalid(format!(
720 "{field_path} must be a list of service names or an object"
721 ))),
722 }
723}
724
725fn optional_service_networks(
726 block: &Block,
727 field: &str,
728 path: &str,
729) -> Result<ServiceNetworks, ComposeAclError> {
730 let Some(value) = block.attributes.get(field) else {
731 return Ok(ServiceNetworks::Empty);
732 };
733 let field_path = child_path(path, field);
734 match value {
735 Value::List(_) => string_list_value(value, &field_path).map(ServiceNetworks::List),
736 Value::Object(entries) => {
737 let mut networks = HashMap::new();
738 for (name, value) in entries {
739 validate_compose_name("network", name)?;
740 let config = match value {
741 Value::Null => None,
742 Value::Object(fields) => {
743 let network_path = child_path(&field_path, name);
744 let fields = object_fields(fields, SERVICE_NETWORK_FIELDS, &network_path)?;
745 let aliases = fields
746 .get("aliases")
747 .map(|value| {
748 string_list_value(value, &child_path(&network_path, "aliases"))
749 })
750 .transpose()?
751 .unwrap_or_default();
752 Some(ServiceNetworkConfig { aliases })
753 }
754 _ => {
755 return Err(ComposeAclError::invalid(format!(
756 "{} must be an object or null",
757 child_path(&field_path, name)
758 )));
759 }
760 };
761 if networks.insert(name.clone(), config).is_some() {
762 return Err(ComposeAclError::invalid(format!(
763 "{field_path} contains duplicate network {name:?}"
764 )));
765 }
766 }
767 Ok(ServiceNetworks::Map(networks))
768 }
769 _ => Err(ComposeAclError::invalid(format!(
770 "{field_path} must be a list of network names or an object"
771 ))),
772 }
773}
774
775fn object_fields<'a>(
776 entries: &'a [(String, Value)],
777 allowed: &[&str],
778 path: &str,
779) -> Result<HashMap<&'a str, &'a Value>, ComposeAclError> {
780 let mut output = HashMap::new();
781 for (key, value) in entries {
782 if !allowed.contains(&key.as_str()) {
783 return Err(ComposeAclError::unsupported_field(
784 child_path(path, key),
785 key,
786 ));
787 }
788 if output.insert(key.as_str(), value).is_some() {
789 return Err(ComposeAclError::invalid(format!(
790 "{path} contains duplicate attribute {key:?}"
791 )));
792 }
793 }
794 Ok(output)
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 const COMPLETE: &str = r#"
802service "api" {
803 image = "ghcr.io/a3s/api:latest"
804 entrypoint = ["/bin/api"]
805 command = ["serve", "--port", "8080"]
806 environment = {
807 PORT = "8080"
808 TOKEN = env("API_TOKEN")
809 }
810 env_file = ["base.env", "local.env"]
811 ports = ["8080:8080"]
812 volumes = ["data:/data"]
813 depends_on = {
814 db = { condition = "service_healthy" }
815 }
816 networks = {
817 backend = { aliases = ["service-api"] }
818 }
819 cpus = 2
820 mem_limit = "1g"
821 restart = "unless-stopped"
822 dns = ["1.1.1.1"]
823 tmpfs = "/tmp"
824 cap_add = ["NET_ADMIN"]
825 cap_drop = ["SYS_ADMIN"]
826 privileged = false
827 labels = { tier = "api" }
828 working_dir = "/app"
829 hostname = "api"
830 extra_hosts = ["host.internal:10.0.0.1"]
831
832 healthcheck {
833 test = ["CMD", "curl", "-f", "http://localhost:8080/health"]
834 interval = "10s"
835 timeout = "3s"
836 retries = 3
837 start_period = "5s"
838 }
839}
840
841service "db" {
842 image = "postgres:17"
843}
844
845volume "data" {
846 driver = "local"
847}
848
849network "backend" {
850 driver = "bridge"
851}
852"#;
853
854 #[test]
855 fn parses_complete_closed_acl_schema() {
856 let environment = HashMap::from([("API_TOKEN".to_string(), "secret".to_string())]);
857 let config = parse_compose_acl(COMPLETE, &environment).expect("valid compose ACL");
858
859 assert_eq!(config.services.len(), 2);
860 let api = &config.services["api"];
861 assert_eq!(api.image.as_deref(), Some("ghcr.io/a3s/api:latest"));
862 assert_eq!(
863 api.command.as_ref().unwrap().to_vec(),
864 ["serve", "--port", "8080"]
865 );
866 assert_eq!(api.environment.to_pairs().len(), 2);
867 assert!(api
868 .environment
869 .to_pairs()
870 .contains(&("TOKEN".to_string(), "secret".to_string())));
871 assert_eq!(api.depends_on.services(), ["db"]);
872 assert_eq!(api.networks.names(), ["backend"]);
873 assert_eq!(api.cpus, Some(2));
874 assert_eq!(api.healthcheck.as_ref().unwrap().retries, Some(3));
875 assert_eq!(
876 config.volumes["data"].as_ref().unwrap().driver.as_deref(),
877 Some("local")
878 );
879 assert_eq!(
880 config.networks["backend"]
881 .as_ref()
882 .unwrap()
883 .driver
884 .as_deref(),
885 Some("bridge")
886 );
887 }
888
889 #[test]
890 fn rejects_unknown_blocks_attributes_and_nested_fields() {
891 for source in [
892 "database \"db\" {}",
893 "service \"api\" { image = \"api\" typo = true }",
894 "service \"api\" { image = \"api\" deploy {} }",
895 "service \"api\" { image = \"api\" healthcheck { typo = 1 } }",
896 "service \"api\" { image = \"api\"",
897 "service \"api\" { image = \"api\" } }",
898 ] {
899 assert!(
900 parse_compose_acl(source, &HashMap::new()).is_err(),
901 "source should fail: {source}"
902 );
903 }
904 }
905
906 #[test]
907 fn rejects_invalid_labels_types_numbers_and_functions() {
908 for source in [
909 "service {}",
910 "service \"bad/name\" { image = \"api\" }",
911 "service \"api\" { ports = \"8080:80\" }",
912 "service \"api\" { cpus = -1 }",
913 "service \"api\" { privileged = \"true\" }",
914 "service \"api\" { image = concat(\"a\", \"b\") }",
915 ] {
916 assert!(
917 parse_compose_acl(source, &HashMap::new()).is_err(),
918 "source should fail: {source}"
919 );
920 }
921 }
922
923 #[test]
924 fn reports_missing_environment_values() {
925 let error = parse_compose_acl(
926 "service \"api\" { environment = { TOKEN = env(\"MISSING\") } }",
927 &HashMap::new(),
928 )
929 .unwrap_err();
930
931 assert!(error.to_string().contains("MISSING"));
932 assert!(error.to_string().contains("not set"));
933 }
934
935 #[test]
936 fn parses_nested_healthcheck_after_multibyte_string() {
937 let source = r#"
938service "api" {
939 image = "api:latest"
940 labels = { description = "服务" }
941
942 healthcheck {
943 test = ["CMD", "true"]
944 }
945}
946"#;
947
948 let config = parse_compose_acl(source, &HashMap::new()).expect("valid Unicode ACL");
949
950 assert_eq!(
951 config.services["api"]
952 .healthcheck
953 .as_ref()
954 .unwrap()
955 .test
956 .to_vec(),
957 ["CMD", "true"]
958 );
959 }
960}