1use 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
25pub(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
80pub(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}