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