use std::collections::HashMap;
use crate::compose::types::ComposeFile;
use crate::error::{ComposeError, Result};
const MAX_ALIAS_REFS: usize = 100;
const MAX_ALIAS_DOC_BYTES: usize = 512 * 1024;
const MAX_FLOW_DEPTH: usize = 100;
pub(super) fn deserialize_with_merge(content: &str) -> Result<ComposeFile> {
deserialize_with_merge_interp(content, None)
}
pub(super) fn deserialize_with_merge_interp(
content: &str,
vars: Option<&HashMap<String, String>>,
) -> Result<ComposeFile> {
guard_flow_depth(content)?;
guard_alias_expansion(content)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(content)?;
if let Some(vars) = vars {
interpolate_value(&mut value, vars)?;
}
apply_merge_keys(&mut value);
let file: ComposeFile = serde_yaml::from_value(value)?;
Ok(file)
}
fn interpolate_value(value: &mut serde_yaml::Value, vars: &HashMap<String, String>) -> Result<()> {
match value {
serde_yaml::Value::String(s) if s.contains('$') => {
*value = interpolate_scalar(s, vars)?;
}
serde_yaml::Value::Sequence(seq) => {
for item in seq.iter_mut() {
interpolate_value(item, vars)?;
}
}
serde_yaml::Value::Mapping(map) => {
let taken = std::mem::take(map);
let mut rebuilt = serde_yaml::Mapping::with_capacity(taken.len());
for (key, mut val) in taken {
let key = interpolate_key(key, vars)?;
interpolate_value(&mut val, vars)?;
rebuilt.insert(key, val);
}
*map = rebuilt;
}
_ => {}
}
Ok(())
}
fn interpolate_scalar(s: &str, vars: &HashMap<String, String>) -> Result<serde_yaml::Value> {
let resolved = crate::substitute::substitute(s, vars)?;
if resolved.is_empty() {
return Ok(serde_yaml::Value::String(String::new()));
}
match serde_yaml::from_str::<serde_yaml::Value>(&resolved) {
Ok(v @ (serde_yaml::Value::Bool(_) | serde_yaml::Value::Number(_))) => Ok(v),
_ => Ok(serde_yaml::Value::String(resolved)),
}
}
fn interpolate_key(
key: serde_yaml::Value,
vars: &HashMap<String, String>,
) -> Result<serde_yaml::Value> {
match key {
serde_yaml::Value::String(s) if s.contains('$') => Ok(serde_yaml::Value::String(
crate::substitute::substitute(&s, vars)?,
)),
other => Ok(other),
}
}
fn guard_alias_expansion(content: &str) -> Result<()> {
let refs = count_alias_refs(content);
if refs == 0 {
return Ok(());
}
if content.len() > MAX_ALIAS_DOC_BYTES {
return Err(ComposeError::Unsupported(format!(
"compose document uses YAML aliases and is {} bytes; documents using anchors/aliases \
must be at most {MAX_ALIAS_DOC_BYTES} bytes — inline the repeated content instead",
content.len()
)));
}
if refs > MAX_ALIAS_REFS {
return Err(ComposeError::Unsupported(format!(
"compose document uses {refs} YAML alias references; at most {MAX_ALIAS_REFS} are \
allowed — inline the repeated content instead"
)));
}
Ok(())
}
fn guard_flow_depth(content: &str) -> Result<()> {
let mut depth: usize = 0;
for line in content.lines() {
let (mut in_single, mut in_double) = (false, false);
for c in line.chars() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'#' if !in_single && !in_double => break,
'[' | '{' if !in_single && !in_double => {
depth += 1;
if depth > MAX_FLOW_DEPTH {
return Err(ComposeError::Unsupported(format!(
"compose document nests flow collections more than {MAX_FLOW_DEPTH} \
levels deep; flatten the structure"
)));
}
}
']' | '}' if !in_single && !in_double => depth = depth.saturating_sub(1),
_ => {}
}
}
}
Ok(())
}
fn count_alias_refs(content: &str) -> usize {
let mut count = 0;
for line in content.lines() {
let mut chars = line.chars().peekable();
let (mut in_single, mut in_double) = (false, false);
let mut prev: Option<char> = None;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'#' if !in_single && !in_double => break,
'*' if !in_single && !in_double => {
let at_node = matches!(prev, None | Some(' ' | '\t' | '[' | '{' | ',' | ':'));
let next_ok = chars
.peek()
.is_some_and(|n| n.is_ascii_alphanumeric() || *n == '_' || *n == '-');
if at_node && next_ok {
count += 1;
}
}
_ => {}
}
prev = Some(c);
}
}
count
}
fn apply_merge_keys(value: &mut serde_yaml::Value) {
match value {
serde_yaml::Value::Mapping(mapping) => {
for v in mapping.values_mut() {
apply_merge_keys(v);
}
let merge_key = serde_yaml::Value::String("<<".to_string());
if let Some(merge_val) = mapping.remove(&merge_key) {
let bases: Vec<serde_yaml::Mapping> = match merge_val {
serde_yaml::Value::Mapping(m) => vec![m],
serde_yaml::Value::Sequence(seq) => seq
.into_iter()
.filter_map(|v| match v {
serde_yaml::Value::Mapping(m) => Some(m),
_ => None,
})
.collect(),
_ => vec![],
};
for base in bases {
for (k, v) in base {
if !mapping.contains_key(&k) {
mapping.insert(k, v);
}
}
}
}
}
serde_yaml::Value::Sequence(seq) => {
for v in seq.iter_mut() {
apply_merge_keys(v);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn interpolation_cannot_inject_yaml_structure() {
let v = vars(&[("U", "root\n privileged: true")]);
let yaml = "services:\n app:\n image: nginx\n user: ${U}\n";
let file = deserialize_with_merge_interp(yaml, Some(&v)).unwrap();
let svc = &file.services["app"];
assert_eq!(svc.user.as_deref(), Some("root\n privileged: true"));
assert_eq!(svc.privileged, None);
}
#[test]
fn empty_interpolation_in_unquoted_scalar_keeps_key_and_is_empty() {
let yaml = "services:\n app:\n image: repo:${TAG}\n user: ${TAG}\n";
let file = deserialize_with_merge_interp(yaml, Some(&vars(&[]))).unwrap();
let svc = &file.services["app"];
assert_eq!(svc.image.as_deref(), Some("repo:"));
assert_eq!(svc.user.as_deref(), Some(""));
}
#[test]
fn interpolated_multiline_and_backslash_values_preserved_verbatim() {
let v = vars(&[("MSG", "line1\nline2\\x")]);
let yaml = "services:\n app:\n image: nginx\n user: ${MSG}\n";
let file = deserialize_with_merge_interp(yaml, Some(&v)).unwrap();
assert_eq!(
file.services["app"].user.as_deref(),
Some("line1\nline2\\x")
);
}
#[test]
fn interpolated_scalar_recovers_numeric_and_boolean_type() {
let v = vars(&[("P", "true"), ("CPU", "512")]);
let yaml =
"services:\n app:\n image: nginx\n privileged: ${P}\n cpu_shares: ${CPU}\n";
let file = deserialize_with_merge_interp(yaml, Some(&v)).unwrap();
assert_eq!(file.services["app"].privileged, Some(true));
assert_eq!(file.services["app"].cpu_shares, Some(512));
}
#[test]
fn no_interpolation_when_vars_is_none() {
let yaml = "services:\n app:\n image: repo:${TAG}\n";
let file = deserialize_with_merge(yaml).unwrap();
assert_eq!(file.services["app"].image.as_deref(), Some("repo:${TAG}"));
}
#[test]
fn count_alias_refs_ignores_quotes_comments_and_globs() {
assert_eq!(count_alias_refs("a: &x 1\nb: *x\n"), 1);
assert_eq!(count_alias_refs("c: [*x, *x, *x]\n"), 3);
assert_eq!(
count_alias_refs("cmd: \"rm *x\"\nd: 1 # *x\ng: ['*.txt', '**']\n"),
0
);
}
#[test]
fn guard_allows_normal_anchored_file() {
let yaml = "x: &d {a: 1}\nweb: {<<: *d}\napi: {<<: *d}\n";
assert!(guard_alias_expansion(yaml).is_ok());
}
#[test]
fn guard_rejects_linear_alias_amplification() {
let mut yaml = String::from("anchor: &a [x, y, z]\nlist:\n");
for _ in 0..(MAX_ALIAS_REFS + 50) {
yaml.push_str(" - *a\n");
}
let err = guard_alias_expansion(&yaml).unwrap_err();
assert!(format!("{err}").contains("alias references"));
}
#[test]
fn guard_flow_depth_allows_shallow_nesting() {
assert!(guard_flow_depth("a: [[1, 2], [3, 4]]\nb: {x: {y: 1}}\n").is_ok());
}
#[test]
fn guard_flow_depth_rejects_pathological_nesting() {
let deep = format!(
"a: {}{}\n",
"[".repeat(MAX_FLOW_DEPTH + 5),
"]".repeat(MAX_FLOW_DEPTH + 5)
);
let err = guard_flow_depth(&deep).unwrap_err();
assert!(format!("{err}").contains("flow collections"));
}
#[test]
fn guard_flow_depth_ignores_brackets_in_quotes_and_comments() {
let yaml = format!("cmd: \"{}\" # {}\n", "[".repeat(200), "{".repeat(200));
assert!(guard_flow_depth(&yaml).is_ok());
}
#[test]
fn guard_rejects_large_aliased_document() {
let mut yaml = String::from("anchor: &a 1\nb: *a\n");
yaml.push_str(&format!("pad: \"{}\"\n", "p".repeat(MAX_ALIAS_DOC_BYTES)));
let err = guard_alias_expansion(&yaml).unwrap_err();
assert!(format!("{err}").contains("at most"));
}
}