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 serde_yaml::Value as YamlValue;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct BuildInput {
14 pub dockerfile: PathBuf,
15 pub context: PathBuf,
16}
17
18#[derive(Debug, Default)]
19pub struct Discovery {
20 pub inputs: Vec<BuildInput>,
21 pub warnings: Vec<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum DockerignoreProblem {
26 Missing { expected: PathBuf },
27 Empty { path: PathBuf },
28}
29
30pub fn discover(requested: &[PathBuf]) -> Discovery {
32 let requested = if requested.is_empty() {
33 vec![PathBuf::from(".")]
34 } else {
35 requested.to_vec()
36 };
37 let mut discovery = Discovery::default();
38 let mut inputs = BTreeMap::<(PathBuf, PathBuf), BuildInput>::new();
39
40 for requested_path in requested {
41 let pattern = requested_path.to_string_lossy();
42 if contains_glob(&pattern) {
43 match glob::glob(&pattern) {
44 Ok(matches) => {
45 for path in matches.flatten() {
46 discover_path(&path, &mut inputs, &mut discovery.warnings);
47 }
48 }
49 Err(error) => discovery
50 .warnings
51 .push(format!("invalid path pattern {pattern:?}: {error}")),
52 }
53 } else {
54 discover_path(&requested_path, &mut inputs, &mut discovery.warnings);
55 }
56 }
57
58 discovery.inputs = inputs.into_values().collect();
59 discovery
60}
61
62fn discover_path(
63 path: &Path,
64 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
65 warnings: &mut Vec<String>,
66) {
67 if path == Path::new("-") {
68 insert_input(inputs, path.to_path_buf(), current_directory());
69 } else if path.is_dir() {
70 discover_directory(path, inputs, warnings);
71 } else if path.is_file() && is_compose_file(path) {
72 discover_compose(path, inputs, warnings);
73 } else if path.is_file() && is_bake_file(path) {
74 discover_bake(path, inputs, warnings);
75 } else {
76 let context = path.parent().unwrap_or_else(|| Path::new("."));
77 insert_input(inputs, path.to_path_buf(), absolute_path(context));
78 }
79}
80
81fn discover_directory(
82 root: &Path,
83 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
84 warnings: &mut Vec<String>,
85) {
86 let mut dockerfiles = Vec::new();
87 let mut compose_files = Vec::new();
88 let mut bake_files = Vec::new();
89
90 let mut walker = WalkBuilder::new(root);
91 walker
92 .follow_links(false)
93 .hidden(false)
94 .ignore(true)
95 .git_ignore(true)
96 .git_exclude(true)
97 .parents(true);
98 for entry in walker.build() {
99 let entry = match entry {
100 Ok(entry) => entry,
101 Err(error) => {
102 warnings.push(format!("cannot inspect repository entry: {error}"));
103 continue;
104 }
105 };
106 if !entry
107 .file_type()
108 .is_some_and(|file_type| file_type.is_file())
109 {
110 continue;
111 }
112 let path = entry.into_path();
113 if is_dockerfile_name(&path) {
114 dockerfiles.push(path);
115 } else if is_compose_file(&path) {
116 compose_files.push(path);
117 } else if is_bake_file(&path) {
118 bake_files.push(path);
119 }
120 }
121
122 compose_files.sort();
123 bake_files.sort();
124 dockerfiles.sort();
125
126 for path in compose_files {
130 discover_compose(&path, inputs, warnings);
131 }
132 for path in bake_files {
133 discover_bake(&path, inputs, warnings);
134 }
135 for dockerfile in dockerfiles {
136 if contains_dockerfile(inputs, &dockerfile) {
137 continue;
138 }
139 let context = infer_context(&dockerfile, root);
140 insert_input(inputs, dockerfile, context);
141 }
142}
143
144fn contains_dockerfile(
145 inputs: &BTreeMap<(PathBuf, PathBuf), BuildInput>,
146 dockerfile: &Path,
147) -> bool {
148 let dockerfile = absolute_path(dockerfile);
149 inputs.keys().any(|(known, _)| known == &dockerfile)
150}
151
152fn infer_context(dockerfile: &Path, repository_root: &Path) -> PathBuf {
153 let root = absolute_path(repository_root);
154 let mut directory = dockerfile
155 .parent()
156 .map(absolute_path)
157 .unwrap_or_else(|| root.clone());
158 loop {
159 if directory.join(".dockerignore").is_file() {
160 return directory;
161 }
162 if directory == root || !directory.starts_with(&root) {
163 return root;
164 }
165 let Some(parent) = directory.parent() else {
166 return root;
167 };
168 directory = parent.to_path_buf();
169 }
170}
171
172fn discover_compose(
173 compose_file: &Path,
174 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
175 warnings: &mut Vec<String>,
176) {
177 let content = match std::fs::read_to_string(compose_file) {
178 Ok(content) => content,
179 Err(error) => {
180 warnings.push(format!(
181 "cannot read Compose file '{}': {error}",
182 compose_file.display()
183 ));
184 return;
185 }
186 };
187 let document: YamlValue = match serde_yaml::from_str(&content) {
188 Ok(document) => document,
189 Err(error) => {
190 warnings.push(format!(
191 "cannot parse Compose file '{}': {error}",
192 compose_file.display()
193 ));
194 return;
195 }
196 };
197 let Some(services) = yaml_mapping_value(&document, "services").and_then(YamlValue::as_mapping)
198 else {
199 return;
200 };
201 let project_dir = compose_file.parent().unwrap_or_else(|| Path::new("."));
202 let environment = compose_environment(project_dir);
203
204 for (service_name, service) in services {
205 let Some(build) = yaml_mapping_value(service, "build") else {
206 continue;
207 };
208 let name = service_name.as_str().unwrap_or("<unnamed>");
209 let (context_value, dockerfile_value) = match build {
210 YamlValue::String(context) => (Some(context.as_str()), Some("Dockerfile")),
211 YamlValue::Mapping(_) => {
212 if yaml_mapping_value(build, "dockerfile_inline").is_some() {
213 continue;
214 }
215 (
216 yaml_mapping_value(build, "context").and_then(YamlValue::as_str),
217 yaml_mapping_value(build, "dockerfile")
218 .and_then(YamlValue::as_str)
219 .or(Some("Dockerfile")),
220 )
221 }
222 _ => continue,
223 };
224 let Some(context_value) = interpolate_path(context_value.unwrap_or("."), &environment)
225 else {
226 warnings.push(format!(
227 "Compose service {name:?} in '{}' has an unresolved build context",
228 compose_file.display()
229 ));
230 continue;
231 };
232 let Some(dockerfile_value) =
233 dockerfile_value.and_then(|value| interpolate_path(value, &environment))
234 else {
235 warnings.push(format!(
236 "Compose service {name:?} in '{}' has an unresolved Dockerfile path",
237 compose_file.display()
238 ));
239 continue;
240 };
241 let Some(context) = resolve_local_path(project_dir, &context_value) else {
242 continue;
243 };
244 let dockerfile = resolve_path(&context, &dockerfile_value);
245 insert_referenced(
246 inputs,
247 dockerfile,
248 context,
249 "Compose",
250 compose_file,
251 warnings,
252 );
253 }
254}
255
256fn yaml_mapping_value<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlValue> {
257 value.as_mapping()?.get(YamlValue::String(key.to_string()))
258}
259
260#[derive(Debug, Default, Clone)]
261struct BakeTarget {
262 context: Option<String>,
263 dockerfile: Option<String>,
264 inherits: Vec<String>,
265 inline: bool,
266}
267
268fn discover_bake(
269 bake_file: &Path,
270 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
271 warnings: &mut Vec<String>,
272) {
273 let content = match std::fs::read_to_string(bake_file) {
274 Ok(content) => content,
275 Err(error) => {
276 warnings.push(format!(
277 "cannot read Bake file '{}': {error}",
278 bake_file.display()
279 ));
280 return;
281 }
282 };
283 let targets = if bake_file.extension() == Some(OsStr::new("json")) {
284 match parse_bake_json(&content) {
285 Ok(targets) => targets,
286 Err(error) => {
287 warnings.push(format!(
288 "cannot parse Bake file '{}': {error}",
289 bake_file.display()
290 ));
291 return;
292 }
293 }
294 } else {
295 match parse_bake_hcl(&content) {
296 Ok(targets) => targets,
297 Err(error) => {
298 warnings.push(format!(
299 "cannot parse Bake file '{}': {error}",
300 bake_file.display()
301 ));
302 return;
303 }
304 }
305 };
306 let base = bake_file.parent().unwrap_or_else(|| Path::new("."));
307
308 for name in targets.keys() {
309 let mut visiting = HashSet::new();
310 let Some(target) = resolve_bake_target(name, &targets, &mut visiting) else {
311 warnings.push(format!(
312 "Bake target {name:?} in '{}' has cyclic or missing inheritance",
313 bake_file.display()
314 ));
315 continue;
316 };
317 if target.inline {
318 continue;
319 }
320 let context_value = target.context.as_deref().unwrap_or(".");
321 let dockerfile_value = target.dockerfile.as_deref().unwrap_or("Dockerfile");
322 let Some(context) = resolve_local_path(base, context_value) else {
323 continue;
324 };
325 let dockerfile = resolve_path(&context, dockerfile_value);
326 insert_referenced(inputs, dockerfile, context, "Bake", bake_file, warnings);
327 }
328}
329
330fn parse_bake_json(content: &str) -> Result<HashMap<String, BakeTarget>, serde_json::Error> {
331 let document: serde_json::Value = serde_json::from_str(content)?;
332 let mut result = HashMap::new();
333 let Some(targets) = document
334 .get("target")
335 .and_then(serde_json::Value::as_object)
336 else {
337 return Ok(result);
338 };
339 for (name, target) in targets {
340 let inherits = target.get("inherits").map(json_strings).unwrap_or_default();
341 result.insert(
342 name.clone(),
343 BakeTarget {
344 context: target
345 .get("context")
346 .and_then(serde_json::Value::as_str)
347 .map(str::to_string),
348 dockerfile: target
349 .get("dockerfile")
350 .and_then(serde_json::Value::as_str)
351 .map(str::to_string),
352 inherits,
353 inline: target.get("dockerfile-inline").is_some()
354 || target.get("dockerfile_inline").is_some(),
355 },
356 );
357 }
358 Ok(result)
359}
360
361fn json_strings(value: &serde_json::Value) -> Vec<String> {
362 match value {
363 serde_json::Value::String(value) => vec![value.clone()],
364 serde_json::Value::Array(values) => values
365 .iter()
366 .filter_map(serde_json::Value::as_str)
367 .map(str::to_string)
368 .collect(),
369 _ => Vec::new(),
370 }
371}
372
373fn parse_bake_hcl(content: &str) -> Result<HashMap<String, BakeTarget>, hcl::Error> {
374 let body: Body = hcl::parse(content)?;
375 let mut context = Context::new();
376 for block in body
377 .blocks()
378 .filter(|block| block.identifier() == "variable")
379 {
380 let Some(name) = block_label(block.labels.first()) else {
381 continue;
382 };
383 let value = std::env::var(name).ok().map(HclValue::from).or_else(|| {
384 hcl_attribute(block, "default")
385 .and_then(|expression| expression.evaluate(&context).ok())
386 });
387 if let Some(value) = value {
388 context.declare_var(name, value);
389 }
390 }
391
392 let mut result = HashMap::new();
393 for block in body.blocks().filter(|block| block.identifier() == "target") {
394 let Some(name) = block_label(block.labels.first()) else {
395 continue;
396 };
397 let string_attribute = |key| {
398 hcl_attribute(block, key)
399 .and_then(|expression| expression.evaluate(&context).ok())
400 .and_then(|value| value.as_str().map(str::to_string))
401 };
402 let inherits = hcl_attribute(block, "inherits")
403 .and_then(|expression| expression.evaluate(&context).ok())
404 .map(hcl_strings)
405 .unwrap_or_default();
406 result.insert(
407 name.to_string(),
408 BakeTarget {
409 context: string_attribute("context"),
410 dockerfile: string_attribute("dockerfile"),
411 inherits,
412 inline: hcl_attribute(block, "dockerfile-inline").is_some(),
413 },
414 );
415 }
416 Ok(result)
417}
418
419fn block_label(label: Option<&BlockLabel>) -> Option<&str> {
420 match label? {
421 BlockLabel::Identifier(value) => Some(value.as_str()),
422 BlockLabel::String(value) => Some(value),
423 }
424}
425
426fn hcl_attribute<'a>(block: &'a hcl::Block, key: &str) -> Option<&'a Expression> {
427 block
428 .body
429 .attributes()
430 .find(|attribute| attribute.key() == key)
431 .map(|attribute| attribute.expr())
432}
433
434fn hcl_strings(value: HclValue) -> Vec<String> {
435 match value {
436 HclValue::String(value) => vec![value],
437 HclValue::Array(values) => values
438 .into_iter()
439 .filter_map(|value| value.as_str().map(str::to_string))
440 .collect(),
441 _ => Vec::new(),
442 }
443}
444
445fn resolve_bake_target(
446 name: &str,
447 targets: &HashMap<String, BakeTarget>,
448 visiting: &mut HashSet<String>,
449) -> Option<BakeTarget> {
450 if !visiting.insert(name.to_string()) {
451 return None;
452 }
453 let target = targets.get(name)?;
454 let mut resolved = BakeTarget::default();
455 for parent in &target.inherits {
456 let inherited = resolve_bake_target(parent, targets, visiting)?;
457 merge_bake_target(&mut resolved, inherited);
458 }
459 merge_bake_target(&mut resolved, target.clone());
460 visiting.remove(name);
461 Some(resolved)
462}
463
464fn merge_bake_target(base: &mut BakeTarget, override_target: BakeTarget) {
465 if override_target.context.is_some() {
466 base.context = override_target.context;
467 }
468 if override_target.dockerfile.is_some() {
469 base.dockerfile = override_target.dockerfile;
470 base.inline = false;
471 }
472 if override_target.inline {
473 base.dockerfile = None;
474 base.inline = true;
475 }
476}
477
478fn insert_referenced(
479 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
480 dockerfile: PathBuf,
481 context: PathBuf,
482 kind: &str,
483 definition: &Path,
484 warnings: &mut Vec<String>,
485) {
486 if dockerfile.is_file() {
487 insert_input(inputs, dockerfile, absolute_path(&context));
488 } else {
489 warnings.push(format!(
490 "{kind} file '{}' references missing Dockerfile '{}'",
491 definition.display(),
492 dockerfile.display()
493 ));
494 }
495}
496
497fn insert_input(
498 inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
499 dockerfile: PathBuf,
500 context: PathBuf,
501) {
502 let dockerfile_key = if dockerfile == Path::new("-") {
503 dockerfile.clone()
504 } else {
505 absolute_path(&dockerfile)
506 };
507 let context = absolute_path(&context);
508 let display_path = relative_to_current_directory(&dockerfile_key);
509 inputs
510 .entry((dockerfile_key, context.clone()))
511 .or_insert(BuildInput {
512 dockerfile: display_path,
513 context,
514 });
515}
516
517pub fn dockerignore_problem(
519 dockerfile: &Path,
520 context: &Path,
521) -> std::io::Result<Option<DockerignoreProblem>> {
522 let specific = dockerfile
523 .parent()
524 .unwrap_or_else(|| Path::new("."))
525 .join(format!(
526 "{}.dockerignore",
527 dockerfile
528 .file_name()
529 .unwrap_or_else(|| OsStr::new("Dockerfile"))
530 .to_string_lossy()
531 ));
532 let root = context.join(".dockerignore");
533 let effective = if specific.is_file() {
534 specific
535 } else if root.is_file() {
536 root
537 } else {
538 return Ok(Some(DockerignoreProblem::Missing { expected: root }));
539 };
540 let content = std::fs::read_to_string(&effective)?;
541 if has_exclusion_pattern(&content) {
542 Ok(None)
543 } else {
544 Ok(Some(DockerignoreProblem::Empty { path: effective }))
545 }
546}
547
548fn has_exclusion_pattern(content: &str) -> bool {
549 content.lines().enumerate().any(|(index, line)| {
550 let line = if index == 0 {
551 line.trim_start_matches('\u{feff}')
552 } else {
553 line
554 };
555 if line.starts_with('#') {
556 return false;
557 }
558 let pattern = line.trim();
559 !pattern.is_empty() && pattern != "." && !pattern.starts_with('!')
560 })
561}
562
563fn is_dockerfile_name(path: &Path) -> bool {
564 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
565 return false;
566 };
567 name == "Dockerfile"
568 || name == "Containerfile"
569 || name
570 .strip_prefix("Dockerfile.")
571 .is_some_and(|suffix| !suffix.is_empty())
572 || name
573 .strip_prefix("Containerfile.")
574 .is_some_and(|suffix| !suffix.is_empty())
575 || name
576 .strip_suffix(".Dockerfile")
577 .is_some_and(|prefix| !prefix.is_empty())
578}
579
580fn is_compose_file(path: &Path) -> bool {
581 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
582 return false;
583 };
584 (name == "compose.yaml"
585 || name == "compose.yml"
586 || name == "docker-compose.yaml"
587 || name == "docker-compose.yml")
588 || ((name.starts_with("compose.") || name.starts_with("docker-compose."))
589 && (name.ends_with(".yaml") || name.ends_with(".yml")))
590}
591
592fn is_bake_file(path: &Path) -> bool {
593 let Some(name) = path.file_name().and_then(OsStr::to_str) else {
594 return false;
595 };
596 name.starts_with("docker-bake") && (name.ends_with(".hcl") || name.ends_with(".json"))
597}
598
599fn contains_glob(path: &str) -> bool {
600 path.contains('*') || path.contains('?') || path.contains('[')
601}
602
603fn compose_environment(project_dir: &Path) -> HashMap<String, String> {
604 let mut environment = HashMap::new();
605 let dotenv = project_dir.join(".env");
606 if dotenv.is_file() {
607 if let Ok(entries) = dotenvy::from_path_iter(dotenv) {
608 environment.extend(entries.flatten());
609 }
610 }
611 environment.extend(std::env::vars());
612 environment
613}
614
615fn interpolate_path(value: &str, environment: &HashMap<String, String>) -> Option<String> {
616 let mut result = String::new();
617 let chars = value.as_bytes();
618 let mut index = 0usize;
619 while index < chars.len() {
620 if chars[index] != b'$' {
621 let character = value[index..].chars().next()?;
622 result.push(character);
623 index += character.len_utf8();
624 continue;
625 }
626 if chars.get(index + 1) == Some(&b'$') {
627 result.push('$');
628 index += 2;
629 continue;
630 }
631 if chars.get(index + 1) == Some(&b'{') {
632 let end = value[index + 2..].find('}')? + index + 2;
633 let expression = &value[index + 2..end];
634 let name_end = expression
635 .find(|character: char| !character.is_ascii_alphanumeric() && character != '_')
636 .unwrap_or(expression.len());
637 let name = &expression[..name_end];
638 if name.is_empty() {
639 return None;
640 }
641 let operator = &expression[name_end..];
642 let current = environment.get(name);
643 let replacement = if let Some(default) = operator.strip_prefix(":-") {
644 current
645 .filter(|value| !value.is_empty())
646 .map(String::as_str)
647 .unwrap_or(default)
648 } else if let Some(default) = operator.strip_prefix('-') {
649 current.map(String::as_str).unwrap_or(default)
650 } else if let Some(alternative) = operator.strip_prefix(":+") {
651 if current.is_some_and(|value| !value.is_empty()) {
652 alternative
653 } else {
654 ""
655 }
656 } else if let Some(alternative) = operator.strip_prefix('+') {
657 if current.is_some() {
658 alternative
659 } else {
660 ""
661 }
662 } else if operator.starts_with(":?") {
663 current
664 .filter(|value| !value.is_empty())
665 .map(String::as_str)?
666 } else if operator.starts_with('?') || operator.is_empty() {
667 current.map(String::as_str)?
668 } else {
669 return None;
670 };
671 result.push_str(replacement);
672 index = end + 1;
673 continue;
674 }
675 let start = index + 1;
676 let mut end = start;
677 while chars
678 .get(end)
679 .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
680 {
681 end += 1;
682 }
683 if end == start {
684 return None;
685 }
686 result.push_str(environment.get(&value[start..end])?);
687 index = end;
688 }
689 Some(expand_home(result))
690}
691
692fn expand_home(value: String) -> String {
693 if value == "~" || value.starts_with("~/") {
694 if let Some(home) = std::env::var_os("HOME") {
695 return PathBuf::from(home)
696 .join(value.strip_prefix("~/").unwrap_or(""))
697 .to_string_lossy()
698 .into_owned();
699 }
700 }
701 value
702}
703
704fn resolve_local_path(base: &Path, value: &str) -> Option<PathBuf> {
705 if value.contains("://") || value.starts_with("git@") || value.starts_with("target:") {
706 return None;
707 }
708 Some(resolve_path(base, value))
709}
710
711fn resolve_path(base: &Path, value: &str) -> PathBuf {
712 let path = Path::new(value);
713 if path.is_absolute() {
714 path.to_path_buf()
715 } else {
716 base.join(path)
717 }
718}
719
720fn absolute_path(path: &Path) -> PathBuf {
721 path.canonicalize().unwrap_or_else(|_| {
722 if path.is_absolute() {
723 path.to_path_buf()
724 } else {
725 current_directory().join(path)
726 }
727 })
728}
729
730fn current_directory() -> PathBuf {
731 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
732}
733
734fn relative_to_current_directory(path: &Path) -> PathBuf {
735 path.strip_prefix(current_directory())
736 .ok()
737 .filter(|relative| !relative.as_os_str().is_empty())
738 .map(Path::to_path_buf)
739 .unwrap_or_else(|| path.to_path_buf())
740}
741
742#[cfg(test)]
743mod tests {
744 use super::{has_exclusion_pattern, interpolate_path, is_dockerfile_name};
745 use std::collections::HashMap;
746 use std::path::Path;
747
748 #[test]
749 fn dockerfile_name_patterns_are_exact() {
750 for name in [
751 "Dockerfile",
752 "Dockerfile.dev",
753 "web.Dockerfile",
754 "Containerfile",
755 "Containerfile.release",
756 ] {
757 assert!(is_dockerfile_name(Path::new(name)), "{name}");
758 }
759 for name in [
760 "dockerfile",
761 "Dockerfile.",
762 ".Dockerfile",
763 "myContainerfile",
764 ] {
765 assert!(!is_dockerfile_name(Path::new(name)), "{name}");
766 }
767 }
768
769 #[test]
770 fn dockerignore_needs_a_positive_pattern() {
771 assert!(!has_exclusion_pattern("# comment\n\n!README.md\n.\n"));
772 assert!(has_exclusion_pattern("# comment\nnode_modules\n"));
773 }
774
775 #[test]
776 fn compose_path_defaults_are_interpolated() {
777 let mut environment = HashMap::new();
778 environment.insert("EMPTY".to_string(), String::new());
779 assert_eq!(
780 interpolate_path("${MISSING:-contexts/api}", &environment),
781 Some("contexts/api".into())
782 );
783 assert_eq!(
784 interpolate_path("${EMPTY-default}", &environment),
785 Some(String::new())
786 );
787 assert_eq!(
788 interpolate_path("${EMPTY:-default}", &environment),
789 Some("default".into())
790 );
791 assert_eq!(
792 interpolate_path("cost-$$5", &environment),
793 Some("cost-$5".into())
794 );
795 }
796}