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