syncable_cli/analyzer/hadolint/parser/
instruction.rs

1//! Dockerfile instruction AST types.
2//!
3//! These types represent the parsed structure of a Dockerfile,
4//! matching the Haskell `language-docker` library for compatibility.
5
6use std::collections::HashSet;
7
8/// A positioned instruction with source location information.
9#[derive(Debug, Clone, PartialEq)]
10pub struct InstructionPos {
11    /// The parsed instruction.
12    pub instruction: Instruction,
13    /// Line number (1-indexed).
14    pub line_number: u32,
15    /// Original source text of the instruction.
16    pub source_text: String,
17}
18
19impl InstructionPos {
20    /// Create a new positioned instruction.
21    pub fn new(instruction: Instruction, line_number: u32, source_text: String) -> Self {
22        Self {
23            instruction,
24            line_number,
25            source_text,
26        }
27    }
28}
29
30/// Dockerfile instructions.
31#[derive(Debug, Clone, PartialEq)]
32pub enum Instruction {
33    /// FROM instruction
34    From(BaseImage),
35    /// RUN instruction
36    Run(RunArgs),
37    /// COPY instruction
38    Copy(CopyArgs, CopyFlags),
39    /// ADD instruction
40    Add(AddArgs, AddFlags),
41    /// ENV instruction
42    Env(Vec<(String, String)>),
43    /// LABEL instruction
44    Label(Vec<(String, String)>),
45    /// EXPOSE instruction
46    Expose(Vec<Port>),
47    /// ARG instruction
48    Arg(String, Option<String>),
49    /// ENTRYPOINT instruction
50    Entrypoint(Arguments),
51    /// CMD instruction
52    Cmd(Arguments),
53    /// SHELL instruction
54    Shell(Arguments),
55    /// USER instruction
56    User(String),
57    /// WORKDIR instruction
58    Workdir(String),
59    /// VOLUME instruction
60    Volume(String),
61    /// MAINTAINER instruction (deprecated)
62    Maintainer(String),
63    /// HEALTHCHECK instruction
64    Healthcheck(HealthCheck),
65    /// ONBUILD instruction (wraps another instruction)
66    OnBuild(Box<Instruction>),
67    /// STOPSIGNAL instruction
68    Stopsignal(String),
69    /// Comment line
70    Comment(String),
71}
72
73impl Instruction {
74    /// Check if this is a FROM instruction.
75    pub fn is_from(&self) -> bool {
76        matches!(self, Self::From(_))
77    }
78
79    /// Check if this is a RUN instruction.
80    pub fn is_run(&self) -> bool {
81        matches!(self, Self::Run(_))
82    }
83
84    /// Check if this is a COPY instruction.
85    pub fn is_copy(&self) -> bool {
86        matches!(self, Self::Copy(_, _))
87    }
88
89    /// Check if this is an ONBUILD instruction.
90    pub fn is_onbuild(&self) -> bool {
91        matches!(self, Self::OnBuild(_))
92    }
93
94    /// Get the wrapped instruction if this is ONBUILD.
95    pub fn unwrap_onbuild(&self) -> Option<&Instruction> {
96        match self {
97            Self::OnBuild(inner) => Some(inner.as_ref()),
98            _ => None,
99        }
100    }
101}
102
103/// Base image in FROM instruction.
104#[derive(Debug, Clone, PartialEq)]
105pub struct BaseImage {
106    /// The image reference.
107    pub image: Image,
108    /// Image tag (e.g., "latest", "3.9").
109    pub tag: Option<String>,
110    /// Image digest (e.g., "sha256:...").
111    pub digest: Option<String>,
112    /// Stage alias (AS name).
113    pub alias: Option<ImageAlias>,
114    /// Target platform (--platform=...).
115    pub platform: Option<String>,
116}
117
118impl BaseImage {
119    /// Create a new base image with just a name.
120    pub fn new(name: impl Into<String>) -> Self {
121        Self {
122            image: Image::new(name),
123            tag: None,
124            digest: None,
125            alias: None,
126            platform: None,
127        }
128    }
129
130    /// Check if the image uses a variable reference.
131    pub fn is_variable(&self) -> bool {
132        self.image.name.starts_with('$')
133    }
134
135    /// Check if this is the scratch image.
136    pub fn is_scratch(&self) -> bool {
137        self.image.name.eq_ignore_ascii_case("scratch")
138    }
139
140    /// Check if the image has an explicit tag or digest.
141    pub fn has_version(&self) -> bool {
142        self.tag.is_some() || self.digest.is_some()
143    }
144}
145
146/// Docker image reference.
147#[derive(Debug, Clone, PartialEq)]
148pub struct Image {
149    /// Optional registry (e.g., "docker.io", "gcr.io").
150    pub registry: Option<String>,
151    /// Image name (e.g., "ubuntu", "library/ubuntu").
152    pub name: String,
153}
154
155impl Image {
156    /// Create a new image with just a name.
157    pub fn new(name: impl Into<String>) -> Self {
158        Self {
159            registry: None,
160            name: name.into(),
161        }
162    }
163
164    /// Create a new image with registry.
165    pub fn with_registry(registry: impl Into<String>, name: impl Into<String>) -> Self {
166        Self {
167            registry: Some(registry.into()),
168            name: name.into(),
169        }
170    }
171
172    /// Get the full image reference.
173    pub fn full_name(&self) -> String {
174        match &self.registry {
175            Some(reg) => format!("{}/{}", reg, self.name),
176            None => self.name.clone(),
177        }
178    }
179}
180
181/// Image alias (AS name in FROM).
182#[derive(Debug, Clone, PartialEq, Eq, Hash)]
183pub struct ImageAlias(pub String);
184
185impl ImageAlias {
186    /// Create a new image alias.
187    pub fn new(name: impl Into<String>) -> Self {
188        Self(name.into())
189    }
190
191    /// Get the alias name.
192    pub fn as_str(&self) -> &str {
193        &self.0
194    }
195}
196
197/// RUN instruction arguments.
198#[derive(Debug, Clone, PartialEq)]
199pub struct RunArgs {
200    /// The command arguments.
201    pub arguments: Arguments,
202    /// RUN flags (--mount, --network, etc.).
203    pub flags: RunFlags,
204}
205
206impl RunArgs {
207    /// Create a new RUN with shell form.
208    pub fn shell(cmd: impl Into<String>) -> Self {
209        Self {
210            arguments: Arguments::Text(cmd.into()),
211            flags: RunFlags::default(),
212        }
213    }
214
215    /// Create a new RUN with exec form.
216    pub fn exec(args: Vec<String>) -> Self {
217        Self {
218            arguments: Arguments::List(args),
219            flags: RunFlags::default(),
220        }
221    }
222}
223
224/// RUN instruction flags.
225#[derive(Debug, Clone, PartialEq, Default)]
226pub struct RunFlags {
227    /// Mount options (--mount=...).
228    pub mount: HashSet<RunMount>,
229    /// Network mode (--network=...).
230    pub network: Option<String>,
231    /// Security mode (--security=...).
232    pub security: Option<String>,
233}
234
235/// RUN mount types.
236#[derive(Debug, Clone, PartialEq, Eq, Hash)]
237pub enum RunMount {
238    /// Bind mount
239    Bind(BindOpts),
240    /// Cache mount
241    Cache(CacheOpts),
242    /// Tmpfs mount
243    Tmpfs(TmpOpts),
244    /// Secret mount
245    Secret(SecretOpts),
246    /// SSH mount
247    Ssh(SshOpts),
248}
249
250/// Bind mount options.
251#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
252pub struct BindOpts {
253    pub target: Option<String>,
254    pub source: Option<String>,
255    pub from: Option<String>,
256    pub read_only: bool,
257}
258
259/// Cache mount options.
260#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
261pub struct CacheOpts {
262    pub target: Option<String>,
263    pub id: Option<String>,
264    pub sharing: Option<String>,
265    pub from: Option<String>,
266    pub source: Option<String>,
267    pub mode: Option<String>,
268    pub uid: Option<u32>,
269    pub gid: Option<u32>,
270    pub read_only: bool,
271}
272
273/// Tmpfs mount options.
274#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
275pub struct TmpOpts {
276    pub target: Option<String>,
277    pub size: Option<String>,
278}
279
280/// Secret mount options.
281#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
282pub struct SecretOpts {
283    pub id: Option<String>,
284    pub target: Option<String>,
285    pub required: bool,
286    pub mode: Option<String>,
287    pub uid: Option<u32>,
288    pub gid: Option<u32>,
289}
290
291/// SSH mount options.
292#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
293pub struct SshOpts {
294    pub id: Option<String>,
295    pub target: Option<String>,
296    pub required: bool,
297    pub mode: Option<String>,
298    pub uid: Option<u32>,
299    pub gid: Option<u32>,
300}
301
302/// COPY instruction arguments.
303#[derive(Debug, Clone, PartialEq)]
304pub struct CopyArgs {
305    /// Source paths.
306    pub sources: Vec<String>,
307    /// Destination path.
308    pub dest: String,
309}
310
311impl CopyArgs {
312    /// Create new copy args.
313    pub fn new(sources: Vec<String>, dest: impl Into<String>) -> Self {
314        Self {
315            sources,
316            dest: dest.into(),
317        }
318    }
319}
320
321/// COPY instruction flags.
322#[derive(Debug, Clone, PartialEq, Default)]
323pub struct CopyFlags {
324    /// --from=<stage>
325    pub from: Option<String>,
326    /// --chown=<user:group>
327    pub chown: Option<String>,
328    /// --chmod=<mode>
329    pub chmod: Option<String>,
330    /// --link
331    pub link: bool,
332}
333
334/// ADD instruction arguments.
335#[derive(Debug, Clone, PartialEq)]
336pub struct AddArgs {
337    /// Source paths/URLs.
338    pub sources: Vec<String>,
339    /// Destination path.
340    pub dest: String,
341}
342
343impl AddArgs {
344    /// Create new add args.
345    pub fn new(sources: Vec<String>, dest: impl Into<String>) -> Self {
346        Self {
347            sources,
348            dest: dest.into(),
349        }
350    }
351
352    /// Check if any source is a URL.
353    pub fn has_url(&self) -> bool {
354        self.sources.iter().any(|s| s.starts_with("http://") || s.starts_with("https://"))
355    }
356
357    /// Check if any source appears to be an archive.
358    pub fn has_archive(&self) -> bool {
359        const ARCHIVE_EXTENSIONS: &[&str] = &[
360            ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz",
361            ".zip", ".gz", ".bz2", ".xz", ".Z", ".lz", ".lzma",
362        ];
363        self.sources.iter().any(|s| {
364            ARCHIVE_EXTENSIONS.iter().any(|ext| s.ends_with(ext))
365        })
366    }
367}
368
369/// ADD instruction flags.
370#[derive(Debug, Clone, PartialEq, Default)]
371pub struct AddFlags {
372    /// --chown=<user:group>
373    pub chown: Option<String>,
374    /// --chmod=<mode>
375    pub chmod: Option<String>,
376    /// --link
377    pub link: bool,
378    /// --checksum=<checksum>
379    pub checksum: Option<String>,
380}
381
382/// Port specification.
383#[derive(Debug, Clone, PartialEq, Eq, Hash)]
384pub struct Port {
385    /// Port number.
386    pub number: u16,
387    /// Protocol (tcp/udp).
388    pub protocol: PortProtocol,
389}
390
391impl Port {
392    /// Create a TCP port.
393    pub fn tcp(number: u16) -> Self {
394        Self {
395            number,
396            protocol: PortProtocol::Tcp,
397        }
398    }
399
400    /// Create a UDP port.
401    pub fn udp(number: u16) -> Self {
402        Self {
403            number,
404            protocol: PortProtocol::Udp,
405        }
406    }
407}
408
409/// Port protocol.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
411pub enum PortProtocol {
412    #[default]
413    Tcp,
414    Udp,
415}
416
417/// Arguments in shell form or exec form.
418#[derive(Debug, Clone, PartialEq)]
419pub enum Arguments {
420    /// Shell form: RUN apt-get update
421    Text(String),
422    /// Exec form: RUN ["apt-get", "update"]
423    List(Vec<String>),
424}
425
426impl Arguments {
427    /// Check if this is shell form.
428    pub fn is_shell_form(&self) -> bool {
429        matches!(self, Self::Text(_))
430    }
431
432    /// Check if this is exec form.
433    pub fn is_exec_form(&self) -> bool {
434        matches!(self, Self::List(_))
435    }
436
437    /// Get the text if shell form.
438    pub fn as_text(&self) -> Option<&str> {
439        match self {
440            Self::Text(s) => Some(s),
441            _ => None,
442        }
443    }
444
445    /// Get the list if exec form.
446    pub fn as_list(&self) -> Option<&[String]> {
447        match self {
448            Self::List(v) => Some(v),
449            _ => None,
450        }
451    }
452
453    /// Convert to a single string (for shell form, returns as-is; for exec form, joins with spaces).
454    pub fn to_string_lossy(&self) -> String {
455        match self {
456            Self::Text(s) => s.clone(),
457            Self::List(v) => v.join(" "),
458        }
459    }
460}
461
462/// HEALTHCHECK instruction.
463#[derive(Debug, Clone, PartialEq)]
464pub enum HealthCheck {
465    /// HEALTHCHECK NONE
466    None,
467    /// HEALTHCHECK CMD ...
468    Cmd {
469        /// The command to run.
470        cmd: Arguments,
471        /// Interval between checks.
472        interval: Option<String>,
473        /// Timeout for each check.
474        timeout: Option<String>,
475        /// Start period before checks begin.
476        start_period: Option<String>,
477        /// Number of retries before unhealthy.
478        retries: Option<u32>,
479    },
480}
481
482impl HealthCheck {
483    /// Create a HEALTHCHECK CMD with defaults.
484    pub fn cmd(cmd: Arguments) -> Self {
485        Self::Cmd {
486            cmd,
487            interval: None,
488            timeout: None,
489            start_period: None,
490            retries: None,
491        }
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_base_image() {
501        let img = BaseImage::new("ubuntu");
502        assert!(!img.is_scratch());
503        assert!(!img.is_variable());
504        assert!(!img.has_version());
505
506        let scratch = BaseImage::new("scratch");
507        assert!(scratch.is_scratch());
508
509        let var = BaseImage::new("${BASE_IMAGE}");
510        assert!(var.is_variable());
511
512        let tagged = BaseImage {
513            tag: Some("20.04".to_string()),
514            ..BaseImage::new("ubuntu")
515        };
516        assert!(tagged.has_version());
517    }
518
519    #[test]
520    fn test_image() {
521        let img = Image::new("ubuntu");
522        assert_eq!(img.full_name(), "ubuntu");
523
524        let img_with_reg = Image::with_registry("gcr.io", "my-project/my-image");
525        assert_eq!(img_with_reg.full_name(), "gcr.io/my-project/my-image");
526    }
527
528    #[test]
529    fn test_arguments() {
530        let shell = Arguments::Text("apt-get update".to_string());
531        assert!(shell.is_shell_form());
532        assert_eq!(shell.as_text(), Some("apt-get update"));
533
534        let exec = Arguments::List(vec!["apt-get".to_string(), "update".to_string()]);
535        assert!(exec.is_exec_form());
536        assert_eq!(exec.as_list(), Some(&["apt-get".to_string(), "update".to_string()][..]));
537    }
538
539    #[test]
540    fn test_add_args() {
541        let add = AddArgs::new(vec!["app.tar.gz".to_string()], "/app");
542        assert!(add.has_archive());
543        assert!(!add.has_url());
544
545        let add_url = AddArgs::new(vec!["https://example.com/file.txt".to_string()], "/app");
546        assert!(add_url.has_url());
547        assert!(!add_url.has_archive());
548    }
549}