Skip to main content

zlayer_builder/dockerfile/
instruction.rs

1//! Dockerfile instruction types
2//!
3//! This module defines the internal representation of Dockerfile instructions,
4//! providing a clean, typed interface for working with parsed Dockerfiles.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Represents a command that can be in shell form or exec form
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub enum ShellOrExec {
12    /// Shell form: `CMD command param1 param2`
13    /// Executed as `/bin/sh -c "command param1 param2"`
14    Shell(String),
15
16    /// Exec form: `CMD ["executable", "param1", "param2"]`
17    /// Executed directly without shell interpretation
18    Exec(Vec<String>),
19}
20
21impl ShellOrExec {
22    /// Returns true if this is the shell form
23    #[must_use]
24    pub fn is_shell(&self) -> bool {
25        matches!(self, Self::Shell(_))
26    }
27
28    /// Returns true if this is the exec form
29    #[must_use]
30    pub fn is_exec(&self) -> bool {
31        matches!(self, Self::Exec(_))
32    }
33
34    /// Convert to a vector of strings suitable for execution
35    #[must_use]
36    pub fn to_exec_args(&self, shell: &[String]) -> Vec<String> {
37        match self {
38            Self::Shell(cmd) => {
39                let mut args = shell.to_vec();
40                args.push(cmd.clone());
41                args
42            }
43            Self::Exec(args) => args.clone(),
44        }
45    }
46}
47
48impl Default for ShellOrExec {
49    fn default() -> Self {
50        Self::Exec(Vec::new())
51    }
52}
53
54/// A single Dockerfile instruction
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub enum Instruction {
57    /// RUN instruction - executes a command and commits the result as a new layer
58    Run(RunInstruction),
59
60    /// COPY instruction - copies files from build context or previous stage
61    Copy(CopyInstruction),
62
63    /// ADD instruction - like COPY but with URL support and auto-extraction
64    Add(AddInstruction),
65
66    /// ENV instruction - sets environment variables
67    Env(EnvInstruction),
68
69    /// WORKDIR instruction - sets the working directory
70    Workdir(String),
71
72    /// EXPOSE instruction - documents which ports the container listens on
73    Expose(ExposeInstruction),
74
75    /// LABEL instruction - adds metadata to the image
76    Label(HashMap<String, String>),
77
78    /// USER instruction - sets the user for subsequent instructions
79    User(String),
80
81    /// ENTRYPOINT instruction - configures container as executable
82    Entrypoint(ShellOrExec),
83
84    /// CMD instruction - provides defaults for container execution
85    Cmd(ShellOrExec),
86
87    /// VOLUME instruction - creates mount points
88    Volume(Vec<String>),
89
90    /// SHELL instruction - changes the default shell
91    Shell(Vec<String>),
92
93    /// ARG instruction - defines build-time variables
94    Arg(ArgInstruction),
95
96    /// STOPSIGNAL instruction - sets the signal for stopping the container
97    Stopsignal(String),
98
99    /// HEALTHCHECK instruction - defines how to check container health
100    Healthcheck(HealthcheckInstruction),
101
102    /// ONBUILD instruction - adds trigger for downstream builds
103    Onbuild(Box<Instruction>),
104}
105
106impl Instruction {
107    /// Returns the instruction name as it would appear in a Dockerfile
108    #[must_use]
109    pub fn name(&self) -> &'static str {
110        match self {
111            Self::Run(_) => "RUN",
112            Self::Copy(_) => "COPY",
113            Self::Add(_) => "ADD",
114            Self::Env(_) => "ENV",
115            Self::Workdir(_) => "WORKDIR",
116            Self::Expose(_) => "EXPOSE",
117            Self::Label(_) => "LABEL",
118            Self::User(_) => "USER",
119            Self::Entrypoint(_) => "ENTRYPOINT",
120            Self::Cmd(_) => "CMD",
121            Self::Volume(_) => "VOLUME",
122            Self::Shell(_) => "SHELL",
123            Self::Arg(_) => "ARG",
124            Self::Stopsignal(_) => "STOPSIGNAL",
125            Self::Healthcheck(_) => "HEALTHCHECK",
126            Self::Onbuild(_) => "ONBUILD",
127        }
128    }
129
130    /// Returns true if this instruction creates a new layer
131    #[must_use]
132    pub fn creates_layer(&self) -> bool {
133        matches!(self, Self::Run(_) | Self::Copy(_) | Self::Add(_))
134    }
135
136    /// Generate a cache key for this instruction.
137    ///
138    /// The key uniquely identifies the instruction and its parameters,
139    /// enabling cache hit detection when combined with the base layer digest.
140    ///
141    /// # Returns
142    ///
143    /// A 16-character hexadecimal string representing the hash of the instruction.
144    ///
145    /// # Example
146    ///
147    /// ```
148    /// use zlayer_builder::dockerfile::Instruction;
149    /// use zlayer_builder::dockerfile::RunInstruction;
150    ///
151    /// let run = Instruction::Run(RunInstruction::shell("echo hello"));
152    /// let key = run.cache_key();
153    /// assert_eq!(key.len(), 16);
154    /// ```
155    #[must_use]
156    #[allow(clippy::too_many_lines)]
157    pub fn cache_key(&self) -> String {
158        use std::collections::hash_map::DefaultHasher;
159        use std::hash::{Hash, Hasher};
160
161        let mut hasher = DefaultHasher::new();
162
163        match self {
164            Self::Run(run) => {
165                "RUN".hash(&mut hasher);
166                // Hash the command representation
167                match &run.command {
168                    ShellOrExec::Shell(s) => {
169                        "shell".hash(&mut hasher);
170                        s.hash(&mut hasher);
171                    }
172                    ShellOrExec::Exec(args) => {
173                        "exec".hash(&mut hasher);
174                        args.hash(&mut hasher);
175                    }
176                }
177                // Include mounts in the hash - they affect layer content
178                for mount in &run.mounts {
179                    format!("{mount:?}").hash(&mut hasher);
180                }
181                // Include network mode if set
182                if let Some(network) = &run.network {
183                    format!("{network:?}").hash(&mut hasher);
184                }
185                // Include security mode if set
186                if let Some(security) = &run.security {
187                    format!("{security:?}").hash(&mut hasher);
188                }
189                // Include transient env in the hash — different env values
190                // change the effective RUN behavior even though they are not
191                // baked into the image config.
192                let mut env_keys: Vec<_> = run.env.keys().collect();
193                env_keys.sort();
194                for key in env_keys {
195                    key.hash(&mut hasher);
196                    run.env.get(key).hash(&mut hasher);
197                }
198            }
199            Self::Copy(copy) => {
200                "COPY".hash(&mut hasher);
201                copy.sources.hash(&mut hasher);
202                copy.destination.hash(&mut hasher);
203                copy.from.hash(&mut hasher);
204                copy.chown.hash(&mut hasher);
205                copy.chmod.hash(&mut hasher);
206                copy.link.hash(&mut hasher);
207                copy.exclude.hash(&mut hasher);
208            }
209            Self::Add(add) => {
210                "ADD".hash(&mut hasher);
211                add.sources.hash(&mut hasher);
212                add.destination.hash(&mut hasher);
213                add.chown.hash(&mut hasher);
214                add.chmod.hash(&mut hasher);
215                add.link.hash(&mut hasher);
216                add.checksum.hash(&mut hasher);
217                add.keep_git_dir.hash(&mut hasher);
218            }
219            Self::Env(env) => {
220                "ENV".hash(&mut hasher);
221                // Sort keys for deterministic hashing
222                let mut keys: Vec<_> = env.vars.keys().collect();
223                keys.sort();
224                for key in keys {
225                    key.hash(&mut hasher);
226                    env.vars.get(key).hash(&mut hasher);
227                }
228            }
229            Self::Workdir(path) => {
230                "WORKDIR".hash(&mut hasher);
231                path.hash(&mut hasher);
232            }
233            Self::Expose(expose) => {
234                "EXPOSE".hash(&mut hasher);
235                expose.port.hash(&mut hasher);
236                format!("{:?}", expose.protocol).hash(&mut hasher);
237            }
238            Self::Label(labels) => {
239                "LABEL".hash(&mut hasher);
240                // Sort keys for deterministic hashing
241                let mut keys: Vec<_> = labels.keys().collect();
242                keys.sort();
243                for key in keys {
244                    key.hash(&mut hasher);
245                    labels.get(key).hash(&mut hasher);
246                }
247            }
248            Self::User(user) => {
249                "USER".hash(&mut hasher);
250                user.hash(&mut hasher);
251            }
252            Self::Entrypoint(cmd) => {
253                "ENTRYPOINT".hash(&mut hasher);
254                match cmd {
255                    ShellOrExec::Shell(s) => {
256                        "shell".hash(&mut hasher);
257                        s.hash(&mut hasher);
258                    }
259                    ShellOrExec::Exec(args) => {
260                        "exec".hash(&mut hasher);
261                        args.hash(&mut hasher);
262                    }
263                }
264            }
265            Self::Cmd(cmd) => {
266                "CMD".hash(&mut hasher);
267                match cmd {
268                    ShellOrExec::Shell(s) => {
269                        "shell".hash(&mut hasher);
270                        s.hash(&mut hasher);
271                    }
272                    ShellOrExec::Exec(args) => {
273                        "exec".hash(&mut hasher);
274                        args.hash(&mut hasher);
275                    }
276                }
277            }
278            Self::Volume(paths) => {
279                "VOLUME".hash(&mut hasher);
280                paths.hash(&mut hasher);
281            }
282            Self::Shell(shell) => {
283                "SHELL".hash(&mut hasher);
284                shell.hash(&mut hasher);
285            }
286            Self::Arg(arg) => {
287                "ARG".hash(&mut hasher);
288                arg.name.hash(&mut hasher);
289                arg.default.hash(&mut hasher);
290            }
291            Self::Stopsignal(signal) => {
292                "STOPSIGNAL".hash(&mut hasher);
293                signal.hash(&mut hasher);
294            }
295            Self::Healthcheck(health) => {
296                "HEALTHCHECK".hash(&mut hasher);
297                match health {
298                    HealthcheckInstruction::None => {
299                        "none".hash(&mut hasher);
300                    }
301                    HealthcheckInstruction::Check {
302                        command,
303                        interval,
304                        timeout,
305                        start_period,
306                        start_interval,
307                        retries,
308                    } => {
309                        "check".hash(&mut hasher);
310                        match command {
311                            ShellOrExec::Shell(s) => {
312                                "shell".hash(&mut hasher);
313                                s.hash(&mut hasher);
314                            }
315                            ShellOrExec::Exec(args) => {
316                                "exec".hash(&mut hasher);
317                                args.hash(&mut hasher);
318                            }
319                        }
320                        interval.map(|d| d.as_nanos()).hash(&mut hasher);
321                        timeout.map(|d| d.as_nanos()).hash(&mut hasher);
322                        start_period.map(|d| d.as_nanos()).hash(&mut hasher);
323                        start_interval.map(|d| d.as_nanos()).hash(&mut hasher);
324                        retries.hash(&mut hasher);
325                    }
326                }
327            }
328            Self::Onbuild(inner) => {
329                "ONBUILD".hash(&mut hasher);
330                // Recursively hash the inner instruction
331                inner.cache_key().hash(&mut hasher);
332            }
333        }
334
335        format!("{:016x}", hasher.finish())
336    }
337}
338
339/// RUN instruction with optional `BuildKit` features
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct RunInstruction {
342    /// The command to run
343    pub command: ShellOrExec,
344
345    /// Optional mount specifications (`BuildKit`)
346    pub mounts: Vec<RunMount>,
347
348    /// Optional network mode (`BuildKit`)
349    pub network: Option<RunNetwork>,
350
351    /// Optional security mode (`BuildKit`)
352    pub security: Option<RunSecurity>,
353
354    /// Transient environment variables for the RUN. Emitted as `--env K=V` flags
355    /// on `buildah run`; intentionally NOT baked into the image's persistent
356    /// config. Empty by default.
357    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
358    pub env: HashMap<String, String>,
359}
360
361impl RunInstruction {
362    /// Create a new RUN instruction from a shell command
363    pub fn shell(cmd: impl Into<String>) -> Self {
364        Self {
365            command: ShellOrExec::Shell(cmd.into()),
366            mounts: Vec::new(),
367            network: None,
368            security: None,
369            env: HashMap::new(),
370        }
371    }
372
373    /// Create a new RUN instruction from exec form
374    #[must_use]
375    pub fn exec(args: Vec<String>) -> Self {
376        Self {
377            command: ShellOrExec::Exec(args),
378            mounts: Vec::new(),
379            network: None,
380            security: None,
381            env: HashMap::new(),
382        }
383    }
384}
385
386/// Mount types for RUN --mount
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub enum RunMount {
389    /// Bind mount from build context or another stage
390    Bind {
391        target: String,
392        source: Option<String>,
393        from: Option<String>,
394        readonly: bool,
395    },
396    /// Cache mount for build caches (e.g., package managers)
397    Cache {
398        target: String,
399        id: Option<String>,
400        sharing: CacheSharing,
401        readonly: bool,
402    },
403    /// Tmpfs mount
404    Tmpfs { target: String, size: Option<u64> },
405    /// Secret mount
406    Secret {
407        target: String,
408        id: String,
409        required: bool,
410    },
411    /// SSH mount for SSH agent forwarding
412    Ssh {
413        target: String,
414        id: Option<String>,
415        required: bool,
416    },
417}
418
419impl RunMount {
420    /// Convert the mount specification to a buildah `--mount` argument string.
421    ///
422    /// Returns a string in the format `type=<type>,<option>=<value>,...`
423    /// suitable for use with `buildah run --mount=<result>`.
424    ///
425    /// # Examples
426    ///
427    /// ```
428    /// use zlayer_builder::dockerfile::{RunMount, CacheSharing};
429    ///
430    /// let cache_mount = RunMount::Cache {
431    ///     target: "/var/cache/apt".to_string(),
432    ///     id: Some("apt-cache".to_string()),
433    ///     sharing: CacheSharing::Shared,
434    ///     readonly: false,
435    /// };
436    /// assert_eq!(
437    ///     cache_mount.to_buildah_arg(),
438    ///     "type=cache,target=/var/cache/apt,id=apt-cache,sharing=shared"
439    /// );
440    /// ```
441    #[must_use]
442    pub fn to_buildah_arg(&self) -> String {
443        match self {
444            Self::Bind {
445                target,
446                source,
447                from,
448                readonly,
449            } => {
450                let mut parts = vec![format!("type=bind,target={}", target)];
451                if let Some(src) = source {
452                    parts.push(format!("source={src}"));
453                }
454                if let Some(from_stage) = from {
455                    parts.push(format!("from={from_stage}"));
456                }
457                if *readonly {
458                    parts.push("ro".to_string());
459                }
460                parts.join(",")
461            }
462            Self::Cache {
463                target,
464                id,
465                sharing,
466                readonly,
467            } => {
468                let mut parts = vec![format!("type=cache,target={}", target)];
469                if let Some(cache_id) = id {
470                    parts.push(format!("id={cache_id}"));
471                }
472                // Only add sharing if not the default (locked)
473                if *sharing != CacheSharing::Locked {
474                    parts.push(format!("sharing={}", sharing.as_str()));
475                }
476                if *readonly {
477                    parts.push("ro".to_string());
478                }
479                parts.join(",")
480            }
481            Self::Tmpfs { target, size } => {
482                let mut parts = vec![format!("type=tmpfs,target={}", target)];
483                if let Some(sz) = size {
484                    parts.push(format!("tmpfs-size={sz}"));
485                }
486                parts.join(",")
487            }
488            Self::Secret {
489                target,
490                id,
491                required,
492            } => {
493                let mut parts = vec![format!("type=secret,id={}", id)];
494                // Only add target if it's not empty (some secrets use default paths)
495                if !target.is_empty() {
496                    parts.push(format!("target={target}"));
497                }
498                if *required {
499                    parts.push("required".to_string());
500                }
501                parts.join(",")
502            }
503            Self::Ssh {
504                target,
505                id,
506                required,
507            } => {
508                let mut parts = vec!["type=ssh".to_string()];
509                if let Some(ssh_id) = id {
510                    parts.push(format!("id={ssh_id}"));
511                }
512                if !target.is_empty() {
513                    parts.push(format!("target={target}"));
514                }
515                if *required {
516                    parts.push("required".to_string());
517                }
518                parts.join(",")
519            }
520        }
521    }
522}
523
524/// Cache sharing mode for RUN --mount=type=cache
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
526pub enum CacheSharing {
527    /// Only one build can access at a time
528    #[default]
529    Locked,
530    /// Multiple builds can access, last write wins
531    Shared,
532    /// Each build gets a private copy
533    Private,
534}
535
536impl CacheSharing {
537    /// Returns the string representation for buildah mount arguments
538    #[must_use]
539    pub fn as_str(&self) -> &'static str {
540        match self {
541            Self::Locked => "locked",
542            Self::Shared => "shared",
543            Self::Private => "private",
544        }
545    }
546}
547
548/// Network mode for RUN --network
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
550pub enum RunNetwork {
551    /// Use default network
552    Default,
553    /// No network access
554    None,
555    /// Use host network
556    Host,
557}
558
559/// Security mode for RUN --security
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
561pub enum RunSecurity {
562    /// Default security
563    Sandbox,
564    /// Insecure mode (privileged)
565    Insecure,
566}
567
568/// COPY instruction
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570pub struct CopyInstruction {
571    /// Source paths (relative to context or stage)
572    pub sources: Vec<String>,
573
574    /// Destination path in the image
575    pub destination: String,
576
577    /// Source stage for multi-stage builds (--from)
578    pub from: Option<String>,
579
580    /// Change ownership (--chown)
581    pub chown: Option<String>,
582
583    /// Change permissions (--chmod)
584    pub chmod: Option<String>,
585
586    /// Create hardlink instead of copying (--link)
587    pub link: bool,
588
589    /// Exclude patterns (--exclude)
590    pub exclude: Vec<String>,
591}
592
593impl CopyInstruction {
594    /// Create a new COPY instruction
595    #[must_use]
596    pub fn new(sources: Vec<String>, destination: String) -> Self {
597        Self {
598            sources,
599            destination,
600            from: None,
601            chown: None,
602            chmod: None,
603            link: false,
604            exclude: Vec::new(),
605        }
606    }
607
608    /// Set the source stage
609    #[must_use]
610    pub fn from_stage(mut self, stage: impl Into<String>) -> Self {
611        self.from = Some(stage.into());
612        self
613    }
614
615    /// Set ownership
616    #[must_use]
617    pub fn chown(mut self, owner: impl Into<String>) -> Self {
618        self.chown = Some(owner.into());
619        self
620    }
621}
622
623/// ADD instruction (similar to COPY but with URL and archive support)
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
625pub struct AddInstruction {
626    /// Source paths or URLs
627    pub sources: Vec<String>,
628
629    /// Destination path in the image
630    pub destination: String,
631
632    /// Change ownership (--chown)
633    pub chown: Option<String>,
634
635    /// Change permissions (--chmod)
636    pub chmod: Option<String>,
637
638    /// Create hardlink instead of copying (--link)
639    pub link: bool,
640
641    /// Checksum to verify remote URLs (--checksum)
642    pub checksum: Option<String>,
643
644    /// Keep UID/GID from archive (--keep-git-dir)
645    pub keep_git_dir: bool,
646}
647
648impl AddInstruction {
649    /// Create a new ADD instruction
650    #[must_use]
651    pub fn new(sources: Vec<String>, destination: String) -> Self {
652        Self {
653            sources,
654            destination,
655            chown: None,
656            chmod: None,
657            link: false,
658            checksum: None,
659            keep_git_dir: false,
660        }
661    }
662}
663
664/// ENV instruction
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
666pub struct EnvInstruction {
667    /// Environment variables to set
668    pub vars: HashMap<String, String>,
669}
670
671impl EnvInstruction {
672    /// Create a new ENV instruction with a single variable
673    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
674        let mut vars = HashMap::new();
675        vars.insert(key.into(), value.into());
676        Self { vars }
677    }
678
679    /// Create a new ENV instruction with multiple variables
680    #[must_use]
681    pub fn from_vars(vars: HashMap<String, String>) -> Self {
682        Self { vars }
683    }
684}
685
686/// EXPOSE instruction
687#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
688pub struct ExposeInstruction {
689    /// Port to expose
690    pub port: u16,
691
692    /// Protocol (tcp or udp)
693    pub protocol: ExposeProtocol,
694}
695
696impl ExposeInstruction {
697    /// Create a new TCP EXPOSE instruction
698    #[must_use]
699    pub fn tcp(port: u16) -> Self {
700        Self {
701            port,
702            protocol: ExposeProtocol::Tcp,
703        }
704    }
705
706    /// Create a new UDP EXPOSE instruction
707    #[must_use]
708    pub fn udp(port: u16) -> Self {
709        Self {
710            port,
711            protocol: ExposeProtocol::Udp,
712        }
713    }
714}
715
716/// Protocol for EXPOSE instruction
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
718pub enum ExposeProtocol {
719    #[default]
720    Tcp,
721    Udp,
722}
723
724/// ARG instruction
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726pub struct ArgInstruction {
727    /// Argument name
728    pub name: String,
729
730    /// Default value (if any)
731    pub default: Option<String>,
732}
733
734impl ArgInstruction {
735    /// Create a new ARG instruction
736    pub fn new(name: impl Into<String>) -> Self {
737        Self {
738            name: name.into(),
739            default: None,
740        }
741    }
742
743    /// Create a new ARG instruction with a default value
744    pub fn with_default(name: impl Into<String>, default: impl Into<String>) -> Self {
745        Self {
746            name: name.into(),
747            default: Some(default.into()),
748        }
749    }
750}
751
752/// HEALTHCHECK instruction
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754pub enum HealthcheckInstruction {
755    /// Disable healthcheck inherited from base image
756    None,
757
758    /// Configure healthcheck
759    Check {
760        /// Command to run for health check
761        command: ShellOrExec,
762
763        /// Interval between checks
764        interval: Option<std::time::Duration>,
765
766        /// Timeout for each check
767        timeout: Option<std::time::Duration>,
768
769        /// Start period before first check
770        start_period: Option<std::time::Duration>,
771
772        /// Start interval during start period
773        start_interval: Option<std::time::Duration>,
774
775        /// Number of retries before unhealthy
776        retries: Option<u32>,
777    },
778}
779
780impl HealthcheckInstruction {
781    /// Create a new CMD-style healthcheck
782    #[must_use]
783    pub fn cmd(command: ShellOrExec) -> Self {
784        Self::Check {
785            command,
786            interval: None,
787            timeout: None,
788            start_period: None,
789            start_interval: None,
790            retries: None,
791        }
792    }
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    #[test]
800    fn test_shell_or_exec() {
801        let shell = ShellOrExec::Shell("echo hello".to_string());
802        assert!(shell.is_shell());
803        assert!(!shell.is_exec());
804
805        let exec = ShellOrExec::Exec(vec!["echo".to_string(), "hello".to_string()]);
806        assert!(exec.is_exec());
807        assert!(!exec.is_shell());
808    }
809
810    #[test]
811    fn test_shell_to_exec_args() {
812        let shell = ShellOrExec::Shell("echo hello".to_string());
813        let default_shell = vec!["/bin/sh".to_string(), "-c".to_string()];
814        let args = shell.to_exec_args(&default_shell);
815        assert_eq!(
816            args,
817            vec!["/bin/sh", "-c", "echo hello"]
818                .into_iter()
819                .map(String::from)
820                .collect::<Vec<_>>()
821        );
822    }
823
824    #[test]
825    fn test_instruction_names() {
826        assert_eq!(
827            Instruction::Run(RunInstruction::shell("test")).name(),
828            "RUN"
829        );
830        assert_eq!(
831            Instruction::Copy(CopyInstruction::new(vec![], ".".into())).name(),
832            "COPY"
833        );
834        assert_eq!(Instruction::Workdir("/app".into()).name(), "WORKDIR");
835    }
836
837    #[test]
838    fn test_creates_layer() {
839        assert!(Instruction::Run(RunInstruction::shell("test")).creates_layer());
840        assert!(Instruction::Copy(CopyInstruction::new(vec![], ".".into())).creates_layer());
841        assert!(!Instruction::Env(EnvInstruction::new("KEY", "value")).creates_layer());
842        assert!(!Instruction::Workdir("/app".into()).creates_layer());
843    }
844
845    #[test]
846    fn test_copy_instruction_builder() {
847        let copy = CopyInstruction::new(vec!["src".into()], "/app".into())
848            .from_stage("builder")
849            .chown("1000:1000");
850
851        assert_eq!(copy.from, Some("builder".to_string()));
852        assert_eq!(copy.chown, Some("1000:1000".to_string()));
853    }
854
855    #[test]
856    fn test_arg_instruction() {
857        let arg = ArgInstruction::new("VERSION");
858        assert_eq!(arg.name, "VERSION");
859        assert!(arg.default.is_none());
860
861        let arg_with_default = ArgInstruction::with_default("VERSION", "1.0");
862        assert_eq!(arg_with_default.default, Some("1.0".to_string()));
863    }
864
865    #[test]
866    fn test_cache_sharing_as_str() {
867        assert_eq!(CacheSharing::Locked.as_str(), "locked");
868        assert_eq!(CacheSharing::Shared.as_str(), "shared");
869        assert_eq!(CacheSharing::Private.as_str(), "private");
870    }
871
872    #[test]
873    fn test_run_mount_cache_to_buildah_arg() {
874        let mount = RunMount::Cache {
875            target: "/var/cache/apt".to_string(),
876            id: Some("apt-cache".to_string()),
877            sharing: CacheSharing::Shared,
878            readonly: false,
879        };
880        assert_eq!(
881            mount.to_buildah_arg(),
882            "type=cache,target=/var/cache/apt,id=apt-cache,sharing=shared"
883        );
884
885        // Test with default sharing (locked) - should not include sharing
886        let mount_locked = RunMount::Cache {
887            target: "/cache".to_string(),
888            id: None,
889            sharing: CacheSharing::Locked,
890            readonly: false,
891        };
892        assert_eq!(mount_locked.to_buildah_arg(), "type=cache,target=/cache");
893
894        // Test readonly
895        let mount_ro = RunMount::Cache {
896            target: "/cache".to_string(),
897            id: Some("mycache".to_string()),
898            sharing: CacheSharing::Locked,
899            readonly: true,
900        };
901        assert_eq!(
902            mount_ro.to_buildah_arg(),
903            "type=cache,target=/cache,id=mycache,ro"
904        );
905    }
906
907    #[test]
908    fn test_run_mount_bind_to_buildah_arg() {
909        let mount = RunMount::Bind {
910            target: "/app".to_string(),
911            source: Some("/src".to_string()),
912            from: Some("builder".to_string()),
913            readonly: true,
914        };
915        assert_eq!(
916            mount.to_buildah_arg(),
917            "type=bind,target=/app,source=/src,from=builder,ro"
918        );
919
920        // Minimal bind mount
921        let mount_minimal = RunMount::Bind {
922            target: "/app".to_string(),
923            source: None,
924            from: None,
925            readonly: false,
926        };
927        assert_eq!(mount_minimal.to_buildah_arg(), "type=bind,target=/app");
928    }
929
930    #[test]
931    fn test_run_mount_tmpfs_to_buildah_arg() {
932        let mount = RunMount::Tmpfs {
933            target: "/tmp".to_string(),
934            size: Some(1_048_576),
935        };
936        assert_eq!(
937            mount.to_buildah_arg(),
938            "type=tmpfs,target=/tmp,tmpfs-size=1048576"
939        );
940
941        let mount_no_size = RunMount::Tmpfs {
942            target: "/tmp".to_string(),
943            size: None,
944        };
945        assert_eq!(mount_no_size.to_buildah_arg(), "type=tmpfs,target=/tmp");
946    }
947
948    #[test]
949    fn test_run_mount_secret_to_buildah_arg() {
950        let mount = RunMount::Secret {
951            target: "/run/secrets/mysecret".to_string(),
952            id: "mysecret".to_string(),
953            required: true,
954        };
955        assert_eq!(
956            mount.to_buildah_arg(),
957            "type=secret,id=mysecret,target=/run/secrets/mysecret,required"
958        );
959
960        // Without target (uses default)
961        let mount_no_target = RunMount::Secret {
962            target: String::new(),
963            id: "mysecret".to_string(),
964            required: false,
965        };
966        assert_eq!(mount_no_target.to_buildah_arg(), "type=secret,id=mysecret");
967    }
968
969    #[test]
970    fn test_run_mount_ssh_to_buildah_arg() {
971        let mount = RunMount::Ssh {
972            target: "/root/.ssh".to_string(),
973            id: Some("default".to_string()),
974            required: true,
975        };
976        assert_eq!(
977            mount.to_buildah_arg(),
978            "type=ssh,id=default,target=/root/.ssh,required"
979        );
980
981        // Minimal SSH mount
982        let mount_minimal = RunMount::Ssh {
983            target: String::new(),
984            id: None,
985            required: false,
986        };
987        assert_eq!(mount_minimal.to_buildah_arg(), "type=ssh");
988    }
989
990    #[test]
991    fn test_cache_key_length() {
992        // All cache keys should be 16 hex characters
993        let run = Instruction::Run(RunInstruction::shell("echo hello"));
994        assert_eq!(run.cache_key().len(), 16);
995
996        let copy = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/app".into()));
997        assert_eq!(copy.cache_key().len(), 16);
998
999        let workdir = Instruction::Workdir("/app".into());
1000        assert_eq!(workdir.cache_key().len(), 16);
1001    }
1002
1003    #[test]
1004    fn test_cache_key_deterministic() {
1005        // Same instruction should produce the same cache key
1006        let run1 = Instruction::Run(RunInstruction::shell("apt-get update"));
1007        let run2 = Instruction::Run(RunInstruction::shell("apt-get update"));
1008        assert_eq!(run1.cache_key(), run2.cache_key());
1009
1010        let copy1 = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/app".into()));
1011        let copy2 = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/app".into()));
1012        assert_eq!(copy1.cache_key(), copy2.cache_key());
1013    }
1014
1015    #[test]
1016    fn test_cache_key_different_for_different_instructions() {
1017        // Different instructions should produce different cache keys
1018        let run = Instruction::Run(RunInstruction::shell("echo hello"));
1019        let workdir = Instruction::Workdir("echo hello".into());
1020        assert_ne!(run.cache_key(), workdir.cache_key());
1021
1022        // Different commands should produce different cache keys
1023        let run1 = Instruction::Run(RunInstruction::shell("apt-get update"));
1024        let run2 = Instruction::Run(RunInstruction::shell("apt-get upgrade"));
1025        assert_ne!(run1.cache_key(), run2.cache_key());
1026
1027        // Same sources, different destinations
1028        let copy1 = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/app".into()));
1029        let copy2 = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/opt".into()));
1030        assert_ne!(copy1.cache_key(), copy2.cache_key());
1031    }
1032
1033    #[test]
1034    fn test_cache_key_shell_vs_exec() {
1035        // Shell form vs exec form should produce different keys even with similar commands
1036        let shell = Instruction::Run(RunInstruction::shell("echo hello"));
1037        let exec = Instruction::Run(RunInstruction::exec(vec![
1038            "echo".to_string(),
1039            "hello".to_string(),
1040        ]));
1041        assert_ne!(shell.cache_key(), exec.cache_key());
1042    }
1043
1044    #[test]
1045    fn test_cache_key_with_mounts() {
1046        // RUN with mounts should differ from RUN without mounts
1047        let run_no_mount = Instruction::Run(RunInstruction::shell("apt-get install -y curl"));
1048
1049        let mut run_with_mount = RunInstruction::shell("apt-get install -y curl");
1050        run_with_mount.mounts = vec![RunMount::Cache {
1051            target: "/var/cache/apt".to_string(),
1052            id: Some("apt-cache".to_string()),
1053            sharing: CacheSharing::Shared,
1054            readonly: false,
1055        }];
1056        let run_mounted = Instruction::Run(run_with_mount);
1057
1058        assert_ne!(run_no_mount.cache_key(), run_mounted.cache_key());
1059    }
1060
1061    #[test]
1062    fn test_cache_key_env_ordering() {
1063        // ENV with same variables in different insertion order should have same key
1064        // (because we sort keys before hashing)
1065        let mut vars1 = HashMap::new();
1066        vars1.insert("A".to_string(), "1".to_string());
1067        vars1.insert("B".to_string(), "2".to_string());
1068        let env1 = Instruction::Env(EnvInstruction::from_vars(vars1));
1069
1070        let mut vars2 = HashMap::new();
1071        vars2.insert("B".to_string(), "2".to_string());
1072        vars2.insert("A".to_string(), "1".to_string());
1073        let env2 = Instruction::Env(EnvInstruction::from_vars(vars2));
1074
1075        assert_eq!(env1.cache_key(), env2.cache_key());
1076    }
1077
1078    #[test]
1079    fn test_run_instruction_env_field_default_empty() {
1080        // Factory constructors initialise env to an empty map.
1081        let s = RunInstruction::shell("echo hi");
1082        assert!(s.env.is_empty());
1083
1084        let e = RunInstruction::exec(vec!["echo".to_string(), "hi".to_string()]);
1085        assert!(e.env.is_empty());
1086    }
1087
1088    #[test]
1089    fn test_cache_key_env_field_affects_hash_and_is_order_invariant() {
1090        // Two RUNs with the same env (different insertion order) hash equal.
1091        let mut env1 = HashMap::new();
1092        env1.insert("A".to_string(), "1".to_string());
1093        env1.insert("B".to_string(), "2".to_string());
1094        let r1 = RunInstruction {
1095            command: ShellOrExec::Shell("echo".to_string()),
1096            mounts: Vec::new(),
1097            network: None,
1098            security: None,
1099            env: env1,
1100        };
1101
1102        let mut env2 = HashMap::new();
1103        env2.insert("B".to_string(), "2".to_string());
1104        env2.insert("A".to_string(), "1".to_string());
1105        let r2 = RunInstruction {
1106            command: ShellOrExec::Shell("echo".to_string()),
1107            mounts: Vec::new(),
1108            network: None,
1109            security: None,
1110            env: env2,
1111        };
1112
1113        assert_eq!(
1114            Instruction::Run(r1.clone()).cache_key(),
1115            Instruction::Run(r2).cache_key()
1116        );
1117
1118        // A RUN with env differs from a RUN without env.
1119        let r_no_env = RunInstruction::shell("echo");
1120        assert_ne!(
1121            Instruction::Run(r1).cache_key(),
1122            Instruction::Run(r_no_env).cache_key()
1123        );
1124    }
1125
1126    #[test]
1127    fn test_cache_key_onbuild() {
1128        // ONBUILD instructions should incorporate the inner instruction's cache key
1129        let inner = RunInstruction::shell("echo hello");
1130        let onbuild = Instruction::Onbuild(Box::new(Instruction::Run(inner.clone())));
1131
1132        // The ONBUILD key should be different from the inner RUN key
1133        let run_key = Instruction::Run(inner).cache_key();
1134        let onbuild_key = onbuild.cache_key();
1135        assert_ne!(run_key, onbuild_key);
1136    }
1137
1138    #[test]
1139    fn test_cache_key_copy_with_options() {
1140        // COPY with --from should differ from COPY without
1141        let copy_simple = Instruction::Copy(CopyInstruction::new(
1142            vec!["binary".into()],
1143            "/app/binary".into(),
1144        ));
1145
1146        let copy_from = Instruction::Copy(
1147            CopyInstruction::new(vec!["binary".into()], "/app/binary".into()).from_stage("builder"),
1148        );
1149
1150        assert_ne!(copy_simple.cache_key(), copy_from.cache_key());
1151    }
1152}