1use std::collections::{BTreeMap, HashMap, HashSet};
4use std::ffi::OsStr;
5use std::path::{Path, PathBuf};
6
7use hcl::eval::{Context, Evaluate};
8use hcl::{BlockLabel, Body, Expression, Value as HclValue};
9use ignore::WalkBuilder;
10use ignore::gitignore::GitignoreBuilder;
11use serde_yaml::Value as YamlValue;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct BuildInput {
15 pub dockerfile: PathBuf,
16 pub context: PathBuf,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ContainerEngine {
22 Docker,
23 Podman,
24}
25
26impl ContainerEngine {
27 pub fn parse(value: Option<&str>) -> anyhow::Result<Self> {
28 match value.unwrap_or("docker").to_ascii_lowercase().as_str() {
29 "docker" => Ok(Self::Docker),
30 "podman" => Ok(Self::Podman),
31 other => anyhow::bail!("Unknown workflow engine '{other}'; expected docker or podman"),
32 }
33 }
34}
35
36#[derive(Debug, Default)]
37pub struct Discovery {
38 pub inputs: Vec<BuildInput>,
39 pub warnings: Vec<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum DockerignoreProblem {
44 Missing { expected: PathBuf },
45 Empty { path: PathBuf },
46}
47
48pub fn discover(requested: &[PathBuf], engine: ContainerEngine) -> Discovery {
50 let requested = if requested.is_empty() {
51 vec![PathBuf::from(".")]
52 } else {
53 requested.to_vec()
54 };
55 let mut discovery = Discovery::default();
56 let mut inputs = BTreeMap::<(PathBuf, PathBuf), BuildInput>::new();
57
58 for requested_path in requested {
59 let pattern = requested_path.to_string_lossy();
60 if contains_glob(&pattern) {
61 match glob::glob(&pattern) {
62 Ok(matches) => {
63 for path in matches.flatten() {
64 discover_path(&path, engine, &mut inputs, &mut discovery.warnings);
65 }
66 }
67 Err(error) => discovery
68 .warnings
69 .push(format!("invalid path pattern {pattern:?}: {error}")),
70 }
71 } else {
72 discover_path(&requested_path, engine, &mut inputs, &mut discovery.warnings);
73 }
74 }
75
76 discovery.inputs = inputs.into_values().collect();
77 discovery
78}
79
80fn discover_path(
81 path: &Path,
82 engine: ContainerEngine,
83 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
84 warnings: &mut Vec<String>,
85) {
86 if path == Path::new("-") {
87 insert_input(inputs, path.to_path_buf(), current_directory());
88 } else if path.is_dir() {
89 discover_directory(path, engine, inputs, warnings);
90 } else if path.is_file() && is_compose_file(path) {
91 discover_compose(path, inputs, warnings);
92 } else if path.is_file() && is_bake_file(path) {
93 discover_bake(path, inputs, warnings);
94 } else if engine == ContainerEngine::Podman && path.is_file() && is_quadlet_build_file(path) {
95 discover_quadlet_build(path, inputs, warnings);
96 } else if engine == ContainerEngine::Podman && path.is_file() && is_quadlet_kube_file(path) {
97 discover_quadlet_kube(path, inputs, warnings);
98 } else if engine == ContainerEngine::Podman && path.is_file() && is_kube_play_file(path) {
99 discover_kube_play(path, inputs, warnings);
100 } else {
101 let context = path.parent().unwrap_or_else(|| Path::new("."));
102 insert_input(inputs, path.to_path_buf(), absolute_path(context));
103 }
104}
105
106fn discover_directory(
107 root: &Path,
108 engine: ContainerEngine,
109 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
110 warnings: &mut Vec<String>,
111) {
112 let mut dockerfiles = Vec::new();
113 let mut compose_files = Vec::new();
114 let mut bake_files = Vec::new();
115 let mut quadlet_build_files = Vec::new();
116 let mut quadlet_kube_files = Vec::new();
117 let mut kube_files = Vec::new();
118
119 let mut walker = WalkBuilder::new(root);
120 walker
121 .follow_links(false)
122 .hidden(false)
123 .ignore(true)
124 .git_ignore(true)
125 .git_exclude(true)
126 .parents(true);
127 for entry in walker.build() {
128 let entry = match entry {
129 Ok(entry) => entry,
130 Err(error) => {
131 warnings.push(format!("cannot inspect repository entry: {error}"));
132 continue;
133 }
134 };
135 if !entry
136 .file_type()
137 .is_some_and(|file_type| file_type.is_file())
138 {
139 continue;
140 }
141 let path = entry.into_path();
142 if is_dockerfile_name(&path) {
143 dockerfiles.push(path);
144 } else if is_compose_file(&path) {
145 compose_files.push(path);
146 } else if is_bake_file(&path) {
147 bake_files.push(path);
148 } else if engine == ContainerEngine::Podman && is_quadlet_build_file(&path) {
149 quadlet_build_files.push(path);
150 } else if engine == ContainerEngine::Podman && is_quadlet_kube_file(&path) {
151 quadlet_kube_files.push(path);
152 } else if engine == ContainerEngine::Podman && is_kube_play_file(&path) {
153 kube_files.push(path);
154 }
155 }
156
157 compose_files.sort();
158 bake_files.sort();
159 quadlet_build_files.sort();
160 quadlet_kube_files.sort();
161 kube_files.sort();
162 dockerfiles.sort();
163
164 for path in compose_files {
168 discover_compose(&path, inputs, warnings);
169 }
170 for path in bake_files {
171 discover_bake(&path, inputs, warnings);
172 }
173 for path in quadlet_build_files {
174 discover_quadlet_build(&path, inputs, warnings);
175 }
176 for path in quadlet_kube_files {
177 discover_quadlet_kube(&path, inputs, warnings);
178 }
179 for path in kube_files {
180 discover_kube_play(&path, inputs, warnings);
181 }
182 for dockerfile in dockerfiles {
183 if contains_dockerfile(inputs, &dockerfile) {
184 continue;
185 }
186 let context = infer_context(&dockerfile, root, engine);
187 insert_input(inputs, dockerfile, context);
188 }
189}
190
191fn contains_dockerfile(
192 inputs: &BTreeMap<(PathBuf, PathBuf), BuildInput>,
193 dockerfile: &Path,
194) -> bool {
195 let dockerfile = absolute_path(dockerfile);
196 inputs.keys().any(|(known, _)| known == &dockerfile)
197}
198
199fn infer_context(dockerfile: &Path, repository_root: &Path, engine: ContainerEngine) -> PathBuf {
200 let root = absolute_path(repository_root);
201 let mut directory = dockerfile
202 .parent()
203 .map(absolute_path)
204 .unwrap_or_else(|| root.clone());
205 loop {
206 if has_context_ignore_file(&directory, engine) {
207 return directory;
208 }
209 if directory == root || !directory.starts_with(&root) {
210 return root;
211 }
212 let Some(parent) = directory.parent() else {
213 return root;
214 };
215 directory = parent.to_path_buf();
216 }
217}
218
219fn has_context_ignore_file(context: &Path, engine: ContainerEngine) -> bool {
220 match engine {
221 ContainerEngine::Docker => context.join(".dockerignore").is_file(),
222 ContainerEngine::Podman => {
223 context.join(".containerignore").is_file() || context.join(".dockerignore").is_file()
224 }
225 }
226}
227
228fn discover_compose(
229 compose_file: &Path,
230 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
231 warnings: &mut Vec<String>,
232) {
233 let content = match std::fs::read_to_string(compose_file) {
234 Ok(content) => content,
235 Err(error) => {
236 warnings.push(format!(
237 "cannot read Compose file '{}': {error}",
238 compose_file.display()
239 ));
240 return;
241 }
242 };
243 let document: YamlValue = match serde_yaml::from_str(&content) {
244 Ok(document) => document,
245 Err(error) => {
246 warnings.push(format!(
247 "cannot parse Compose file '{}': {error}",
248 compose_file.display()
249 ));
250 return;
251 }
252 };
253 let Some(services) = yaml_mapping_value(&document, "services").and_then(YamlValue::as_mapping)
254 else {
255 return;
256 };
257 let project_dir = compose_file.parent().unwrap_or_else(|| Path::new("."));
258 let environment = compose_environment(project_dir);
259
260 for (service_name, service) in services {
261 let Some(build) = yaml_mapping_value(service, "build") else {
262 continue;
263 };
264 let name = service_name.as_str().unwrap_or("<unnamed>");
265 let (context_value, dockerfile_value) = match build {
266 YamlValue::String(context) => (Some(context.as_str()), Some("Dockerfile")),
267 YamlValue::Mapping(_) => {
268 if yaml_mapping_value(build, "dockerfile_inline").is_some() {
269 continue;
270 }
271 (
272 yaml_mapping_value(build, "context").and_then(YamlValue::as_str),
273 yaml_mapping_value(build, "dockerfile")
274 .and_then(YamlValue::as_str)
275 .or(Some("Dockerfile")),
276 )
277 }
278 _ => continue,
279 };
280 let Some(context_value) = interpolate_path(context_value.unwrap_or("."), &environment)
281 else {
282 warnings.push(format!(
283 "Compose service {name:?} in '{}' has an unresolved build context",
284 compose_file.display()
285 ));
286 continue;
287 };
288 let Some(dockerfile_value) =
289 dockerfile_value.and_then(|value| interpolate_path(value, &environment))
290 else {
291 warnings.push(format!(
292 "Compose service {name:?} in '{}' has an unresolved Dockerfile path",
293 compose_file.display()
294 ));
295 continue;
296 };
297 let Some(context) = resolve_local_path(project_dir, &context_value) else {
298 continue;
299 };
300 let dockerfile = resolve_path(&context, &dockerfile_value);
301 insert_referenced(
302 inputs,
303 dockerfile,
304 context,
305 "Compose",
306 compose_file,
307 warnings,
308 );
309 }
310}
311
312fn yaml_mapping_value<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlValue> {
313 value.as_mapping()?.get(YamlValue::String(key.to_string()))
314}
315
316#[derive(Debug, Default, Clone)]
317struct BakeTarget {
318 context: Option<String>,
319 dockerfile: Option<String>,
320 inherits: Vec<String>,
321 inline: bool,
322}
323
324fn discover_bake(
325 bake_file: &Path,
326 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
327 warnings: &mut Vec<String>,
328) {
329 let content = match std::fs::read_to_string(bake_file) {
330 Ok(content) => content,
331 Err(error) => {
332 warnings.push(format!(
333 "cannot read Bake file '{}': {error}",
334 bake_file.display()
335 ));
336 return;
337 }
338 };
339 let targets = if bake_file.extension() == Some(OsStr::new("json")) {
340 match parse_bake_json(&content) {
341 Ok(targets) => targets,
342 Err(error) => {
343 warnings.push(format!(
344 "cannot parse Bake file '{}': {error}",
345 bake_file.display()
346 ));
347 return;
348 }
349 }
350 } else {
351 match parse_bake_hcl(&content) {
352 Ok(targets) => targets,
353 Err(error) => {
354 warnings.push(format!(
355 "cannot parse Bake file '{}': {error}",
356 bake_file.display()
357 ));
358 return;
359 }
360 }
361 };
362 let base = bake_file.parent().unwrap_or_else(|| Path::new("."));
363
364 for name in targets.keys() {
365 let mut visiting = HashSet::new();
366 let Some(target) = resolve_bake_target(name, &targets, &mut visiting) else {
367 warnings.push(format!(
368 "Bake target {name:?} in '{}' has cyclic or missing inheritance",
369 bake_file.display()
370 ));
371 continue;
372 };
373 if target.inline {
374 continue;
375 }
376 let context_value = target.context.as_deref().unwrap_or(".");
377 let dockerfile_value = target.dockerfile.as_deref().unwrap_or("Dockerfile");
378 let Some(context) = resolve_local_path(base, context_value) else {
379 continue;
380 };
381 let dockerfile = resolve_path(&context, dockerfile_value);
382 insert_referenced(inputs, dockerfile, context, "Bake", bake_file, warnings);
383 }
384}
385
386fn discover_quadlet_build(
387 unit: &Path,
388 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
389 warnings: &mut Vec<String>,
390) {
391 let content = match std::fs::read_to_string(unit) {
392 Ok(content) => content,
393 Err(error) => {
394 warnings.push(format!("cannot read Quadlet build unit '{}': {error}", unit.display()));
395 return;
396 }
397 };
398 if !quadlet_has_section(&content, "Build") {
399 return;
400 }
401 let base = unit.parent().unwrap_or_else(|| Path::new("."));
402 let files = quadlet_values(&content, "Build", "File");
403 let working_directory = quadlet_values(&content, "Build", "SetWorkingDirectory")
404 .into_iter()
405 .last()
406 .or_else(|| quadlet_values(&content, "Service", "WorkingDirectory").into_iter().last());
407
408 if files.is_empty() {
409 let Some(context) = quadlet_context(base, None, working_directory.as_deref()) else {
410 warnings.push(format!(
411 "Quadlet build unit '{}' has no local build context",
412 unit.display()
413 ));
414 return;
415 };
416 if let Some(dockerfile) = default_containerfile(&context) {
417 insert_referenced(inputs, dockerfile, context, "Quadlet build", unit, warnings);
418 } else {
419 warnings.push(format!(
420 "Quadlet build unit '{}' has no Containerfile or Dockerfile in '{}'",
421 unit.display(),
422 context.display()
423 ));
424 }
425 return;
426 }
427
428 for file in files {
429 if file == "-" {
430 warnings.push(format!(
431 "Quadlet build unit '{}' reads its Containerfile from stdin and cannot be discovered",
432 unit.display()
433 ));
434 continue;
435 }
436 let Some(dockerfile) = resolve_local_path(base, &file) else {
437 warnings.push(format!(
438 "Quadlet build unit '{}' references a non-local Containerfile '{file}'",
439 unit.display()
440 ));
441 continue;
442 };
443 let Some(context) = quadlet_context(base, Some(&dockerfile), working_directory.as_deref()) else {
444 warnings.push(format!(
445 "Quadlet build unit '{}' has a non-local build context",
446 unit.display()
447 ));
448 continue;
449 };
450 insert_referenced(inputs, dockerfile, context, "Quadlet build", unit, warnings);
451 }
452}
453
454fn discover_quadlet_kube(
455 unit: &Path,
456 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
457 warnings: &mut Vec<String>,
458) {
459 let content = match std::fs::read_to_string(unit) {
460 Ok(content) => content,
461 Err(error) => {
462 warnings.push(format!("cannot read Quadlet kube unit '{}': {error}", unit.display()));
463 return;
464 }
465 };
466 if !quadlet_has_section(&content, "Kube") {
467 return;
468 }
469 let base = unit.parent().unwrap_or_else(|| Path::new("."));
470 for yaml in quadlet_values(&content, "Kube", "Yaml") {
471 let Some(path) = resolve_local_path(base, &yaml) else {
472 warnings.push(format!(
473 "Quadlet kube unit '{}' references a non-local YAML source '{yaml}'",
474 unit.display()
475 ));
476 continue;
477 };
478 if path.is_file() {
479 discover_kube_play(&path, inputs, warnings);
480 } else {
481 warnings.push(format!(
482 "Quadlet kube unit '{}' references missing YAML '{}'",
483 unit.display(),
484 path.display()
485 ));
486 }
487 }
488}
489
490fn discover_kube_play(
491 yaml_file: &Path,
492 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
493 warnings: &mut Vec<String>,
494) {
495 let content = match std::fs::read_to_string(yaml_file) {
496 Ok(content) => content,
497 Err(error) => {
498 warnings.push(format!("cannot read Kubernetes YAML '{}': {error}", yaml_file.display()));
499 return;
500 }
501 };
502 let document: YamlValue = match serde_yaml::from_str(&content) {
503 Ok(document) => document,
504 Err(error) => {
505 warnings.push(format!("cannot parse Kubernetes YAML '{}': {error}", yaml_file.display()));
506 return;
507 }
508 };
509 let base = yaml_file.parent().unwrap_or_else(|| Path::new("."));
510 for image in kube_image_values(&document) {
511 let Some(image_dir) = local_kube_image_directory(&image) else {
512 continue;
513 };
514 let context = base.join(image_dir);
515 let Some(dockerfile) = default_containerfile(&context) else {
516 continue;
517 };
518 insert_referenced(inputs, dockerfile, context, "Podman kube play", yaml_file, warnings);
519 }
520}
521
522fn quadlet_values(content: &str, target_section: &str, target_key: &str) -> Vec<String> {
523 let mut section = "";
524 let mut values = Vec::new();
525 for raw_line in content.lines() {
526 let line = raw_line.trim();
527 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
528 continue;
529 }
530 if let Some(name) = line.strip_prefix('[').and_then(|name| name.strip_suffix(']')) {
531 section = name.trim();
532 continue;
533 }
534 let Some((key, value)) = line.split_once('=') else {
535 continue;
536 };
537 if section.eq_ignore_ascii_case(target_section) && key.trim().eq_ignore_ascii_case(target_key) {
538 values.push(value.trim().to_string());
539 }
540 }
541 values
542}
543
544fn quadlet_has_section(content: &str, target_section: &str) -> bool {
545 content.lines().any(|line| {
546 line.trim()
547 .strip_prefix('[')
548 .and_then(|name| name.strip_suffix(']'))
549 .is_some_and(|name| name.trim().eq_ignore_ascii_case(target_section))
550 })
551}
552
553fn quadlet_context(base: &Path, dockerfile: Option<&Path>, value: Option<&str>) -> Option<PathBuf> {
554 match value.unwrap_or("file") {
555 "file" => dockerfile.and_then(Path::parent).map(Path::to_path_buf),
556 "unit" => Some(base.to_path_buf()),
557 value => resolve_local_path(base, value),
558 }
559}
560
561fn default_containerfile(context: &Path) -> Option<PathBuf> {
562 ["Containerfile", "Dockerfile"]
563 .into_iter()
564 .map(|name| context.join(name))
565 .find(|path| path.is_file())
566}
567
568fn kube_image_values(document: &YamlValue) -> Vec<String> {
569 let mut images = Vec::new();
570 collect_kube_image_values(document, &mut images);
571 images
572}
573
574fn collect_kube_image_values(value: &YamlValue, images: &mut Vec<String>) {
575 match value {
576 YamlValue::Mapping(entries) => {
577 for (key, value) in entries {
578 if key.as_str() == Some("image") {
579 if let Some(image) = value.as_str() {
580 images.push(image.to_string());
581 }
582 }
583 collect_kube_image_values(value, images);
584 }
585 }
586 YamlValue::Sequence(values) => {
587 for value in values {
588 collect_kube_image_values(value, images);
589 }
590 }
591 _ => {}
592 }
593}
594
595fn local_kube_image_directory(image: &str) -> Option<&str> {
596 if image.is_empty()
597 || image.contains('/')
598 || image.contains(':')
599 || image.contains('@')
600 || image == "latest"
601 {
602 return None;
603 }
604 Some(image)
605}
606
607fn parse_bake_json(content: &str) -> Result<HashMap<String, BakeTarget>, serde_json::Error> {
608 let document: serde_json::Value = serde_json::from_str(content)?;
609 let mut result = HashMap::new();
610 let Some(targets) = document
611 .get("target")
612 .and_then(serde_json::Value::as_object)
613 else {
614 return Ok(result);
615 };
616 for (name, target) in targets {
617 let inherits = target.get("inherits").map(json_strings).unwrap_or_default();
618 result.insert(
619 name.clone(),
620 BakeTarget {
621 context: target
622 .get("context")
623 .and_then(serde_json::Value::as_str)
624 .map(str::to_string),
625 dockerfile: target
626 .get("dockerfile")
627 .and_then(serde_json::Value::as_str)
628 .map(str::to_string),
629 inherits,
630 inline: target.get("dockerfile-inline").is_some()
631 || target.get("dockerfile_inline").is_some(),
632 },
633 );
634 }
635 Ok(result)
636}
637
638fn json_strings(value: &serde_json::Value) -> Vec<String> {
639 match value {
640 serde_json::Value::String(value) => vec![value.clone()],
641 serde_json::Value::Array(values) => values
642 .iter()
643 .filter_map(serde_json::Value::as_str)
644 .map(str::to_string)
645 .collect(),
646 _ => Vec::new(),
647 }
648}
649
650fn parse_bake_hcl(content: &str) -> Result<HashMap<String, BakeTarget>, hcl::Error> {
651 let body: Body = hcl::parse(content)?;
652 let mut context = Context::new();
653 for block in body
654 .blocks()
655 .filter(|block| block.identifier() == "variable")
656 {
657 let Some(name) = block_label(block.labels.first()) else {
658 continue;
659 };
660 let value = std::env::var(name).ok().map(HclValue::from).or_else(|| {
661 hcl_attribute(block, "default")
662 .and_then(|expression| expression.evaluate(&context).ok())
663 });
664 if let Some(value) = value {
665 context.declare_var(name, value);
666 }
667 }
668
669 let mut result = HashMap::new();
670 for block in body.blocks().filter(|block| block.identifier() == "target") {
671 let Some(name) = block_label(block.labels.first()) else {
672 continue;
673 };
674 let string_attribute = |key| {
675 hcl_attribute(block, key)
676 .and_then(|expression| expression.evaluate(&context).ok())
677 .and_then(|value| value.as_str().map(str::to_string))
678 };
679 let inherits = hcl_attribute(block, "inherits")
680 .and_then(|expression| expression.evaluate(&context).ok())
681 .map(hcl_strings)
682 .unwrap_or_default();
683 result.insert(
684 name.to_string(),
685 BakeTarget {
686 context: string_attribute("context"),
687 dockerfile: string_attribute("dockerfile"),
688 inherits,
689 inline: hcl_attribute(block, "dockerfile-inline").is_some(),
690 },
691 );
692 }
693 Ok(result)
694}
695
696fn block_label(label: Option<&BlockLabel>) -> Option<&str> {
697 match label? {
698 BlockLabel::Identifier(value) => Some(value.as_str()),
699 BlockLabel::String(value) => Some(value),
700 }
701}
702
703fn hcl_attribute<'a>(block: &'a hcl::Block, key: &str) -> Option<&'a Expression> {
704 block
705 .body
706 .attributes()
707 .find(|attribute| attribute.key() == key)
708 .map(|attribute| attribute.expr())
709}
710
711fn hcl_strings(value: HclValue) -> Vec<String> {
712 match value {
713 HclValue::String(value) => vec![value],
714 HclValue::Array(values) => values
715 .into_iter()
716 .filter_map(|value| value.as_str().map(str::to_string))
717 .collect(),
718 _ => Vec::new(),
719 }
720}
721
722fn resolve_bake_target(
723 name: &str,
724 targets: &HashMap<String, BakeTarget>,
725 visiting: &mut HashSet<String>,
726) -> Option<BakeTarget> {
727 if !visiting.insert(name.to_string()) {
728 return None;
729 }
730 let target = targets.get(name)?;
731 let mut resolved = BakeTarget::default();
732 for parent in &target.inherits {
733 let inherited = resolve_bake_target(parent, targets, visiting)?;
734 merge_bake_target(&mut resolved, inherited);
735 }
736 merge_bake_target(&mut resolved, target.clone());
737 visiting.remove(name);
738 Some(resolved)
739}
740
741fn merge_bake_target(base: &mut BakeTarget, override_target: BakeTarget) {
742 if override_target.context.is_some() {
743 base.context = override_target.context;
744 }
745 if override_target.dockerfile.is_some() {
746 base.dockerfile = override_target.dockerfile;
747 base.inline = false;
748 }
749 if override_target.inline {
750 base.dockerfile = None;
751 base.inline = true;
752 }
753}
754
755fn insert_referenced(
756 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
757 dockerfile: PathBuf,
758 context: PathBuf,
759 kind: &str,
760 definition: &Path,
761 warnings: &mut Vec<String>,
762) {
763 if dockerfile.is_file() {
764 insert_input(inputs, dockerfile, absolute_path(&context));
765 } else {
766 warnings.push(format!(
767 "{kind} file '{}' references missing Dockerfile '{}'",
768 definition.display(),
769 dockerfile.display()
770 ));
771 }
772}
773
774fn insert_input(
775 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
776 dockerfile: PathBuf,
777 context: PathBuf,
778) {
779 let dockerfile_key = if dockerfile == Path::new("-") {
780 dockerfile.clone()
781 } else {
782 absolute_path(&dockerfile)
783 };
784 let context = absolute_path(&context);
785 let display_path = relative_to_current_directory(&dockerfile_key);
786 inputs
787 .entry((dockerfile_key, context.clone()))
788 .or_insert(BuildInput {
789 dockerfile: display_path,
790 context,
791 });
792}
793
794pub fn ignorefile_problem(
796 dockerfile: &Path,
797 context: &Path,
798) -> std::io::Result<Option<DockerignoreProblem>> {
799 ignorefile_problem_for_engine(dockerfile, context, ContainerEngine::Docker)
800}
801
802pub fn ignorefile_problem_for_engine(
804 dockerfile: &Path,
805 context: &Path,
806 engine: ContainerEngine,
807) -> std::io::Result<Option<DockerignoreProblem>> {
808 let effective = match effective_ignorefile(dockerfile, context, engine) {
809 Some(path) => path,
810 None => return Ok(Some(DockerignoreProblem::Missing { expected: expected_ignorefile(dockerfile, context, engine) })),
811 };
812 let content = std::fs::read_to_string(&effective)?;
813 if has_exclusion_pattern(&content) {
814 Ok(None)
815 } else {
816 Ok(Some(DockerignoreProblem::Empty { path: effective }))
817 }
818}
819
820fn expected_ignorefile(_dockerfile: &Path, context: &Path, engine: ContainerEngine) -> PathBuf {
821 match engine {
822 ContainerEngine::Docker => context.join(".dockerignore"),
823 ContainerEngine::Podman => context.join(".containerignore"),
824 }
825}
826
827fn effective_ignorefile(dockerfile: &Path, context: &Path, engine: ContainerEngine) -> Option<PathBuf> {
828 let specific = dockerfile.parent().unwrap_or_else(|| Path::new(".")).join(format!(
829 "{}.dockerignore",
830 dockerfile.file_name().unwrap_or_else(|| OsStr::new("Dockerfile")).to_string_lossy()
831 ));
832 let root = context.join(".dockerignore");
833 let container = context.join(".containerignore");
834 match engine {
835 ContainerEngine::Docker if specific.is_file() => Some(specific),
836 ContainerEngine::Docker if root.is_file() => Some(root),
837 ContainerEngine::Podman if container.is_file() => Some(container),
838 ContainerEngine::Podman if root.is_file() => Some(root),
839 _ => None,
840 }
841}
842
843pub fn ignored_copy_sources(
847 dockerfile: &Path,
848 context: &Path,
849 engine: ContainerEngine,
850) -> std::io::Result<Vec<(usize, String)>> {
851 let Some(ignorefile) = effective_ignorefile(dockerfile, context, engine) else { return Ok(Vec::new()); };
852 let mut builder = GitignoreBuilder::new(context);
853 builder.add(ignorefile);
854 let matcher = builder.build().map_err(std::io::Error::other)?;
855 let content = std::fs::read_to_string(dockerfile)?;
856 let document = crate::parser::parse_document(&content);
857 let mut ignored = Vec::new();
858 for instruction in document.instructions.iter().filter(|instruction| matches!(instruction.instruction.as_str(), "COPY" | "ADD")) {
859 let words = instruction.words.iter().filter(|word| !word.value.starts_with("--")).collect::<Vec<_>>();
860 let source_count = words.len().saturating_sub(1);
861 for source in words.into_iter().take(source_count) {
862 if source.value.contains("://") || source.value.contains('*') || source.value.contains('?') || source.value.starts_with('/') {
863 continue;
864 }
865 let candidate = context.join(&source.value);
866 if matcher.matched_path_or_any_parents(&candidate, false).is_ignore() {
867 ignored.push((instruction.line, source.value.clone()));
868 }
869 }
870 }
871 Ok(ignored)
872}
873
874pub fn ignores_common_copy_all_hazards(
878 dockerfile: &Path,
879 context: &Path,
880 engine: ContainerEngine,
881) -> std::io::Result<bool> {
882 let Some(ignorefile) = effective_ignorefile(dockerfile, context, engine) else {
883 return Ok(false);
884 };
885 let mut builder = GitignoreBuilder::new(context);
886 builder.add(ignorefile);
887 let matcher = builder.build().map_err(std::io::Error::other)?;
888 Ok([
889 (".git", true),
890 ("node_modules", true),
891 (".env", false),
892 ("dist", true),
893 ]
894 .into_iter()
895 .all(|(path, is_dir)| matcher.matched_path_or_any_parents(context.join(path), is_dir).is_ignore()))
896}
897
898fn has_exclusion_pattern(content: &str) -> bool {
899 content.lines().enumerate().any(|(index, line)| {
900 let line = if index == 0 {
901 line.trim_start_matches('\u{feff}')
902 } else {
903 line
904 };
905 if line.starts_with('#') {
906 return false;
907 }
908 let pattern = line.trim();
909 !pattern.is_empty() && pattern != "." && !pattern.starts_with('!')
910 })
911}
912
913fn is_dockerfile_name(path: &Path) -> bool {
914 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
915 return false;
916 };
917 if name.ends_with(".dockerignore") {
920 return false;
921 }
922 name == "Dockerfile"
923 || name == "Containerfile"
924 || name
925 .strip_prefix("Dockerfile.")
926 .is_some_and(|suffix| !suffix.is_empty())
927 || name
928 .strip_prefix("Containerfile.")
929 .is_some_and(|suffix| !suffix.is_empty())
930 || name
931 .strip_suffix(".Dockerfile")
932 .is_some_and(|prefix| !prefix.is_empty())
933}
934
935fn is_compose_file(path: &Path) -> bool {
936 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
937 return false;
938 };
939 (name == "compose.yaml"
940 || name == "compose.yml"
941 || name == "docker-compose.yaml"
942 || name == "docker-compose.yml")
943 || ((name.starts_with("compose.") || name.starts_with("docker-compose."))
944 && (name.ends_with(".yaml") || name.ends_with(".yml")))
945}
946
947fn is_bake_file(path: &Path) -> bool {
948 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
949 return false;
950 };
951 name.starts_with("docker-bake") && (name.ends_with(".hcl") || name.ends_with(".json"))
952}
953
954fn is_quadlet_build_file(path: &Path) -> bool {
955 path.extension() == Some(OsStr::new("build"))
956}
957
958fn is_quadlet_kube_file(path: &Path) -> bool {
959 path.extension() == Some(OsStr::new("kube"))
960}
961
962fn is_kube_play_file(path: &Path) -> bool {
963 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
964 return false;
965 };
966 name == "kube.yaml"
967 || name == "kube.yml"
968 || name.ends_with(".kube.yaml")
969 || name.ends_with(".kube.yml")
970}
971
972fn contains_glob(path: &str) -> bool {
973 path.contains('*') || path.contains('?') || path.contains('[')
974}
975
976fn compose_environment(project_dir: &Path) -> HashMap<String, String> {
977 let mut environment = HashMap::new();
978 let dotenv = project_dir.join(".env");
979 if dotenv.is_file() {
980 if let Ok(entries) = dotenvy::from_path_iter(dotenv) {
981 environment.extend(entries.flatten());
982 }
983 }
984 environment.extend(std::env::vars());
985 environment
986}
987
988fn interpolate_path(value: &str, environment: &HashMap<String, String>) -> Option<String> {
989 let mut result = String::new();
990 let chars = value.as_bytes();
991 let mut index = 0usize;
992 while index < chars.len() {
993 if chars[index] != b'$' {
994 let character = value[index..].chars().next()?;
995 result.push(character);
996 index += character.len_utf8();
997 continue;
998 }
999 if chars.get(index + 1) == Some(&b'$') {
1000 result.push('$');
1001 index += 2;
1002 continue;
1003 }
1004 if chars.get(index + 1) == Some(&b'{') {
1005 let end = value[index + 2..].find('}')? + index + 2;
1006 let expression = &value[index + 2..end];
1007 let name_end = expression
1008 .find(|character: char| !character.is_ascii_alphanumeric() && character != '_')
1009 .unwrap_or(expression.len());
1010 let name = &expression[..name_end];
1011 if name.is_empty() {
1012 return None;
1013 }
1014 let operator = &expression[name_end..];
1015 let current = environment.get(name);
1016 let replacement = if let Some(default) = operator.strip_prefix(":-") {
1017 current
1018 .filter(|value| !value.is_empty())
1019 .map(String::as_str)
1020 .unwrap_or(default)
1021 } else if let Some(default) = operator.strip_prefix('-') {
1022 current.map(String::as_str).unwrap_or(default)
1023 } else if let Some(alternative) = operator.strip_prefix(":+") {
1024 if current.is_some_and(|value| !value.is_empty()) {
1025 alternative
1026 } else {
1027 ""
1028 }
1029 } else if let Some(alternative) = operator.strip_prefix('+') {
1030 if current.is_some() {
1031 alternative
1032 } else {
1033 ""
1034 }
1035 } else if operator.starts_with(":?") {
1036 current
1037 .filter(|value| !value.is_empty())
1038 .map(String::as_str)?
1039 } else if operator.starts_with('?') || operator.is_empty() {
1040 current.map(String::as_str)?
1041 } else {
1042 return None;
1043 };
1044 result.push_str(replacement);
1045 index = end + 1;
1046 continue;
1047 }
1048 let start = index + 1;
1049 let mut end = start;
1050 while chars
1051 .get(end)
1052 .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
1053 {
1054 end += 1;
1055 }
1056 if end == start {
1057 return None;
1058 }
1059 result.push_str(environment.get(&value[start..end])?);
1060 index = end;
1061 }
1062 Some(expand_home(result))
1063}
1064
1065fn expand_home(value: String) -> String {
1066 if value == "~" || value.starts_with("~/") {
1067 if let Some(home) = std::env::var_os("HOME") {
1068 return PathBuf::from(home)
1069 .join(value.strip_prefix("~/").unwrap_or(""))
1070 .to_string_lossy()
1071 .into_owned();
1072 }
1073 }
1074 value
1075}
1076
1077fn resolve_local_path(base: &Path, value: &str) -> Option<PathBuf> {
1078 if value.contains("://") || value.starts_with("git@") || value.starts_with("target:") {
1079 return None;
1080 }
1081 Some(resolve_path(base, value))
1082}
1083
1084fn resolve_path(base: &Path, value: &str) -> PathBuf {
1085 let path = Path::new(value);
1086 if path.is_absolute() {
1087 path.to_path_buf()
1088 } else {
1089 base.join(path)
1090 }
1091}
1092
1093fn absolute_path(path: &Path) -> PathBuf {
1094 path.canonicalize().unwrap_or_else(|_| {
1095 if path.is_absolute() {
1096 path.to_path_buf()
1097 } else {
1098 current_directory().join(path)
1099 }
1100 })
1101}
1102
1103fn current_directory() -> PathBuf {
1104 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1105}
1106
1107fn relative_to_current_directory(path: &Path) -> PathBuf {
1108 path.strip_prefix(current_directory())
1109 .ok()
1110 .filter(|relative| !relative.as_os_str().is_empty())
1111 .map(Path::to_path_buf)
1112 .unwrap_or_else(|| path.to_path_buf())
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117 use super::{has_exclusion_pattern, interpolate_path, is_dockerfile_name};
1118 use std::collections::HashMap;
1119 use std::path::Path;
1120
1121 #[test]
1122 fn dockerfile_name_patterns_are_exact() {
1123 for name in [
1124 "Dockerfile",
1125 "Dockerfile.dev",
1126 "web.Dockerfile",
1127 "Containerfile",
1128 "Containerfile.release",
1129 ] {
1130 assert!(is_dockerfile_name(Path::new(name)), "{name}");
1131 }
1132 for name in [
1133 "dockerfile",
1134 "Dockerfile.",
1135 ".Dockerfile",
1136 "myContainerfile",
1137 "Dockerfile.dockerignore",
1138 "Containerfile.release.dockerignore",
1139 ] {
1140 assert!(!is_dockerfile_name(Path::new(name)), "{name}");
1141 }
1142 }
1143
1144 #[test]
1145 fn dockerignore_needs_a_positive_pattern() {
1146 assert!(!has_exclusion_pattern("# comment\n\n!README.md\n.\n"));
1147 assert!(has_exclusion_pattern("# comment\nnode_modules\n"));
1148 }
1149
1150 #[test]
1151 fn compose_path_defaults_are_interpolated() {
1152 let mut environment = HashMap::new();
1153 environment.insert("EMPTY".to_string(), String::new());
1154 assert_eq!(
1155 interpolate_path("${MISSING:-contexts/api}", &environment),
1156 Some("contexts/api".into())
1157 );
1158 assert_eq!(
1159 interpolate_path("${EMPTY-default}", &environment),
1160 Some(String::new())
1161 );
1162 assert_eq!(
1163 interpolate_path("${EMPTY:-default}", &environment),
1164 Some("default".into())
1165 );
1166 assert_eq!(
1167 interpolate_path("cost-$$5", &environment),
1168 Some("cost-$5".into())
1169 );
1170 }
1171}