Skip to main content

dockerfile_roast/
repository.rs

1//! Repository-aware Dockerfile and build-context discovery.
2
3use 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/// Selects build-context conventions without invoking a container engine.
20#[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
48/// Resolve CLI paths into local Dockerfiles and their effective build contexts.
49pub 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    // Build definitions carry stronger context information than filename-only
165    // discovery, so process them first. Pair-based deduplication preserves the
166    // legitimate case where one Dockerfile is built from multiple contexts.
167    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
794/// Return the missing or ineffective ignore file for a build input.
795pub 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
802/// Return the missing or ineffective ignore file selected by the chosen engine.
803pub 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
843/// Return local COPY/ADD sources that are excluded by the effective ignore file.
844/// This uses gitignore-compatible matching for Docker's documented pattern subset;
845/// unusual Docker-only pattern behavior remains deliberately conservative.
846pub 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
874/// Whether the effective ignore file excludes the common broad-copy hazards
875/// `.git`, `node_modules`, `.env`, and `dist`. This is deliberately a narrow
876/// signal for diagnostic wording, not proof that `COPY .` is optimal.
877pub 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    // Dockerfile-specific ignore files share the Dockerfile name prefix, but
918    // are build-context metadata rather than Dockerfiles.
919    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}