Skip to main content

zlayer_builder/dockerfile/
render.rs

1//! Dockerfile IR → text rendering and shared variable expansion.
2//!
3//! This module provides two backend-agnostic operations over the parsed
4//! [`Dockerfile`] IR:
5//!
6//! - [`expand_dockerfile`] performs Docker build-time variable substitution
7//!   across an entire `Dockerfile`, returning a fully-substituted clone of the
8//!   IR. It mirrors the per-instruction expansion the buildah CLI backend does
9//!   inline (`backend::buildah::expand_instruction`) but operates on the whole
10//!   document, maintaining the accumulating ARG/ENV binding map per stage with
11//!   Docker semantics (per-stage ARG scope, pre-FROM global ARG defaults).
12//! - [`render_dockerfile`] serializes an (already-substituted) `Dockerfile` IR
13//!   back to canonical Dockerfile text.
14//!
15//! These are additive building blocks for unifying the build backends: a single
16//! shared expansion + render pass that every backend can reuse instead of each
17//! re-deriving the substitution rules.
18
19use std::collections::HashMap;
20use std::fmt::Write as _;
21
22use super::instruction::{
23    AddInstruction, CopyInstruction, EnvInstruction, ExposeProtocol, HealthcheckInstruction,
24    Instruction, RunInstruction, RunMount, RunNetwork, RunSecurity, ShellOrExec,
25};
26use super::parser::{Dockerfile, Stage};
27use super::variable::expand_variables;
28
29/// Escape a string for embedding inside a JSON string literal (exec-form arrays).
30///
31/// Shared by the Dockerfile text renderer and the buildah command translator so
32/// both produce byte-identical JSON escaping. Handles backslash, double-quote,
33/// and the common control characters (`\n`, `\r`, `\t`).
34#[must_use]
35pub(crate) fn escape_json_string(s: &str) -> String {
36    s.replace('\\', "\\\\")
37        .replace('"', "\\\"")
38        .replace('\n', "\\n")
39        .replace('\r', "\\r")
40        .replace('\t', "\\t")
41}
42
43/// Quote a shell token if it contains whitespace or characters that the shell
44/// would otherwise interpret. Returns the token unchanged when no quoting is
45/// required. Used for inlining per-step `RUN` env assignments and degraded
46/// exec-form args into a shell-form `RUN` line.
47fn shell_quote(s: &str) -> String {
48    if s.is_empty() {
49        return "\"\"".to_string();
50    }
51    let needs_quote = s.chars().any(|c| {
52        c.is_whitespace()
53            || matches!(
54                c,
55                '"' | '\''
56                    | '\\'
57                    | '$'
58                    | '`'
59                    | '&'
60                    | '|'
61                    | ';'
62                    | '<'
63                    | '>'
64                    | '('
65                    | ')'
66                    | '*'
67                    | '?'
68                    | '['
69                    | ']'
70                    | '{'
71                    | '}'
72                    | '#'
73                    | '~'
74                    | '!'
75            )
76    });
77    if !needs_quote {
78        return s.to_string();
79    }
80    // Double-quote and escape the characters that remain special inside double
81    // quotes: backslash, double-quote, dollar, and backtick.
82    let mut out = String::with_capacity(s.len() + 2);
83    out.push('"');
84    for c in s.chars() {
85        match c {
86            '"' | '\\' | '$' | '`' => {
87                out.push('\\');
88                out.push(c);
89            }
90            _ => out.push(c),
91        }
92    }
93    out.push('"');
94    out
95}
96
97/// Render an exec-form argument vector as a JSON array: `["a","b"]`.
98fn render_json_array(args: &[String]) -> String {
99    let inner: Vec<String> = args
100        .iter()
101        .map(|a| format!("\"{}\"", escape_json_string(a)))
102        .collect();
103    format!("[{}]", inner.join(", "))
104}
105
106/// Render a `key=value` env list inline, sorted by key, shell-quoting values.
107fn render_inline_env(env: &HashMap<String, String>) -> String {
108    let mut keys: Vec<&String> = env.keys().collect();
109    keys.sort();
110    keys.iter()
111        .map(|k| format!("{}={}", k, shell_quote(&env[*k])))
112        .collect::<Vec<_>>()
113        .join(" ")
114}
115
116/// Merge the build's default cache mounts into every `RUN` instruction of a
117/// [`Dockerfile`] IR, in place.
118///
119/// For each `RUN` instruction, every [`RunMount::Cache`] entry in
120/// `options.default_cache_mounts` is appended to `run.mounts` unless a cache
121/// mount with the same `target` is already present (step-level mounts win).
122/// Non-cache entries in `default_cache_mounts` are ignored (the field only ever
123/// carries cache mounts, but we filter defensively).
124///
125/// This lifts the auto-cache-mount injection that the buildah CLI backend used
126/// to do inline per-RUN so it now materializes as real IR before rendering —
127/// making the rendered Dockerfile carry the `--mount=type=cache` flags
128/// identically for any backend that renders the IR to text.
129///
130/// A no-op when `options.default_cache_mounts` is empty (the common case).
131pub fn merge_default_cache_mounts(df: &mut Dockerfile, options: &crate::builder::BuildOptions) {
132    if options.default_cache_mounts.is_empty() {
133        return;
134    }
135    for stage in &mut df.stages {
136        for instruction in &mut stage.instructions {
137            let Instruction::Run(run) = instruction else {
138                continue;
139            };
140            for default_mount in &options.default_cache_mounts {
141                let RunMount::Cache { target, .. } = default_mount else {
142                    continue;
143                };
144                let already_has = run
145                    .mounts
146                    .iter()
147                    .any(|m| matches!(m, RunMount::Cache { target: t, .. } if t == target));
148                if !already_has {
149                    run.mounts.push(default_mount.clone());
150                }
151            }
152        }
153    }
154}
155
156/// Expand Docker build-time variables across an entire [`Dockerfile`] IR.
157///
158/// Walks `global_args` first (pre-FROM ARG defaults), then each [`Stage`]'s
159/// instructions, maintaining the accumulating ARG/ENV binding map exactly as the
160/// buildah CLI loop does: ARG scope is per-stage (reset to `build_args` + global
161/// ARG defaults at the start of each stage), and ARG/ENV instructions fold their
162/// (expanded) values into the bindings so later instructions observe them.
163///
164/// Substituted fields match `backend::buildah::expand_instruction` — RUN command,
165/// ENV values, COPY/ADD src+dest, WORKDIR, USER, LABEL values — PLUS each value
166/// in `RunInstruction::env` (the per-step transient env), which the inline
167/// translator historically failed to expand.
168///
169/// Returns a fully-substituted clone of the IR; the input is not modified.
170#[must_use]
171#[allow(clippy::implicit_hasher)]
172pub fn expand_dockerfile(df: &Dockerfile, build_args: &HashMap<String, String>) -> Dockerfile {
173    let mut out = df.clone();
174
175    for stage in &mut out.stages {
176        // Per-stage ARG scope (Docker semantics): start from the build args,
177        // overlaid with global (pre-FROM) ARG defaults that the build did not
178        // already provide.
179        let mut arg_values: HashMap<String, String> = build_args.clone();
180        for global_arg in &df.global_args {
181            if !arg_values.contains_key(&global_arg.name) {
182                if let Some(default) = &global_arg.default {
183                    arg_values.insert(global_arg.name.clone(), default.clone());
184                }
185            }
186        }
187        let mut env_values: HashMap<String, String> = HashMap::new();
188
189        for instruction in &mut stage.instructions {
190            *instruction = expand_instruction(instruction, &mut arg_values, &mut env_values);
191        }
192    }
193
194    out
195}
196
197/// Every build-arg NAME declared in the [`Dockerfile`] IR: the pre-FROM global
198/// `ARG`s plus every per-stage `ARG` instruction. Duplicates across stages are
199/// possible; callers that care about uniqueness should dedup.
200fn declared_arg_names(df: &Dockerfile) -> Vec<String> {
201    let mut names: Vec<String> = df.global_args.iter().map(|a| a.name.clone()).collect();
202    for stage in &df.stages {
203        for instruction in &stage.instructions {
204            if let Instruction::Arg(arg) = instruction {
205                names.push(arg.name.clone());
206            }
207        }
208    }
209    names
210}
211
212/// Forward declared-but-unset build-args from the process environment.
213///
214/// Docker's `--build-arg FOO` (no `=value`) takes `FOO`'s value from the
215/// builder's environment. We extend that convenience to every build-arg that is
216/// DECLARED in the Dockerfile IR (a pre-FROM global `ARG` or a per-stage `ARG`):
217/// if such an arg has no effective value in `args` (absent, or an empty string)
218/// and the process environment has a matching non-empty var, the env value is
219/// forwarded into `args`. This is why a declared `FORGEJO_TOKEN` build-arg
220/// populates from `$FORGEJO_TOKEN` with no explicit `--set`.
221///
222/// Guarantees:
223/// - Never overrides an explicit non-empty value already in `args`.
224/// - Only forwards names that are actually declared as build-args — the whole
225///   environment is never dumped into the image.
226/// - A no-op when the matching env var is unset or empty (identical to today).
227///
228/// Call this on the merged `effective_build_args` BEFORE deriving the expansion
229/// map so both the `--build-arg` flags and in-Dockerfile `${VAR}` expansion see
230/// the forwarded value.
231#[allow(clippy::implicit_hasher)]
232pub fn forward_build_arg_env(
233    df: &Dockerfile,
234    args: &mut std::collections::BTreeMap<String, String>,
235) {
236    for name in declared_arg_names(df) {
237        // Only fall back when the declared arg has no effective value.
238        if args.get(&name).is_some_and(|v| !v.is_empty()) {
239            continue;
240        }
241        match std::env::var(&name) {
242            Ok(value) if !value.is_empty() => {
243                args.insert(name, value);
244            }
245            _ => {}
246        }
247    }
248}
249
250/// Expand a single instruction's build-time-expandable fields and fold ARG/ENV
251/// bindings into the accumulators. This is the shared counterpart of the inline
252/// `backend::buildah::expand_instruction`, with the BUG3 fix: `RunInstruction`'s
253/// per-step `env` values are expanded too.
254fn expand_instruction(
255    instruction: &Instruction,
256    arg_values: &mut HashMap<String, String>,
257    env_values: &mut HashMap<String, String>,
258) -> Instruction {
259    match instruction {
260        Instruction::Run(run) => {
261            let mut run = run.clone();
262            run.command = match &run.command {
263                ShellOrExec::Shell(s) => {
264                    ShellOrExec::Shell(expand_variables(s, arg_values, env_values))
265                }
266                ShellOrExec::Exec(args) => ShellOrExec::Exec(
267                    args.iter()
268                        .map(|a| expand_variables(a, arg_values, env_values))
269                        .collect(),
270                ),
271            };
272            // BUG3 fix: expand the per-step transient env values too. The old
273            // inline translator expanded only `run.command`, so a per-step
274            // `ENV TOKEN=${FORGEJO_TOKEN}` style assignment on a RUN reached the
275            // runtime unexpanded.
276            run.env = run
277                .env
278                .iter()
279                .map(|(k, v)| (k.clone(), expand_variables(v, arg_values, env_values)))
280                .collect();
281            Instruction::Run(run)
282        }
283        Instruction::Env(env) => {
284            let mut vars = HashMap::with_capacity(env.vars.len());
285            for (key, value) in &env.vars {
286                let expanded = expand_variables(value, arg_values, env_values);
287                env_values.insert(key.clone(), expanded.clone());
288                vars.insert(key.clone(), expanded);
289            }
290            Instruction::Env(EnvInstruction { vars })
291        }
292        Instruction::Copy(copy) => {
293            let mut copy = copy.clone();
294            copy.sources = copy
295                .sources
296                .iter()
297                .map(|s| expand_variables(s, arg_values, env_values))
298                .collect();
299            copy.destination = expand_variables(&copy.destination, arg_values, env_values);
300            Instruction::Copy(copy)
301        }
302        Instruction::Add(add) => {
303            let mut add = add.clone();
304            add.sources = add
305                .sources
306                .iter()
307                .map(|s| expand_variables(s, arg_values, env_values))
308                .collect();
309            add.destination = expand_variables(&add.destination, arg_values, env_values);
310            Instruction::Add(add)
311        }
312        Instruction::Workdir(dir) => {
313            Instruction::Workdir(expand_variables(dir, arg_values, env_values))
314        }
315        Instruction::User(user) => {
316            Instruction::User(expand_variables(user, arg_values, env_values))
317        }
318        Instruction::Label(labels) => {
319            let expanded = labels
320                .iter()
321                .map(|(k, v)| (k.clone(), expand_variables(v, arg_values, env_values)))
322                .collect();
323            Instruction::Label(expanded)
324        }
325        Instruction::Arg(arg) => {
326            // `ARG NAME=default` contributes its (expanded) default only when
327            // the build did not already pass a value for it; a bare `ARG NAME`
328            // leaves the variable unset (preserved as-is by `expand_variables`).
329            if !arg_values.contains_key(&arg.name) {
330                if let Some(default) = &arg.default {
331                    let expanded = expand_variables(default, arg_values, env_values);
332                    arg_values.insert(arg.name.clone(), expanded);
333                }
334            }
335            instruction.clone()
336        }
337        other => other.clone(),
338    }
339}
340
341/// Serialize a (presumably already variable-substituted) [`Dockerfile`] IR back
342/// to canonical Dockerfile text.
343///
344/// Pure function: it does not perform variable expansion. Instruction order is
345/// preserved exactly as it appears in the IR. The output round-trips through
346/// [`Dockerfile::parse`] to a structurally-equivalent IR.
347#[must_use]
348pub fn render_dockerfile(df: &Dockerfile) -> String {
349    let mut out = String::new();
350
351    // Global ARGs (before the first FROM).
352    for arg in &df.global_args {
353        match &arg.default {
354            Some(default) => {
355                let _ = writeln!(out, "ARG {}={}", arg.name, default);
356            }
357            None => {
358                let _ = writeln!(out, "ARG {}", arg.name);
359            }
360        }
361    }
362
363    for (idx, stage) in df.stages.iter().enumerate() {
364        if idx > 0 || !df.global_args.is_empty() {
365            out.push('\n');
366        }
367        render_stage(stage, &mut out);
368    }
369
370    out
371}
372
373/// Render a single stage (its `FROM` line plus instructions).
374fn render_stage(stage: &Stage, out: &mut String) {
375    out.push_str("FROM ");
376    if let Some(platform) = &stage.platform {
377        let _ = write!(out, "--platform={platform} ");
378    }
379    out.push_str(&stage.base_image.to_string());
380    if let Some(name) = &stage.name {
381        let _ = write!(out, " AS {name}");
382    }
383    out.push('\n');
384
385    for instruction in &stage.instructions {
386        out.push_str(&render_instruction(instruction));
387        out.push('\n');
388    }
389}
390
391/// Render a single instruction to one (possibly multi-token) Dockerfile line.
392#[allow(clippy::too_many_lines)]
393fn render_instruction(instruction: &Instruction) -> String {
394    match instruction {
395        Instruction::Run(run) => render_run(run),
396        Instruction::Copy(copy) => render_copy(copy),
397        Instruction::Add(add) => render_add(add),
398        Instruction::Env(env) => {
399            let mut keys: Vec<&String> = env.vars.keys().collect();
400            keys.sort();
401            let parts: Vec<String> = keys
402                .iter()
403                .map(|k| format!("{}={}", k, shell_quote(&env.vars[*k])))
404                .collect();
405            format!("ENV {}", parts.join(" "))
406        }
407        Instruction::Workdir(dir) => format!("WORKDIR {dir}"),
408        Instruction::Expose(expose) => {
409            let proto = match expose.protocol {
410                ExposeProtocol::Tcp => "tcp",
411                ExposeProtocol::Udp => "udp",
412            };
413            format!("EXPOSE {}/{proto}", expose.port)
414        }
415        Instruction::Label(labels) => {
416            let mut keys: Vec<&String> = labels.keys().collect();
417            keys.sort();
418            let parts: Vec<String> = keys
419                .iter()
420                .map(|k| format!("{}=\"{}\"", k, escape_json_string(&labels[*k])))
421                .collect();
422            format!("LABEL {}", parts.join(" "))
423        }
424        Instruction::User(user) => format!("USER {user}"),
425        Instruction::Entrypoint(cmd) => render_shell_or_exec("ENTRYPOINT", cmd),
426        Instruction::Cmd(cmd) => render_shell_or_exec("CMD", cmd),
427        Instruction::Volume(paths) => format!("VOLUME {}", render_json_array(paths)),
428        Instruction::Shell(shell) => format!("SHELL {}", render_json_array(shell)),
429        Instruction::Arg(arg) => match &arg.default {
430            Some(default) => format!("ARG {}={}", arg.name, default),
431            None => format!("ARG {}", arg.name),
432        },
433        Instruction::Stopsignal(signal) => format!("STOPSIGNAL {signal}"),
434        Instruction::Healthcheck(health) => render_healthcheck(health),
435        Instruction::Onbuild(inner) => format!("ONBUILD {}", render_instruction(inner)),
436    }
437}
438
439/// Render an `ENTRYPOINT` / `CMD` instruction: exec form → JSON array, shell form
440/// → bare command.
441fn render_shell_or_exec(keyword: &str, cmd: &ShellOrExec) -> String {
442    match cmd {
443        ShellOrExec::Exec(args) => format!("{keyword} {}", render_json_array(args)),
444        ShellOrExec::Shell(s) => format!("{keyword} {s}"),
445    }
446}
447
448/// Render a `RUN` instruction, folding `--mount`, `--network`, `--security`
449/// flags and the per-step env inline.
450fn render_run(run: &RunInstruction) -> String {
451    let mut flags = String::new();
452    for mount in &run.mounts {
453        let _ = write!(flags, "--mount={} ", mount.to_buildah_arg());
454    }
455    if let Some(network) = &run.network {
456        let net = match network {
457            RunNetwork::Default => "default",
458            RunNetwork::None => "none",
459            RunNetwork::Host => "host",
460        };
461        let _ = write!(flags, "--network={net} ");
462    }
463    if let Some(security) = &run.security {
464        let sec = match security {
465            RunSecurity::Sandbox => "sandbox",
466            RunSecurity::Insecure => "insecure",
467        };
468        let _ = write!(flags, "--security={sec} ");
469    }
470
471    let env_prefix = if run.env.is_empty() {
472        String::new()
473    } else {
474        format!("{} ", render_inline_env(&run.env))
475    };
476
477    match &run.command {
478        ShellOrExec::Shell(s) => {
479            format!("RUN {flags}{env_prefix}{s}")
480        }
481        ShellOrExec::Exec(args) => {
482            if run.env.is_empty() {
483                // Exec form with no env → JSON array form.
484                format!("RUN {flags}{}", render_json_array(args))
485            } else {
486                // Exec form with env can't be expressed as a JSON array (env
487                // assignments are a shell concept), so degrade to shell form
488                // with the args joined and individually shell-quoted.
489                let joined = args
490                    .iter()
491                    .map(|a| shell_quote(a))
492                    .collect::<Vec<_>>()
493                    .join(" ");
494                format!("RUN {flags}{env_prefix}{joined}")
495            }
496        }
497    }
498}
499
500/// Render a `COPY` instruction with its flags.
501fn render_copy(copy: &CopyInstruction) -> String {
502    let mut s = String::from("COPY ");
503    if let Some(from) = &copy.from {
504        let _ = write!(s, "--from={from} ");
505    }
506    if let Some(chown) = &copy.chown {
507        let _ = write!(s, "--chown={chown} ");
508    }
509    if let Some(chmod) = &copy.chmod {
510        let _ = write!(s, "--chmod={chmod} ");
511    }
512    if copy.link {
513        s.push_str("--link ");
514    }
515    for exclude in &copy.exclude {
516        let _ = write!(s, "--exclude={exclude} ");
517    }
518    s.push_str(&copy.sources.join(" "));
519    s.push(' ');
520    s.push_str(&copy.destination);
521    s
522}
523
524/// Render an `ADD` instruction with its flags.
525fn render_add(add: &AddInstruction) -> String {
526    let mut s = String::from("ADD ");
527    if let Some(chown) = &add.chown {
528        let _ = write!(s, "--chown={chown} ");
529    }
530    if let Some(chmod) = &add.chmod {
531        let _ = write!(s, "--chmod={chmod} ");
532    }
533    if add.link {
534        s.push_str("--link ");
535    }
536    if let Some(checksum) = &add.checksum {
537        let _ = write!(s, "--checksum={checksum} ");
538    }
539    s.push_str(&add.sources.join(" "));
540    s.push(' ');
541    s.push_str(&add.destination);
542    s
543}
544
545/// Render a `HEALTHCHECK` instruction.
546fn render_healthcheck(health: &HealthcheckInstruction) -> String {
547    match health {
548        HealthcheckInstruction::None => "HEALTHCHECK NONE".to_string(),
549        HealthcheckInstruction::Check {
550            command,
551            interval,
552            timeout,
553            start_period,
554            start_interval,
555            retries,
556        } => {
557            let mut s = String::from("HEALTHCHECK");
558            if let Some(d) = interval {
559                let _ = write!(s, " --interval={}s", d.as_secs());
560            }
561            if let Some(d) = timeout {
562                let _ = write!(s, " --timeout={}s", d.as_secs());
563            }
564            if let Some(d) = start_period {
565                let _ = write!(s, " --start-period={}s", d.as_secs());
566            }
567            if let Some(d) = start_interval {
568                let _ = write!(s, " --start-interval={}s", d.as_secs());
569            }
570            if let Some(r) = retries {
571                let _ = write!(s, " --retries={r}");
572            }
573            match command {
574                ShellOrExec::Exec(args) => {
575                    let _ = write!(s, " CMD {}", render_json_array(args));
576                }
577                ShellOrExec::Shell(cmd) => {
578                    let _ = write!(s, " CMD {cmd}");
579                }
580            }
581            s
582        }
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::dockerfile::{
590        AddInstruction, ArgInstruction, CacheSharing, CopyInstruction, DockerfileFromTarget,
591        EnvInstruction, ExposeInstruction, HealthcheckInstruction, Instruction, RunInstruction,
592        RunMount, ShellOrExec, Stage,
593    };
594    use std::str::FromStr;
595    use std::time::Duration;
596    use zlayer_types::ImageReference;
597
598    fn img(s: &str) -> DockerfileFromTarget {
599        DockerfileFromTarget::Image(ImageReference::from_str(s).unwrap())
600    }
601
602    /// Normalize the `dockerfile_parser` "Misc" quirk where WORKDIR/USER/
603    /// STOPSIGNAL arguments are captured with their leading whitespace included
604    /// (e.g. `WORKDIR /src` parses to `Workdir(" /src")`). The renderer emits the
605    /// canonical single-space form; trimming here lets the structural-equivalence
606    /// check compare the meaningful payload rather than this parser artifact.
607    fn normalize(inst: &Instruction) -> Instruction {
608        match inst {
609            Instruction::Workdir(s) => Instruction::Workdir(s.trim().to_string()),
610            Instruction::User(s) => Instruction::User(s.trim().to_string()),
611            Instruction::Stopsignal(s) => Instruction::Stopsignal(s.trim().to_string()),
612            other => other.clone(),
613        }
614    }
615
616    /// Compare two instruction vectors for structural equivalence, normalizing
617    /// the parser's leading-whitespace quirk on Misc-derived instructions.
618    fn assert_instructions_eq(expected: &[Instruction], actual: &[Instruction]) {
619        assert_eq!(
620            expected.len(),
621            actual.len(),
622            "instruction count mismatch\nexpected: {expected:#?}\nactual: {actual:#?}"
623        );
624        for (e, a) in expected.iter().zip(actual.iter()) {
625            assert_eq!(normalize(e), normalize(a), "instruction mismatch");
626        }
627    }
628
629    #[test]
630    fn forward_build_arg_env_fills_only_declared_empty_args() {
631        use std::collections::BTreeMap;
632
633        // Unique names so we don't collide with the ambient environment.
634        let declared_empty = "ZLAYER_TEST_FWD_DECLARED_EMPTY";
635        let declared_explicit = "ZLAYER_TEST_FWD_DECLARED_EXPLICIT";
636        let declared_noenv = "ZLAYER_TEST_FWD_DECLARED_NOENV";
637        let undeclared = "ZLAYER_TEST_FWD_UNDECLARED";
638
639        std::env::set_var(declared_empty, "from-env");
640        std::env::set_var(declared_explicit, "from-env");
641        std::env::set_var(undeclared, "from-env");
642        std::env::remove_var(declared_noenv);
643
644        // IR declares three args (two global, one per-stage); `undeclared` is NOT
645        // declared anywhere.
646        let df = Dockerfile {
647            global_args: vec![
648                ArgInstruction::new(declared_empty),
649                ArgInstruction::new(declared_noenv),
650            ],
651            stages: vec![Stage {
652                index: 0,
653                name: None,
654                base_image: img("alpine:3.18"),
655                platform: None,
656                instructions: vec![Instruction::Arg(ArgInstruction::new(declared_explicit))],
657            }],
658        };
659
660        let mut args: BTreeMap<String, String> = BTreeMap::new();
661        // Declared arg with an empty value -> takes the env value.
662        args.insert(declared_empty.to_string(), String::new());
663        // Declared arg with an explicit non-empty value -> preserved as-is.
664        args.insert(declared_explicit.to_string(), "explicit".to_string());
665
666        forward_build_arg_env(&df, &mut args);
667
668        // Empty + env set -> resolves to the env value.
669        assert_eq!(
670            args.get(declared_empty).map(String::as_str),
671            Some("from-env")
672        );
673        // Explicit non-empty + env set -> keeps the explicit value (never overridden).
674        assert_eq!(
675            args.get(declared_explicit).map(String::as_str),
676            Some("explicit")
677        );
678        // Declared but env unset -> unchanged (still absent, current behavior).
679        assert!(!args.contains_key(declared_noenv));
680        // Undeclared env var -> never forwarded into the image.
681        assert!(!args.contains_key(undeclared));
682
683        std::env::remove_var(declared_empty);
684        std::env::remove_var(declared_explicit);
685        std::env::remove_var(undeclared);
686    }
687
688    #[test]
689    fn round_trip_multistage_representative() {
690        let stage0 = Stage {
691            index: 0,
692            name: Some("builder".to_string()),
693            base_image: img("golang:1.21"),
694            platform: None,
695            instructions: vec![
696                Instruction::Workdir("/src".to_string()),
697                Instruction::Copy(CopyInstruction::new(vec![".".to_string()], ".".to_string())),
698                Instruction::Run(RunInstruction::shell("go build -o /app")),
699                Instruction::Run(RunInstruction::exec(vec![
700                    "/bin/true".to_string(),
701                    "arg with space".to_string(),
702                ])),
703                Instruction::Arg(ArgInstruction::with_default("VERSION", "1.0")),
704                Instruction::Env(EnvInstruction::new("FOO", "bar baz")),
705                Instruction::Expose(ExposeInstruction::tcp(8080)),
706                Instruction::Stopsignal("SIGTERM".to_string()),
707            ],
708        };
709
710        let mut label_map = std::collections::HashMap::new();
711        label_map.insert(
712            "org.opencontainers.image.title".to_string(),
713            "my app".to_string(),
714        );
715
716        let stage1 = Stage {
717            index: 1,
718            name: None,
719            base_image: img("alpine:3.18"),
720            platform: None,
721            instructions: vec![
722                Instruction::Copy(
723                    CopyInstruction::new(vec!["/app".to_string()], "/app".to_string())
724                        .from_stage("builder")
725                        .chown("1000:1000"),
726                ),
727                Instruction::User("appuser".to_string()),
728                Instruction::Label(label_map),
729                Instruction::Volume(vec!["/data".to_string(), "/cache".to_string()]),
730                Instruction::Shell(vec!["/bin/sh".to_string(), "-c".to_string()]),
731                Instruction::Entrypoint(ShellOrExec::Exec(vec!["/app".to_string()])),
732                Instruction::Cmd(ShellOrExec::Shell("--serve".to_string())),
733            ],
734        };
735
736        let df = Dockerfile {
737            global_args: vec![ArgInstruction::with_default("BASE_TAG", "latest")],
738            stages: vec![stage0, stage1],
739        };
740
741        let text = render_dockerfile(&df);
742        let reparsed = Dockerfile::parse(&text)
743            .unwrap_or_else(|e| panic!("re-parse failed: {e}\n---\n{text}\n---"));
744
745        assert_eq!(
746            reparsed.global_args.len(),
747            df.global_args.len(),
748            "global args mismatch\n{text}"
749        );
750        assert_eq!(
751            reparsed.stages.len(),
752            df.stages.len(),
753            "stage count\n{text}"
754        );
755
756        for (orig, re) in df.stages.iter().zip(reparsed.stages.iter()) {
757            assert_eq!(orig.name, re.name, "stage name\n{text}");
758            assert_eq!(
759                orig.base_image.to_string(),
760                re.base_image.to_string(),
761                "base image\n{text}"
762            );
763            assert_instructions_eq(&orig.instructions, &re.instructions);
764        }
765    }
766
767    #[test]
768    fn render_healthcheck_variants() {
769        // NONE form.
770        assert_eq!(
771            render_instruction(&Instruction::Healthcheck(HealthcheckInstruction::None)),
772            "HEALTHCHECK NONE"
773        );
774
775        // Full options + shell CMD. (Parser folds HEALTHCHECK flags into the
776        // command string, so this is asserted on rendered text rather than via a
777        // re-parse round-trip.)
778        let hc = HealthcheckInstruction::Check {
779            command: ShellOrExec::Shell("curl -f http://localhost/ || exit 1".to_string()),
780            interval: Some(Duration::from_secs(30)),
781            timeout: Some(Duration::from_secs(5)),
782            start_period: Some(Duration::from_secs(10)),
783            start_interval: Some(Duration::from_secs(2)),
784            retries: Some(3),
785        };
786        assert_eq!(
787            render_instruction(&Instruction::Healthcheck(hc)),
788            "HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=2s \
789             --retries=3 CMD curl -f http://localhost/ || exit 1"
790        );
791
792        // Exec-form CMD renders as a JSON array.
793        let hc_exec = HealthcheckInstruction::cmd(ShellOrExec::Exec(vec![
794            "curl".to_string(),
795            "-f".to_string(),
796            "http://localhost/".to_string(),
797        ]));
798        assert_eq!(
799            render_instruction(&Instruction::Healthcheck(hc_exec)),
800            r#"HEALTHCHECK CMD ["curl", "-f", "http://localhost/"]"#
801        );
802    }
803
804    #[test]
805    fn render_run_with_cache_mount() {
806        // `RUN --mount=type=cache` renders the BuildKit mount flag inline. (The
807        // bundled `dockerfile_parser` folds `RUN` flags into the command string
808        // rather than into typed mounts, so this is asserted on the rendered
809        // text rather than via a re-parse round-trip.)
810        let mut run = RunInstruction::shell("apt-get update");
811        run.mounts = vec![RunMount::Cache {
812            target: "/var/cache/apt".to_string(),
813            id: Some("apt".to_string()),
814            sharing: CacheSharing::Shared,
815            readonly: false,
816        }];
817
818        let line = render_instruction(&Instruction::Run(run));
819        assert_eq!(
820            line,
821            "RUN --mount=type=cache,target=/var/cache/apt,id=apt,sharing=shared apt-get update"
822        );
823    }
824
825    #[test]
826    fn round_trip_run_exec_no_env() {
827        let df = Dockerfile {
828            global_args: vec![],
829            stages: vec![Stage {
830                index: 0,
831                name: None,
832                base_image: img("alpine"),
833                platform: None,
834                instructions: vec![Instruction::Run(RunInstruction::exec(vec![
835                    "echo".to_string(),
836                    "hello world".to_string(),
837                ]))],
838            }],
839        };
840
841        let text = render_dockerfile(&df);
842        assert!(
843            text.contains(r#"RUN ["echo", "hello world"]"#),
844            "got:\n{text}"
845        );
846
847        let reparsed = Dockerfile::parse(&text).unwrap();
848        assert_instructions_eq(&df.stages[0].instructions, &reparsed.stages[0].instructions);
849    }
850
851    #[test]
852    fn round_trip_run_with_per_step_env() {
853        // Exec form with env degrades to shell form. We assert the rendered text
854        // carries the env inline and the command tokens.
855        let mut run = RunInstruction::shell("make build");
856        run.env.insert("CC".to_string(), "clang".to_string());
857        run.env.insert("FLAGS".to_string(), "-O2 -g".to_string());
858
859        let df = Dockerfile {
860            global_args: vec![],
861            stages: vec![Stage {
862                index: 0,
863                name: None,
864                base_image: img("alpine"),
865                platform: None,
866                instructions: vec![Instruction::Run(run)],
867            }],
868        };
869
870        let text = render_dockerfile(&df);
871        // Sorted keys, value with whitespace quoted.
872        assert!(
873            text.contains(r#"RUN CC=clang FLAGS="-O2 -g" make build"#),
874            "got:\n{text}"
875        );
876        // The shell-form RUN re-parses to a shell command (env folds into the
877        // command string since Dockerfile syntax has no per-step env concept).
878        let reparsed = Dockerfile::parse(&text).unwrap();
879        assert_eq!(reparsed.stages[0].instructions.len(), 1);
880        assert!(matches!(
881            &reparsed.stages[0].instructions[0],
882            Instruction::Run(_)
883        ));
884    }
885
886    #[test]
887    fn round_trip_onbuild() {
888        let df = Dockerfile {
889            global_args: vec![],
890            stages: vec![Stage {
891                index: 0,
892                name: None,
893                base_image: img("alpine"),
894                platform: None,
895                instructions: vec![Instruction::Onbuild(Box::new(Instruction::Run(
896                    RunInstruction::shell("echo onbuild"),
897                )))],
898            }],
899        };
900
901        let text = render_dockerfile(&df);
902        assert!(text.contains("ONBUILD RUN echo onbuild"), "got:\n{text}");
903        // The existing parser drops ONBUILD, but the renderer must PRESERVE it.
904        // Assert via the rendered text rather than a re-parse round-trip.
905    }
906
907    #[test]
908    fn render_add_with_checksum() {
909        let mut add = AddInstruction::new(
910            vec!["https://example.com/file.tar.gz".to_string()],
911            "/opt/file.tar.gz".to_string(),
912        );
913        add.checksum = Some("sha256:abc".to_string());
914        add.chown = Some("0:0".to_string());
915
916        let line = render_instruction(&Instruction::Add(add));
917        assert_eq!(
918            line,
919            "ADD --chown=0:0 --checksum=sha256:abc https://example.com/file.tar.gz /opt/file.tar.gz"
920        );
921    }
922
923    #[test]
924    fn expand_dockerfile_bug3_run_env_and_command() {
925        // BUG3: a RUN with per-step env `TOKEN=${FORGEJO_TOKEN}` plus a command
926        // referencing `${FORGEJO_TOKEN}` must expand BOTH.
927        let mut run = RunInstruction::shell("auth --token ${FORGEJO_TOKEN}");
928        run.env
929            .insert("TOKEN".to_string(), "${FORGEJO_TOKEN}".to_string());
930
931        let df = Dockerfile {
932            global_args: vec![],
933            stages: vec![Stage {
934                index: 0,
935                name: None,
936                base_image: img("alpine"),
937                platform: None,
938                instructions: vec![Instruction::Run(run)],
939            }],
940        };
941
942        let build_args: std::collections::HashMap<String, String> =
943            [("FORGEJO_TOKEN".to_string(), "s3cr3t".to_string())]
944                .into_iter()
945                .collect();
946
947        let expanded = expand_dockerfile(&df, &build_args);
948        let Instruction::Run(run) = &expanded.stages[0].instructions[0] else {
949            panic!("expected RUN");
950        };
951
952        assert_eq!(
953            run.env.get("TOKEN"),
954            Some(&"s3cr3t".to_string()),
955            "BUG3: run.env value must be substituted"
956        );
957        match &run.command {
958            ShellOrExec::Shell(s) => assert_eq!(s, "auth --token s3cr3t"),
959            ShellOrExec::Exec(args) => panic!("expected shell command, got exec: {args:?}"),
960        }
961    }
962
963    #[test]
964    fn merge_default_cache_mounts_adds_and_dedups() {
965        use crate::builder::BuildOptions;
966        use crate::dockerfile::{CacheSharing, RunInstruction, RunMount};
967
968        // RUN #0: no mounts → should receive the default cache mount.
969        let run_bare = RunInstruction::shell("apt-get update");
970
971        // RUN #1: already has a cache mount for the SAME target → the default
972        // for that target must NOT be appended (step-level wins).
973        let mut run_has_same = RunInstruction::shell("pip install foo");
974        run_has_same.mounts.push(RunMount::Cache {
975            target: "/var/cache/apt".to_string(),
976            id: Some("step".to_string()),
977            sharing: CacheSharing::Locked,
978            readonly: false,
979        });
980
981        let df = Dockerfile {
982            global_args: vec![],
983            stages: vec![Stage {
984                index: 0,
985                name: None,
986                base_image: img("alpine"),
987                platform: None,
988                instructions: vec![
989                    Instruction::Run(run_bare),
990                    Instruction::Run(run_has_same),
991                    // A non-RUN instruction must be untouched.
992                    Instruction::Workdir("/app".to_string()),
993                ],
994            }],
995        };
996
997        let mut ir = df;
998        let options = BuildOptions {
999            default_cache_mounts: vec![RunMount::Cache {
1000                target: "/var/cache/apt".to_string(),
1001                id: Some("auto".to_string()),
1002                sharing: CacheSharing::Shared,
1003                readonly: false,
1004            }],
1005            ..BuildOptions::default()
1006        };
1007
1008        super::merge_default_cache_mounts(&mut ir, &options);
1009
1010        let Instruction::Run(r0) = &ir.stages[0].instructions[0] else {
1011            panic!("expected RUN");
1012        };
1013        assert_eq!(r0.mounts.len(), 1, "bare RUN gains the default cache mount");
1014        assert!(matches!(
1015            &r0.mounts[0],
1016            RunMount::Cache { target, id, .. }
1017                if target == "/var/cache/apt" && id.as_deref() == Some("auto")
1018        ));
1019
1020        let Instruction::Run(r1) = &ir.stages[0].instructions[1] else {
1021            panic!("expected RUN");
1022        };
1023        assert_eq!(
1024            r1.mounts.len(),
1025            1,
1026            "RUN with same-target cache mount is NOT duplicated"
1027        );
1028        assert!(matches!(
1029            &r1.mounts[0],
1030            RunMount::Cache { id, .. } if id.as_deref() == Some("step")
1031        ));
1032
1033        assert!(matches!(
1034            &ir.stages[0].instructions[2],
1035            Instruction::Workdir(_)
1036        ));
1037    }
1038
1039    #[test]
1040    fn merge_default_cache_mounts_noop_when_empty() {
1041        use crate::builder::BuildOptions;
1042        use crate::dockerfile::RunInstruction;
1043
1044        let df = Dockerfile {
1045            global_args: vec![],
1046            stages: vec![Stage {
1047                index: 0,
1048                name: None,
1049                base_image: img("alpine"),
1050                platform: None,
1051                instructions: vec![Instruction::Run(RunInstruction::shell("echo hi"))],
1052            }],
1053        };
1054        let mut ir = df;
1055        super::merge_default_cache_mounts(&mut ir, &BuildOptions::default());
1056        let Instruction::Run(r) = &ir.stages[0].instructions[0] else {
1057            panic!("expected RUN");
1058        };
1059        assert!(r.mounts.is_empty());
1060    }
1061
1062    #[test]
1063    fn expand_dockerfile_arg_and_env_accumulation() {
1064        // ARG default participates; ENV folds in and is visible to later RUN.
1065        let df = Dockerfile {
1066            global_args: vec![],
1067            stages: vec![Stage {
1068                index: 0,
1069                name: None,
1070                base_image: img("alpine"),
1071                platform: None,
1072                instructions: vec![
1073                    Instruction::Arg(ArgInstruction::with_default("GREETING", "hi")),
1074                    Instruction::Env(EnvInstruction::new("WHO", "${GREETING} world")),
1075                    Instruction::Run(RunInstruction::shell("echo ${WHO}")),
1076                ],
1077            }],
1078        };
1079
1080        let expanded = expand_dockerfile(&df, &std::collections::HashMap::new());
1081        let Instruction::Env(env) = &expanded.stages[0].instructions[1] else {
1082            panic!("expected ENV");
1083        };
1084        assert_eq!(env.vars.get("WHO"), Some(&"hi world".to_string()));
1085
1086        let Instruction::Run(run) = &expanded.stages[0].instructions[2] else {
1087            panic!("expected RUN");
1088        };
1089        match &run.command {
1090            ShellOrExec::Shell(s) => assert_eq!(s, "echo hi world"),
1091            ShellOrExec::Exec(args) => panic!("expected shell, got exec: {args:?}"),
1092        }
1093    }
1094}