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 (name, value) in &mut block.attributes {
117        // Secret references are names, not scalar values. Expanding them here
118        // could turn caller-owned Secret bytes into durable Compose data.
119        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        // `env()` resolves a literal value. Secret references must remain
171        // non-sensitive names and are validated as plain strings later.
172        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}