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            }
190            Self::Copy(copy) => {
191                "COPY".hash(&mut hasher);
192                copy.sources.hash(&mut hasher);
193                copy.destination.hash(&mut hasher);
194                copy.from.hash(&mut hasher);
195                copy.chown.hash(&mut hasher);
196                copy.chmod.hash(&mut hasher);
197                copy.link.hash(&mut hasher);
198                copy.exclude.hash(&mut hasher);
199            }
200            Self::Add(add) => {
201                "ADD".hash(&mut hasher);
202                add.sources.hash(&mut hasher);
203                add.destination.hash(&mut hasher);
204                add.chown.hash(&mut hasher);
205                add.chmod.hash(&mut hasher);
206                add.link.hash(&mut hasher);
207                add.checksum.hash(&mut hasher);
208                add.keep_git_dir.hash(&mut hasher);
209            }
210            Self::Env(env) => {
211                "ENV".hash(&mut hasher);
212                // Sort keys for deterministic hashing
213                let mut keys: Vec<_> = env.vars.keys().collect();
214                keys.sort();
215                for key in keys {
216                    key.hash(&mut hasher);
217                    env.vars.get(key).hash(&mut hasher);
218                }
219            }
220            Self::Workdir(path) => {
221                "WORKDIR".hash(&mut hasher);
222                path.hash(&mut hasher);
223            }
224            Self::Expose(expose) => {
225                "EXPOSE".hash(&mut hasher);
226                expose.port.hash(&mut hasher);
227                format!("{:?}", expose.protocol).hash(&mut hasher);
228            }
229            Self::Label(labels) => {
230                "LABEL".hash(&mut hasher);
231                // Sort keys for deterministic hashing
232                let mut keys: Vec<_> = labels.keys().collect();
233                keys.sort();
234                for key in keys {
235                    key.hash(&mut hasher);
236                    labels.get(key).hash(&mut hasher);
237                }
238            }
239            Self::User(user) => {
240                "USER".hash(&mut hasher);
241                user.hash(&mut hasher);
242            }
243            Self::Entrypoint(cmd) => {
244                "ENTRYPOINT".hash(&mut hasher);
245                match cmd {
246                    ShellOrExec::Shell(s) => {
247                        "shell".hash(&mut hasher);
248                        s.hash(&mut hasher);
249                    }
250                    ShellOrExec::Exec(args) => {
251                        "exec".hash(&mut hasher);
252                        args.hash(&mut hasher);
253                    }
254                }
255            }
256            Self::Cmd(cmd) => {
257                "CMD".hash(&mut hasher);
258                match cmd {
259                    ShellOrExec::Shell(s) => {
260                        "shell".hash(&mut hasher);
261                        s.hash(&mut hasher);
262                    }
263                    ShellOrExec::Exec(args) => {
264                        "exec".hash(&mut hasher);
265                        args.hash(&mut hasher);
266                    }
267                }
268            }
269            Self::Volume(paths) => {
270                "VOLUME".hash(&mut hasher);
271                paths.hash(&mut hasher);
272            }
273            Self::Shell(shell) => {
274                "SHELL".hash(&mut hasher);
275                shell.hash(&mut hasher);
276            }
277            Self::Arg(arg) => {
278                "ARG".hash(&mut hasher);
279                arg.name.hash(&mut hasher);
280                arg.default.hash(&mut hasher);
281            }
282            Self::Stopsignal(signal) => {
283                "STOPSIGNAL".hash(&mut hasher);
284                signal.hash(&mut hasher);
285            }
286            Self::Healthcheck(health) => {
287                "HEALTHCHECK".hash(&mut hasher);
288                match health {
289                    HealthcheckInstruction::None => {
290                        "none".hash(&mut hasher);
291                    }
292                    HealthcheckInstruction::Check {
293                        command,
294                        interval,
295                        timeout,
296                        start_period,
297                        start_interval,
298                        retries,
299                    } => {
300                        "check".hash(&mut hasher);
301                        match command {
302                            ShellOrExec::Shell(s) => {
303                                "shell".hash(&mut hasher);
304                                s.hash(&mut hasher);
305                            }
306                            ShellOrExec::Exec(args) => {
307                                "exec".hash(&mut hasher);
308                                args.hash(&mut hasher);
309                            }
310                        }
311                        interval.map(|d| d.as_nanos()).hash(&mut hasher);
312                        timeout.map(|d| d.as_nanos()).hash(&mut hasher);
313                        start_period.map(|d| d.as_nanos()).hash(&mut hasher);
314                        start_interval.map(|d| d.as_nanos()).hash(&mut hasher);
315                        retries.hash(&mut hasher);
316                    }
317                }
318            }
319            Self::Onbuild(inner) => {
320                "ONBUILD".hash(&mut hasher);
321                // Recursively hash the inner instruction
322                inner.cache_key().hash(&mut hasher);
323            }
324        }
325
326        format!("{:016x}", hasher.finish())
327    }
328}
329
330/// RUN instruction with optional `BuildKit` features
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct RunInstruction {
333    /// The command to run
334    pub command: ShellOrExec,
335
336    /// Optional mount specifications (`BuildKit`)
337    pub mounts: Vec<RunMount>,
338
339    /// Optional network mode (`BuildKit`)
340    pub network: Option<RunNetwork>,
341
342    /// Optional security mode (`BuildKit`)
343    pub security: Option<RunSecurity>,
344}
345
346impl RunInstruction {
347    /// Create a new RUN instruction from a shell command
348    pub fn shell(cmd: impl Into<String>) -> Self {
349        Self {
350            command: ShellOrExec::Shell(cmd.into()),
351            mounts: Vec::new(),
352            network: None,
353            security: None,
354        }
355    }
356
357    /// Create a new RUN instruction from exec form
358    #[must_use]
359    pub fn exec(args: Vec<String>) -> Self {
360        Self {
361            command: ShellOrExec::Exec(args),
362            mounts: Vec::new(),
363            network: None,
364            security: None,
365        }
366    }
367}
368
369/// Mount types for RUN --mount
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub enum RunMount {
372    /// Bind mount from build context or another stage
373    Bind {
374        target: String,
375        source: Option<String>,
376        from: Option<String>,
377        readonly: bool,
378    },
379    /// Cache mount for build caches (e.g., package managers)
380    Cache {
381        target: String,
382        id: Option<String>,
383        sharing: CacheSharing,
384        readonly: bool,
385    },
386    /// Tmpfs mount
387    Tmpfs { target: String, size: Option<u64> },
388    /// Secret mount
389    Secret {
390        target: String,
391        id: String,
392        required: bool,
393    },
394    /// SSH mount for SSH agent forwarding
395    Ssh {
396        target: String,
397        id: Option<String>,
398        required: bool,
399    },
400}
401
402impl RunMount {
403    /// Convert the mount specification to a buildah `--mount` argument string.
404    ///
405    /// Returns a string in the format `type=<type>,<option>=<value>,...`
406    /// suitable for use with `buildah run --mount=<result>`.
407    ///
408    /// # Examples
409    ///
410    /// ```
411    /// use zlayer_builder::dockerfile::{RunMount, CacheSharing};
412    ///
413    /// let cache_mount = RunMount::Cache {
414    ///     target: "/var/cache/apt".to_string(),
415    ///     id: Some("apt-cache".to_string()),
416    ///     sharing: CacheSharing::Shared,
417    ///     readonly: false,
418    /// };
419    /// assert_eq!(
420    ///     cache_mount.to_buildah_arg(),
421    ///     "type=cache,target=/var/cache/apt,id=apt-cache,sharing=shared"
422    /// );
423    /// ```
424    #[must_use]
425    pub fn to_buildah_arg(&self) -> String {
426        match self {
427            Self::Bind {
428                target,
429                source,
430                from,
431                readonly,
432            } => {
433                let mut parts = vec![format!("type=bind,target={}", target)];
434                if let Some(src) = source {
435                    parts.push(format!("source={src}"));
436                }
437                if let Some(from_stage) = from {
438                    parts.push(format!("from={from_stage}"));
439                }
440                if *readonly {
441                    parts.push("ro".to_string());
442                }
443                parts.join(",")
444            }
445            Self::Cache {
446                target,
447                id,
448                sharing,
449                readonly,
450            } => {
451                let mut parts = vec![format!("type=cache,target={}", target)];
452                if let Some(cache_id) = id {
453                    parts.push(format!("id={cache_id}"));
454                }
455                // Only add sharing if not the default (locked)
456                if *sharing != CacheSharing::Locked {
457                    parts.push(format!("sharing={}", sharing.as_str()));
458                }
459                if *readonly {
460                    parts.push("ro".to_string());
461                }
462                parts.join(",")
463            }
464            Self::Tmpfs { target, size } => {
465                let mut parts = vec![format!("type=tmpfs,target={}", target)];
466                if let Some(sz) = size {
467                    parts.push(format!("tmpfs-size={sz}"));
468                }
469                parts.join(",")
470            }
471            Self::Secret {
472                target,
473                id,
474                required,
475            } => {
476                let mut parts = vec![format!("type=secret,id={}", id)];
477                // Only add target if it's not empty (some secrets use default paths)
478                if !target.is_empty() {
479                    parts.push(format!("target={target}"));
480                }
481                if *required {
482                    parts.push("required".to_string());
483                }
484                parts.join(",")
485            }
486            Self::Ssh {
487                target,
488                id,
489                required,
490            } => {
491                let mut parts = vec!["type=ssh".to_string()];
492                if let Some(ssh_id) = id {
493                    parts.push(format!("id={ssh_id}"));
494                }
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        }
504    }
505}
506
507/// Cache sharing mode for RUN --mount=type=cache
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
509pub enum CacheSharing {
510    /// Only one build can access at a time
511    #[default]
512    Locked,
513    /// Multiple builds can access, last write wins
514    Shared,
515    /// Each build gets a private copy
516    Private,
517}
518
519impl CacheSharing {
520    /// Returns the string representation for buildah mount arguments
521    #[must_use]
522    pub fn as_str(&self) -> &'static str {
523        match self {
524            Self::Locked => "locked",
525            Self::Shared => "shared",
526            Self::Private => "private",
527        }
528    }
529}
530
531/// Network mode for RUN --network
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
533pub enum RunNetwork {
534    /// Use default network
535    Default,
536    /// No network access
537    None,
538    /// Use host network
539    Host,
540}
541
542/// Security mode for RUN --security
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544pub enum RunSecurity {
545    /// Default security
546    Sandbox,
547    /// Insecure mode (privileged)
548    Insecure,
549}
550
551/// COPY instruction
552#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
553pub struct CopyInstruction {
554    /// Source paths (relative to context or stage)
555    pub sources: Vec<String>,
556
557    /// Destination path in the image
558    pub destination: String,
559
560    /// Source stage for multi-stage builds (--from)
561    pub from: Option<String>,
562
563    /// Change ownership (--chown)
564    pub chown: Option<String>,
565
566    /// Change permissions (--chmod)
567    pub chmod: Option<String>,
568
569    /// Create hardlink instead of copying (--link)
570    pub link: bool,
571
572    /// Exclude patterns (--exclude)
573    pub exclude: Vec<String>,
574}
575
576impl CopyInstruction {
577    /// Create a new COPY instruction
578    #[must_use]
579    pub fn new(sources: Vec<String>, destination: String) -> Self {
580        Self {
581            sources,
582            destination,
583            from: None,
584            chown: None,
585            chmod: None,
586            link: false,
587            exclude: Vec::new(),
588        }
589    }
590
591    /// Set the source stage
592    #[must_use]
593    pub fn from_stage(mut self, stage: impl Into<String>) -> Self {
594        self.from = Some(stage.into());
595        self
596    }
597
598    /// Set ownership
599    #[must_use]
600    pub fn chown(mut self, owner: impl Into<String>) -> Self {
601        self.chown = Some(owner.into());
602        self
603    }
604}
605
606/// ADD instruction (similar to COPY but with URL and archive support)
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608pub struct AddInstruction {
609    /// Source paths or URLs
610    pub sources: Vec<String>,
611
612    /// Destination path in the image
613    pub destination: String,
614
615    /// Change ownership (--chown)
616    pub chown: Option<String>,
617
618    /// Change permissions (--chmod)
619    pub chmod: Option<String>,
620
621    /// Create hardlink instead of copying (--link)
622    pub link: bool,
623
624    /// Checksum to verify remote URLs (--checksum)
625    pub checksum: Option<String>,
626
627    /// Keep UID/GID from archive (--keep-git-dir)
628    pub keep_git_dir: bool,
629}
630
631impl AddInstruction {
632    /// Create a new ADD instruction
633    #[must_use]
634    pub fn new(sources: Vec<String>, destination: String) -> Self {
635        Self {
636            sources,
637            destination,
638            chown: None,
639            chmod: None,
640            link: false,
641            checksum: None,
642            keep_git_dir: false,
643        }
644    }
645}
646
647/// ENV instruction
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649pub struct EnvInstruction {
650    /// Environment variables to set
651    pub vars: HashMap<String, String>,
652}
653
654impl EnvInstruction {
655    /// Create a new ENV instruction with a single variable
656    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
657        let mut vars = HashMap::new();
658        vars.insert(key.into(), value.into());
659        Self { vars }
660    }
661
662    /// Create a new ENV instruction with multiple variables
663    #[must_use]
664    pub fn from_vars(vars: HashMap<String, String>) -> Self {
665        Self { vars }
666    }
667}
668
669/// EXPOSE instruction
670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
671pub struct ExposeInstruction {
672    /// Port to expose
673    pub port: u16,
674
675    /// Protocol (tcp or udp)
676    pub protocol: ExposeProtocol,
677}
678
679impl ExposeInstruction {
680    /// Create a new TCP EXPOSE instruction
681    #[must_use]
682    pub fn tcp(port: u16) -> Self {
683        Self {
684            port,
685            protocol: ExposeProtocol::Tcp,
686        }
687    }
688
689    /// Create a new UDP EXPOSE instruction
690    #[must_use]
691    pub fn udp(port: u16) -> Self {
692        Self {
693            port,
694            protocol: ExposeProtocol::Udp,
695        }
696    }
697}
698
699/// Protocol for EXPOSE instruction
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
701pub enum ExposeProtocol {
702    #[default]
703    Tcp,
704    Udp,
705}
706
707/// ARG instruction
708#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
709pub struct ArgInstruction {
710    /// Argument name
711    pub name: String,
712
713    /// Default value (if any)
714    pub default: Option<String>,
715}
716
717impl ArgInstruction {
718    /// Create a new ARG instruction
719    pub fn new(name: impl Into<String>) -> Self {
720        Self {
721            name: name.into(),
722            default: None,
723        }
724    }
725
726    /// Create a new ARG instruction with a default value
727    pub fn with_default(name: impl Into<String>, default: impl Into<String>) -> Self {
728        Self {
729            name: name.into(),
730            default: Some(default.into()),
731        }
732    }
733}
734
735/// HEALTHCHECK instruction
736#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
737pub enum HealthcheckInstruction {
738    /// Disable healthcheck inherited from base image
739    None,
740
741    /// Configure healthcheck
742    Check {
743        /// Command to run for health check
744        command: ShellOrExec,
745
746        /// Interval between checks
747        interval: Option<std::time::Duration>,
748
749        /// Timeout for each check
750        timeout: Option<std::time::Duration>,
751
752        /// Start period before first check
753        start_period: Option<std::time::Duration>,
754
755        /// Start interval during start period
756        start_interval: Option<std::time::Duration>,
757
758        /// Number of retries before unhealthy
759        retries: Option<u32>,
760    },
761}
762
763impl HealthcheckInstruction {
764    /// Create a new CMD-style healthcheck
765    #[must_use]
766    pub fn cmd(command: ShellOrExec) -> Self {
767        Self::Check {
768            command,
769            interval: None,
770            timeout: None,
771            start_period: None,
772            start_interval: None,
773            retries: None,
774        }
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    #[test]
783    fn test_shell_or_exec() {
784        let shell = ShellOrExec::Shell("echo hello".to_string());
785        assert!(shell.is_shell());
786        assert!(!shell.is_exec());
787
788        let exec = ShellOrExec::Exec(vec!["echo".to_string(), "hello".to_string()]);
789        assert!(exec.is_exec());
790        assert!(!exec.is_shell());
791    }
792
793    #[test]
794    fn test_shell_to_exec_args() {
795        let shell = ShellOrExec::Shell("echo hello".to_string());
796        let default_shell = vec!["/bin/sh".to_string(), "-c".to_string()];
797        let args = shell.to_exec_args(&default_shell);
798        assert_eq!(
799            args,
800            vec!["/bin/sh", "-c", "echo hello"]
801                .into_iter()
802                .map(String::from)
803                .collect::<Vec<_>>()
804        );
805    }
806
807    #[test]
808    fn test_instruction_names() {
809        assert_eq!(
810            Instruction::Run(RunInstruction::shell("test")).name(),
811            "RUN"
812        );
813        assert_eq!(
814            Instruction::Copy(CopyInstruction::new(vec![], ".".into())).name(),
815            "COPY"
816        );
817        assert_eq!(Instruction::Workdir("/app".into()).name(), "WORKDIR");
818    }
819
820    #[test]
821    fn test_creates_layer() {
822        assert!(Instruction::Run(RunInstruction::shell("test")).creates_layer());
823        assert!(Instruction::Copy(CopyInstruction::new(vec![], ".".into())).creates_layer());
824        assert!(!Instruction::Env(EnvInstruction::new("KEY", "value")).creates_layer());
825        assert!(!Instruction::Workdir("/app".into()).creates_layer());
826    }
827
828    #[test]
829    fn test_copy_instruction_builder() {
830        let copy = CopyInstruction::new(vec!["src".into()], "/app".into())
831            .from_stage("builder")
832            .chown("1000:1000");
833
834        assert_eq!(copy.from, Some("builder".to_string()));
835        assert_eq!(copy.chown, Some("1000:1000".to_string()));
836    }
837
838    #[test]
839    fn test_arg_instruction() {
840        let arg = ArgInstruction::new("VERSION");
841        assert_eq!(arg.name, "VERSION");
842        assert!(arg.default.is_none());
843
844        let arg_with_default = ArgInstruction::with_default("VERSION", "1.0");
845        assert_eq!(arg_with_default.default, Some("1.0".to_string()));
846    }
847
848    #[test]
849    fn test_cache_sharing_as_str() {
850        assert_eq!(CacheSharing::Locked.as_str(), "locked");
851        assert_eq!(CacheSharing::Shared.as_str(), "shared");
852        assert_eq!(CacheSharing::Private.as_str(), "private");
853    }
854
855    #[test]
856    fn test_run_mount_cache_to_buildah_arg() {
857        let mount = RunMount::Cache {
858            target: "/var/cache/apt".to_string(),
859            id: Some("apt-cache".to_string()),
860            sharing: CacheSharing::Shared,
861            readonly: false,
862        };
863        assert_eq!(
864            mount.to_buildah_arg(),
865            "type=cache,target=/var/cache/apt,id=apt-cache,sharing=shared"
866        );
867
868        // Test with default sharing (locked) - should not include sharing
869        let mount_locked = RunMount::Cache {
870            target: "/cache".to_string(),
871            id: None,
872            sharing: CacheSharing::Locked,
873            readonly: false,
874        };
875        assert_eq!(mount_locked.to_buildah_arg(), "type=cache,target=/cache");
876
877        // Test readonly
878        let mount_ro = RunMount::Cache {
879            target: "/cache".to_string(),
880            id: Some("mycache".to_string()),
881            sharing: CacheSharing::Locked,
882            readonly: true,
883        };
884        assert_eq!(
885            mount_ro.to_buildah_arg(),
886            "type=cache,target=/cache,id=mycache,ro"
887        );
888    }
889
890    #[test]
891    fn test_run_mount_bind_to_buildah_arg() {
892        let mount = RunMount::Bind {
893            target: "/app".to_string(),
894            source: Some("/src".to_string()),
895            from: Some("builder".to_string()),
896            readonly: true,
897        };
898        assert_eq!(
899            mount.to_buildah_arg(),
900            "type=bind,target=/app,source=/src,from=builder,ro"
901        );
902
903        // Minimal bind mount
904        let mount_minimal = RunMount::Bind {
905            target: "/app".to_string(),
906            source: None,
907            from: None,
908            readonly: false,
909        };
910        assert_eq!(mount_minimal.to_buildah_arg(), "type=bind,target=/app");
911    }
912
913    #[test]
914    fn test_run_mount_tmpfs_to_buildah_arg() {
915        let mount = RunMount::Tmpfs {
916            target: "/tmp".to_string(),
917            size: Some(1_048_576),
918        };
919        assert_eq!(
920            mount.to_buildah_arg(),
921            "type=tmpfs,target=/tmp,tmpfs-size=1048576"
922        );
923
924        let mount_no_size = RunMount::Tmpfs {
925            target: "/tmp".to_string(),
926            size: None,
927        };
928        assert_eq!(mount_no_size.to_buildah_arg(), "type=tmpfs,target=/tmp");
929    }
930
931    #[test]
932    fn test_run_mount_secret_to_buildah_arg() {
933        let mount = RunMount::Secret {
934            target: "/run/secrets/mysecret".to_string(),
935            id: "mysecret".to_string(),
936            required: true,
937        };
938        assert_eq!(
939            mount.to_buildah_arg(),
940            "type=secret,id=mysecret,target=/run/secrets/mysecret,required"
941        );
942
943        // Without target (uses default)
944        let mount_no_target = RunMount::Secret {
945            target: String::new(),
946            id: "mysecret".to_string(),
947            required: false,
948        };
949        assert_eq!(mount_no_target.to_buildah_arg(), "type=secret,id=mysecret");
950    }
951
952    #[test]
953    fn test_run_mount_ssh_to_buildah_arg() {
954        let mount = RunMount::Ssh {
955            target: "/root/.ssh".to_string(),
956            id: Some("default".to_string()),
957            required: true,
958        };
959        assert_eq!(
960            mount.to_buildah_arg(),
961            "type=ssh,id=default,target=/root/.ssh,required"
962        );
963
964        // Minimal SSH mount
965        let mount_minimal = RunMount::Ssh {
966            target: String::new(),
967            id: None,
968            required: false,
969        };
970        assert_eq!(mount_minimal.to_buildah_arg(), "type=ssh");
971    }
972
973    #[test]
974    fn test_cache_key_length() {
975        // All cache keys should be 16 hex characters
976        let run = Instruction::Run(RunInstruction::shell("echo hello"));
977        assert_eq!(run.cache_key().len(), 16);
978
979        let copy = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/app".into()));
980        assert_eq!(copy.cache_key().len(), 16);
981
982        let workdir = Instruction::Workdir("/app".into());
983        assert_eq!(workdir.cache_key().len(), 16);
984    }
985
986    #[test]
987    fn test_cache_key_deterministic() {
988        // Same instruction should produce the same cache key
989        let run1 = Instruction::Run(RunInstruction::shell("apt-get update"));
990        let run2 = Instruction::Run(RunInstruction::shell("apt-get update"));
991        assert_eq!(run1.cache_key(), run2.cache_key());
992
993        let copy1 = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/app".into()));
994        let copy2 = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/app".into()));
995        assert_eq!(copy1.cache_key(), copy2.cache_key());
996    }
997
998    #[test]
999    fn test_cache_key_different_for_different_instructions() {
1000        // Different instructions should produce different cache keys
1001        let run = Instruction::Run(RunInstruction::shell("echo hello"));
1002        let workdir = Instruction::Workdir("echo hello".into());
1003        assert_ne!(run.cache_key(), workdir.cache_key());
1004
1005        // Different commands should produce different cache keys
1006        let run1 = Instruction::Run(RunInstruction::shell("apt-get update"));
1007        let run2 = Instruction::Run(RunInstruction::shell("apt-get upgrade"));
1008        assert_ne!(run1.cache_key(), run2.cache_key());
1009
1010        // Same sources, different destinations
1011        let copy1 = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/app".into()));
1012        let copy2 = Instruction::Copy(CopyInstruction::new(vec!["src".into()], "/opt".into()));
1013        assert_ne!(copy1.cache_key(), copy2.cache_key());
1014    }
1015
1016    #[test]
1017    fn test_cache_key_shell_vs_exec() {
1018        // Shell form vs exec form should produce different keys even with similar commands
1019        let shell = Instruction::Run(RunInstruction::shell("echo hello"));
1020        let exec = Instruction::Run(RunInstruction::exec(vec![
1021            "echo".to_string(),
1022            "hello".to_string(),
1023        ]));
1024        assert_ne!(shell.cache_key(), exec.cache_key());
1025    }
1026
1027    #[test]
1028    fn test_cache_key_with_mounts() {
1029        // RUN with mounts should differ from RUN without mounts
1030        let run_no_mount = Instruction::Run(RunInstruction::shell("apt-get install -y curl"));
1031
1032        let mut run_with_mount = RunInstruction::shell("apt-get install -y curl");
1033        run_with_mount.mounts = vec![RunMount::Cache {
1034            target: "/var/cache/apt".to_string(),
1035            id: Some("apt-cache".to_string()),
1036            sharing: CacheSharing::Shared,
1037            readonly: false,
1038        }];
1039        let run_mounted = Instruction::Run(run_with_mount);
1040
1041        assert_ne!(run_no_mount.cache_key(), run_mounted.cache_key());
1042    }
1043
1044    #[test]
1045    fn test_cache_key_env_ordering() {
1046        // ENV with same variables in different insertion order should have same key
1047        // (because we sort keys before hashing)
1048        let mut vars1 = HashMap::new();
1049        vars1.insert("A".to_string(), "1".to_string());
1050        vars1.insert("B".to_string(), "2".to_string());
1051        let env1 = Instruction::Env(EnvInstruction::from_vars(vars1));
1052
1053        let mut vars2 = HashMap::new();
1054        vars2.insert("B".to_string(), "2".to_string());
1055        vars2.insert("A".to_string(), "1".to_string());
1056        let env2 = Instruction::Env(EnvInstruction::from_vars(vars2));
1057
1058        assert_eq!(env1.cache_key(), env2.cache_key());
1059    }
1060
1061    #[test]
1062    fn test_cache_key_onbuild() {
1063        // ONBUILD instructions should incorporate the inner instruction's cache key
1064        let inner = RunInstruction::shell("echo hello");
1065        let onbuild = Instruction::Onbuild(Box::new(Instruction::Run(inner.clone())));
1066
1067        // The ONBUILD key should be different from the inner RUN key
1068        let run_key = Instruction::Run(inner).cache_key();
1069        let onbuild_key = onbuild.cache_key();
1070        assert_ne!(run_key, onbuild_key);
1071    }
1072
1073    #[test]
1074    fn test_cache_key_copy_with_options() {
1075        // COPY with --from should differ from COPY without
1076        let copy_simple = Instruction::Copy(CopyInstruction::new(
1077            vec!["binary".into()],
1078            "/app/binary".into(),
1079        ));
1080
1081        let copy_from = Instruction::Copy(
1082            CopyInstruction::new(vec!["binary".into()], "/app/binary".into()).from_stage("builder"),
1083        );
1084
1085        assert_ne!(copy_simple.cache_key(), copy_from.cache_key());
1086    }
1087}