Skip to main content

a3s_box_core/compose/
acl.rs

1//! Closed-schema A3S ACL parser for Compose applications.
2
3use 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/// An invalid A3S Compose ACL document.
21#[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    /// Structured diagnostic suitable for CLI, API, or Cloud callers.
63    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}