Skip to main content

document_svg/document/
compose.rs

1//! Bounded Docker Compose file previews.
2//!
3//! Compose files describe services and shared resources. This adapter renders
4//! an inert service inventory with bounded counts; it never starts containers,
5//! pulls or builds images, evaluates commands, resolves interpolation/includes,
6//! contacts a Docker daemon, reads environment values, or exposes secret data.
7
8use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_COMPOSE_JSON_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_COMPOSE_YAML_BYTES: u64 = 16 * 1024 * 1024;
18const MAX_COMPOSE_DEPTH: usize = 100;
19const MAX_COMPOSE_VALUES: usize = 300_000;
20const MAX_COMPOSE_SERVICES: usize = 100_000;
21const MAX_COMPOSE_LINES: usize = 500_000;
22const MAX_COMPOSE_LINE_BYTES: usize = 1024 * 1024;
23const MAX_COMPOSE_STRING_BYTES: usize = 2 * 1024 * 1024;
24
25/// Detect a Compose YAML document from its required top-level `services` map.
26pub(crate) fn looks_like_yaml_prefix(prefix: &[u8]) -> bool {
27    let Ok(text) = std::str::from_utf8(prefix) else {
28        return false;
29    };
30    let mut services = false;
31    let mut service_entry = false;
32    let mut service_property = false;
33    for raw in text.lines() {
34        let trimmed = raw.trim();
35        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---" || trimmed == "..." {
36            continue;
37        }
38        let indent = raw.len() - raw.trim_start().len();
39        let Some((key, _)) = trimmed.split_once(':') else {
40            continue;
41        };
42        let key = key.trim().trim_matches(['"', '\'']);
43        if indent == 0 {
44            if key == "apiVersion" || key == "kind" {
45                return false;
46            }
47            services = key == "services";
48            service_entry = false;
49            service_property = false;
50        } else if services && indent == 2 {
51            service_entry = true;
52        } else if services && indent >= 4 && service_entry {
53            service_property = matches!(
54                key,
55                "image"
56                    | "build"
57                    | "command"
58                    | "entrypoint"
59                    | "environment"
60                    | "restart"
61                    | "container_name"
62                    | "expose"
63                    | "healthcheck"
64                    | "deploy"
65                    | "profiles"
66                    | "ports"
67                    | "depends_on"
68                    | "volumes"
69                    | "networks"
70                    | "secrets"
71            );
72        }
73        if services && service_entry && service_property {
74            return true;
75        }
76    }
77    false
78}
79
80/// Detect a Compose JSON document conservatively to avoid claiming generic
81/// configuration objects which merely happen to have a `services` key.
82pub(crate) fn looks_like_json_prefix(prefix: &[u8]) -> bool {
83    let text = String::from_utf8_lossy(prefix);
84    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
85    trimmed.starts_with('{')
86        && text.contains("\"services\"")
87        && [
88            "\"image\"",
89            "\"build\"",
90            "\"environment\"",
91            "\"restart\"",
92            "\"container_name\"",
93            "\"expose\"",
94            "\"healthcheck\"",
95            "\"deploy\"",
96            "\"profiles\"",
97            "\"ports\"",
98            "\"depends_on\"",
99            "\"volumes\"",
100            "\"networks\"",
101            "\"secrets\"",
102        ]
103        .iter()
104        .any(|needle| text.contains(needle))
105}
106
107struct ComposePageSink<'a> {
108    inner: &'a mut dyn PageConsumer,
109    warnings: &'a [String],
110}
111
112impl PageConsumer for ComposePageSink<'_> {
113    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
114        page.source_format = "compose".into();
115        if page.title.is_empty() {
116            page.title = "Docker Compose".into();
117        }
118        page.description =
119            "Docker Compose services and resource references are rendered as inert metadata; no runtime operation is performed".into();
120        for warning in self.warnings {
121            page.warn(warning.clone());
122        }
123        self.inner.consume(page)
124    }
125}
126
127pub(crate) fn convert(
128    path: &Path,
129    options: &ConvertOptions,
130    sink: &mut dyn PageConsumer,
131) -> Result<Vec<String>> {
132    let bytes = read_limited_file(
133        path,
134        options.max_input_bytes.min(MAX_COMPOSE_JSON_BYTES),
135        "Docker Compose input",
136    )?;
137    let text = String::from_utf8(bytes).map_err(|error| {
138        Error::InvalidInput(format!("Docker Compose input must be UTF-8: {error}"))
139    })?;
140    let (table, metadata, warnings) = if text
141        .trim_start_matches('\u{feff}')
142        .trim_start()
143        .starts_with('{')
144    {
145        parse_json(&text)?
146    } else {
147        parse_yaml(&text)?
148    };
149    let blocks = vec![
150        HtmlBlock::Heading {
151            level: 1,
152            text: "Docker Compose".into(),
153        },
154        HtmlBlock::Paragraph { text: metadata },
155        HtmlBlock::Table(table),
156    ];
157    let mut page_sink = ComposePageSink {
158        inner: sink,
159        warnings: &warnings,
160    };
161    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
162    Ok(warnings)
163}
164
165fn parse_json(text: &str) -> Result<(TableData, String, Vec<String>)> {
166    if text.len() as u64 > MAX_COMPOSE_JSON_BYTES {
167        return Err(Error::LimitExceeded(format!(
168            "Docker Compose JSON exceeds {MAX_COMPOSE_JSON_BYTES} bytes"
169        )));
170    }
171    preflight_depth(text)?;
172    let value: Value = serde_json::from_str(text)
173        .map_err(|error| Error::InvalidInput(format!("invalid Docker Compose JSON: {error}")))?;
174    let mut count = 0usize;
175    count_values(&value, 0, &mut count)?;
176    let root = value
177        .as_object()
178        .ok_or_else(|| Error::InvalidInput("Docker Compose JSON root must be an object".into()))?;
179    let services = root
180        .get("services")
181        .and_then(Value::as_object)
182        .ok_or_else(|| {
183            Error::InvalidInput("Docker Compose JSON requires a services object".into())
184        })?;
185    if services.is_empty() {
186        return Err(Error::InvalidInput(
187            "Docker Compose JSON services object is empty".into(),
188        ));
189    }
190    if services.len() > MAX_COMPOSE_SERVICES {
191        return Err(Error::LimitExceeded(format!(
192            "Docker Compose services exceed {MAX_COMPOSE_SERVICES}"
193        )));
194    }
195    let rows = services
196        .iter()
197        .map(|(name, definition)| json_row(name, definition))
198        .collect::<Vec<_>>();
199    let mut warnings = base_warnings();
200    warnings.push(
201        "Compose JSON command, environment, healthcheck, config and secret values are omitted"
202            .into(),
203    );
204    let metadata = metadata_json(root, services.len());
205    Ok((table(rows), metadata, warnings))
206}
207
208fn json_row(name: &str, definition: &Value) -> Vec<String> {
209    let object = definition.as_object();
210    let image = object
211        .and_then(|v| v.get("image"))
212        .and_then(Value::as_str)
213        .map_or_else(
214            || {
215                if object.is_some_and(|v| v.contains_key("build")) {
216                    "build".to_owned()
217                } else {
218                    "—".to_owned()
219                }
220            },
221            truncate,
222        );
223    let build = object.is_some_and(|v| v.contains_key("build"));
224    let image_build = if build && image != "build" {
225        format!("{image} + build")
226    } else {
227        image
228    };
229    let ports = object.map_or(0, |v| count_attr(v.get("ports")));
230    let depends = object.map_or(0, |v| count_attr(v.get("depends_on")));
231    let volumes = object.map_or(0, |v| count_attr(v.get("volumes")));
232    let networks = object.map_or(0, |v| count_attr(v.get("networks")));
233    let secrets = object.map_or(0, |v| count_attr(v.get("secrets")));
234    let runtime = object.map_or("—", |v| {
235        if v.contains_key("command") {
236            "command"
237        } else {
238            "—"
239        }
240    });
241    vec![
242        truncate(name),
243        truncate(&image_build),
244        ports.to_string(),
245        depends.to_string(),
246        volumes.to_string(),
247        format!(
248            "net:{networks} sec:{secrets}{}",
249            if runtime == "command" { " cmd" } else { "" }
250        ),
251    ]
252}
253
254fn metadata_json(root: &serde_json::Map<String, Value>, services: usize) -> String {
255    let count = |key: &str| root.get(key).map_or(0, |value| count_attr(Some(value)));
256    let mut parts = vec![
257        format!("Services: {services}"),
258        format!("Networks: {}", count("networks")),
259        format!("Volumes: {}", count("volumes")),
260        format!("Secrets: {}", count("secrets")),
261        format!("Configs: {}", count("configs")),
262    ];
263    if let Some(name) = root.get("name").and_then(Value::as_str) {
264        parts.insert(0, format!("Name: {}", truncate(name)));
265    }
266    if let Some(version) = root.get("version").and_then(Value::as_str) {
267        parts.push(format!("Version: {}", truncate(version)));
268    }
269    parts.join("\n")
270}
271
272fn parse_yaml(text: &str) -> Result<(TableData, String, Vec<String>)> {
273    if text.len() as u64 > MAX_COMPOSE_YAML_BYTES {
274        return Err(Error::LimitExceeded(format!(
275            "Docker Compose YAML exceeds {MAX_COMPOSE_YAML_BYTES} bytes"
276        )));
277    }
278    let (_, mut warnings) = crate::document::yaml::parse_yaml_blocks(text)?;
279    let mut parser = ComposeYamlParser::default();
280    for (line_number, raw) in text.lines().enumerate() {
281        if line_number >= MAX_COMPOSE_LINES {
282            return Err(Error::LimitExceeded(format!(
283                "Docker Compose YAML exceeds {MAX_COMPOSE_LINES} lines"
284            )));
285        }
286        if raw.len() > MAX_COMPOSE_LINE_BYTES {
287            return Err(Error::LimitExceeded(format!(
288                "Docker Compose YAML line {} exceeds {MAX_COMPOSE_LINE_BYTES} bytes",
289                line_number + 1
290            )));
291        }
292        parser.line(raw)?;
293    }
294    parser.finish()?;
295    if parser.rows.is_empty() {
296        return Err(Error::InvalidInput(
297            "Docker Compose YAML requires a non-empty services map".into(),
298        ));
299    }
300    warnings.extend(base_warnings());
301    warnings.push(
302        "Compose YAML command, environment, healthcheck, config and secret values are omitted"
303            .into(),
304    );
305    let metadata = format!(
306        "Services: {}\nNetworks: {}\nVolumes: {}\nSecrets: {}\nConfigs: {}",
307        parser.rows.len(),
308        parser.networks,
309        parser.volumes,
310        parser.secrets,
311        parser.configs
312    );
313    Ok((table(parser.rows), metadata, warnings))
314}
315
316#[derive(Default)]
317struct ComposeYamlParser {
318    in_services: bool,
319    top_section: Option<String>,
320    current_field: Option<String>,
321    current: Option<ComposeService>,
322    rows: Vec<Vec<String>>,
323    networks: usize,
324    volumes: usize,
325    secrets: usize,
326    configs: usize,
327}
328
329#[derive(Default)]
330struct ComposeService {
331    name: String,
332    image: String,
333    build: bool,
334    ports: usize,
335    depends: usize,
336    volumes: usize,
337    networks: usize,
338    secrets: usize,
339    command: bool,
340}
341
342impl ComposeYamlParser {
343    fn line(&mut self, raw: &str) -> Result<()> {
344        let trimmed = raw.trim();
345        if trimmed.is_empty() || trimmed.starts_with('#') {
346            return Ok(());
347        }
348        if trimmed == "---" || trimmed == "..." {
349            self.flush_service()?;
350            self.in_services = false;
351            self.top_section = None;
352            self.current_field = None;
353            return Ok(());
354        }
355        let indent = raw.len() - raw.trim_start().len();
356        if indent == 0 {
357            self.flush_service()?;
358            self.current_field = None;
359            let Some((raw_key, raw_value)) = trimmed.split_once(':') else {
360                self.in_services = false;
361                self.top_section = None;
362                return Ok(());
363            };
364            let key = clean_key(raw_key);
365            let value = scalar(raw_value);
366            self.in_services = key == "services";
367            self.top_section = match key.as_str() {
368                "networks" | "volumes" | "secrets" | "configs" => Some(key),
369                _ => None,
370            };
371            if !self.in_services {
372                self.count_inline_resource(&self.top_section.clone(), &value);
373            }
374            return Ok(());
375        }
376        if self.in_services {
377            if indent == 2 {
378                self.flush_service()?;
379                let Some((raw_key, raw_value)) = trimmed.split_once(':') else {
380                    self.current_field = None;
381                    return Ok(());
382                };
383                let key = clean_key(raw_key);
384                if key.is_empty() {
385                    return Ok(());
386                }
387                self.current = Some(ComposeService {
388                    name: key,
389                    ..ComposeService::default()
390                });
391                self.current_field = None;
392                let value = scalar(raw_value);
393                if !value.is_empty() {
394                    self.current_field = Some("service_inline".into());
395                }
396                return Ok(());
397            }
398            if indent == 4 {
399                let Some((raw_key, raw_value)) = trimmed.split_once(':') else {
400                    return Ok(());
401                };
402                let key = clean_key(raw_key);
403                self.current_field = Some(key.clone());
404                if let Some(service) = self.current.as_mut() {
405                    let value = scalar(raw_value);
406                    match key.as_str() {
407                        "image" => service.image = truncate(&value),
408                        "build" => service.build = true,
409                        "command" => service.command = true,
410                        "ports" => service.ports += inline_count(&value),
411                        "depends_on" => service.depends += inline_count(&value),
412                        "volumes" => service.volumes += inline_count(&value),
413                        "networks" => service.networks += inline_count(&value),
414                        "secrets" => service.secrets += inline_count(&value),
415                        _ => {}
416                    }
417                }
418                return Ok(());
419            }
420            if indent >= 6
421                && let Some(field) = self.current_field.as_deref()
422                && let Some(service) = self.current.as_mut()
423                && indent == 6
424                && (trimmed.starts_with('-') || trimmed.contains(':'))
425            {
426                match field {
427                    "ports" => service.ports = service.ports.saturating_add(1),
428                    "depends_on" => service.depends = service.depends.saturating_add(1),
429                    "volumes" => service.volumes = service.volumes.saturating_add(1),
430                    "networks" => service.networks = service.networks.saturating_add(1),
431                    "secrets" => service.secrets = service.secrets.saturating_add(1),
432                    _ => {}
433                }
434            }
435            return Ok(());
436        }
437        if let Some(section) = self.top_section.as_deref()
438            && indent == 2
439            && trimmed.split_once(':').is_some()
440        {
441            match section {
442                "networks" => self.networks = self.networks.saturating_add(1),
443                "volumes" => self.volumes = self.volumes.saturating_add(1),
444                "secrets" => self.secrets = self.secrets.saturating_add(1),
445                "configs" => self.configs = self.configs.saturating_add(1),
446                _ => {}
447            }
448        }
449        Ok(())
450    }
451
452    fn count_inline_resource(&mut self, section: &Option<String>, value: &str) {
453        let count = inline_count(value);
454        match section.as_deref() {
455            Some("networks") => self.networks = self.networks.saturating_add(count),
456            Some("volumes") => self.volumes = self.volumes.saturating_add(count),
457            Some("secrets") => self.secrets = self.secrets.saturating_add(count),
458            Some("configs") => self.configs = self.configs.saturating_add(count),
459            _ => {}
460        }
461    }
462
463    fn flush_service(&mut self) -> Result<()> {
464        let Some(service) = self.current.take() else {
465            return Ok(());
466        };
467        if self.rows.len() >= MAX_COMPOSE_SERVICES {
468            return Err(Error::LimitExceeded(format!(
469                "Docker Compose services exceed {MAX_COMPOSE_SERVICES}"
470            )));
471        }
472        let image = if service.image.is_empty() {
473            if service.build {
474                "build".into()
475            } else {
476                "—".into()
477            }
478        } else if service.build {
479            format!("{} + build", service.image)
480        } else {
481            service.image
482        };
483        self.rows.push(vec![
484            truncate(&service.name),
485            truncate(&image),
486            service.ports.to_string(),
487            service.depends.to_string(),
488            service.volumes.to_string(),
489            format!(
490                "net:{} sec:{}{}",
491                service.networks,
492                service.secrets,
493                if service.command { " cmd" } else { "" }
494            ),
495        ]);
496        Ok(())
497    }
498
499    fn finish(&mut self) -> Result<()> {
500        self.flush_service()
501    }
502}
503
504fn table(rows: Vec<Vec<String>>) -> TableData {
505    TableData {
506        headers: vec![
507            "Service".into(),
508            "Image/build".into(),
509            "Ports".into(),
510            "Depends".into(),
511            "Volumes".into(),
512            "Net/sec/cmd".into(),
513        ],
514        rows,
515        alignments: vec![TableAlign::Left; 6],
516        raw_source: String::new(),
517    }
518}
519
520fn base_warnings() -> Vec<String> {
521    vec!["Docker Compose services and resource references are rendered inertly; Docker daemon access, image pull/build, command, healthcheck, network, volume, config and secret operations are never executed".into(), "Compose interpolation, include and merge semantics are not evaluated; secret and environment values remain omitted".into()]
522}
523
524fn clean_key(raw: &str) -> String {
525    raw.trim().trim_matches(['"', '\'']).to_owned()
526}
527
528fn scalar(raw: &str) -> String {
529    raw.trim()
530        .split_once(" #")
531        .map_or(raw.trim(), |(value, _)| value.trim())
532        .trim_matches(['"', '\''])
533        .to_owned()
534}
535
536fn inline_count(value: &str) -> usize {
537    let value = value.trim();
538    if value.is_empty() || value == "{}" || value == "[]" || value == "null" {
539        return 0;
540    }
541    if value.starts_with('[') {
542        return value
543            .trim_matches(['[', ']'])
544            .split(',')
545            .filter(|part| !part.trim().is_empty())
546            .count();
547    }
548    if value.starts_with('{') {
549        return value
550            .trim_matches(['{', '}'])
551            .split(',')
552            .filter(|part| part.split_once(':').is_some())
553            .count();
554    }
555    if value.starts_with('-') { 1 } else { 0 }
556}
557
558fn count_attr(value: Option<&Value>) -> usize {
559    match value {
560        Some(Value::Array(values)) => values.len(),
561        Some(Value::Object(values)) => values.len(),
562        Some(Value::String(value)) if !value.trim().is_empty() => 1,
563        _ => 0,
564    }
565}
566
567fn truncate(value: &str) -> String {
568    if value.len() <= MAX_COMPOSE_STRING_BYTES {
569        return value.to_owned();
570    }
571    let mut end = MAX_COMPOSE_STRING_BYTES;
572    while !value.is_char_boundary(end) {
573        end -= 1;
574    }
575    format!("{}…", &value[..end])
576}
577
578fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
579    if depth > MAX_COMPOSE_DEPTH {
580        return Err(Error::LimitExceeded(format!(
581            "Docker Compose JSON nesting exceeds {MAX_COMPOSE_DEPTH} levels"
582        )));
583    }
584    *count = count.saturating_add(1);
585    if *count > MAX_COMPOSE_VALUES {
586        return Err(Error::LimitExceeded(format!(
587            "Docker Compose JSON contains more than {MAX_COMPOSE_VALUES} values"
588        )));
589    }
590    match value {
591        Value::Array(values) => {
592            for item in values {
593                count_values(item, depth + 1, count)?;
594            }
595        }
596        Value::Object(map) => {
597            for item in map.values() {
598                count_values(item, depth + 1, count)?;
599            }
600        }
601        Value::String(value) if value.len() > MAX_COMPOSE_STRING_BYTES => {
602            return Err(Error::LimitExceeded(format!(
603                "Docker Compose JSON string exceeds {MAX_COMPOSE_STRING_BYTES} bytes"
604            )));
605        }
606        _ => {}
607    }
608    Ok(())
609}
610
611fn preflight_depth(text: &str) -> Result<()> {
612    let mut depth = 0usize;
613    let mut quoted = false;
614    let mut escaped = false;
615    for byte in text.bytes() {
616        if quoted {
617            if escaped {
618                escaped = false;
619            } else if byte == b'\\' {
620                escaped = true;
621            } else if byte == b'"' {
622                quoted = false;
623            }
624            continue;
625        }
626        match byte {
627            b'"' => quoted = true,
628            b'{' | b'[' => {
629                depth += 1;
630                if depth > MAX_COMPOSE_DEPTH {
631                    return Err(Error::LimitExceeded(format!(
632                        "Docker Compose JSON nesting exceeds {MAX_COMPOSE_DEPTH} levels"
633                    )));
634                }
635            }
636            b'}' | b']' => depth = depth.saturating_sub(1),
637            _ => {}
638        }
639    }
640    Ok(())
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn recognizes_compose_yaml_but_not_kubernetes() {
649        assert!(looks_like_yaml_prefix(
650            b"services:\n  web:\n    image: nginx\n"
651        ));
652        assert!(!looks_like_yaml_prefix(
653            b"apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: x\n"
654        ));
655    }
656
657    #[test]
658    fn counts_json_services_without_secret_values() {
659        let (table, metadata, warnings) = parse_json(
660            r#"{"name":"demo","services":{"web":{"image":"nginx","ports":["80:80"],"secrets":["token"],"command":"echo secret"}},"secrets":{"token":{"file":"secret.txt"}}}"#,
661        )
662        .unwrap();
663        assert!(metadata.contains("Services: 1"));
664        assert_eq!(table.rows[0][0], "web");
665        assert_eq!(table.rows[0][5], "net:0 sec:1 cmd");
666        assert!(!table.rows[0].iter().any(|value| value.contains("secret")));
667        assert!(warnings.iter().any(|warning| warning.contains("omitted")));
668    }
669}