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
355            .iter()
356            .any(|s| s.starts_with("http://") || s.starts_with("https://"))
357    }
358
359    /// Check if any source appears to be an archive.
360    pub fn has_archive(&self) -> bool {
361        const ARCHIVE_EXTENSIONS: &[&str] = &[
362            ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz", ".zip", ".gz",
363            ".bz2", ".xz", ".Z", ".lz", ".lzma",
364        ];
365        self.sources
366            .iter()
367            .any(|s| ARCHIVE_EXTENSIONS.iter().any(|ext| s.ends_with(ext)))
368    }
369}
370
371/// ADD instruction flags.
372#[derive(Debug, Clone, PartialEq, Default)]
373pub struct AddFlags {
374    /// --chown=<user:group>
375    pub chown: Option<String>,
376    /// --chmod=<mode>
377    pub chmod: Option<String>,
378    /// --link
379    pub link: bool,
380    /// --checksum=<checksum>
381    pub checksum: Option<String>,
382}
383
384/// Port specification.
385#[derive(Debug, Clone, PartialEq, Eq, Hash)]
386pub struct Port {
387    /// Port number.
388    pub number: u16,
389    /// Protocol (tcp/udp).
390    pub protocol: PortProtocol,
391}
392
393impl Port {
394    /// Create a TCP port.
395    pub fn tcp(number: u16) -> Self {
396        Self {
397            number,
398            protocol: PortProtocol::Tcp,
399        }
400    }
401
402    /// Create a UDP port.
403    pub fn udp(number: u16) -> Self {
404        Self {
405            number,
406            protocol: PortProtocol::Udp,
407        }
408    }
409}
410
411/// Port protocol.
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
413pub enum PortProtocol {
414    #[default]
415    Tcp,
416    Udp,
417}
418
419/// Arguments in shell form or exec form.
420#[derive(Debug, Clone, PartialEq)]
421pub enum Arguments {
422    /// Shell form: RUN apt-get update
423    Text(String),
424    /// Exec form: RUN ["apt-get", "update"]
425    List(Vec<String>),
426}
427
428impl Arguments {
429    /// Check if this is shell form.
430    pub fn is_shell_form(&self) -> bool {
431        matches!(self, Self::Text(_))
432    }
433
434    /// Check if this is exec form.
435    pub fn is_exec_form(&self) -> bool {
436        matches!(self, Self::List(_))
437    }
438
439    /// Get the text if shell form.
440    pub fn as_text(&self) -> Option<&str> {
441        match self {
442            Self::Text(s) => Some(s),
443            _ => None,
444        }
445    }
446
447    /// Get the list if exec form.
448    pub fn as_list(&self) -> Option<&[String]> {
449        match self {
450            Self::List(v) => Some(v),
451            _ => None,
452        }
453    }
454
455    /// Convert to a single string (for shell form, returns as-is; for exec form, joins with spaces).
456    pub fn to_string_lossy(&self) -> String {
457        match self {
458            Self::Text(s) => s.clone(),
459            Self::List(v) => v.join(" "),
460        }
461    }
462}
463
464/// HEALTHCHECK instruction.
465#[derive(Debug, Clone, PartialEq)]
466pub enum HealthCheck {
467    /// HEALTHCHECK NONE
468    None,
469    /// HEALTHCHECK CMD ...
470    Cmd {
471        /// The command to run.
472        cmd: Arguments,
473        /// Interval between checks.
474        interval: Option<String>,
475        /// Timeout for each check.
476        timeout: Option<String>,
477        /// Start period before checks begin.
478        start_period: Option<String>,
479        /// Number of retries before unhealthy.
480        retries: Option<u32>,
481    },
482}
483
484impl HealthCheck {
485    /// Create a HEALTHCHECK CMD with defaults.
486    pub fn cmd(cmd: Arguments) -> Self {
487        Self::Cmd {
488            cmd,
489            interval: None,
490            timeout: None,
491            start_period: None,
492            retries: None,
493        }
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn test_base_image() {
503        let img = BaseImage::new("ubuntu");
504        assert!(!img.is_scratch());
505        assert!(!img.is_variable());
506        assert!(!img.has_version());
507
508        let scratch = BaseImage::new("scratch");
509        assert!(scratch.is_scratch());
510
511        let var = BaseImage::new("${BASE_IMAGE}");
512        assert!(var.is_variable());
513
514        let tagged = BaseImage {
515            tag: Some("20.04".to_string()),
516            ..BaseImage::new("ubuntu")
517        };
518        assert!(tagged.has_version());
519    }
520
521    #[test]
522    fn test_image() {
523        let img = Image::new("ubuntu");
524        assert_eq!(img.full_name(), "ubuntu");
525
526        let img_with_reg = Image::with_registry("gcr.io", "my-project/my-image");
527        assert_eq!(img_with_reg.full_name(), "gcr.io/my-project/my-image");
528    }
529
530    #[test]
531    fn test_arguments() {
532        let shell = Arguments::Text("apt-get update".to_string());
533        assert!(shell.is_shell_form());
534        assert_eq!(shell.as_text(), Some("apt-get update"));
535
536        let exec = Arguments::List(vec!["apt-get".to_string(), "update".to_string()]);
537        assert!(exec.is_exec_form());
538        assert_eq!(
539            exec.as_list(),
540            Some(&["apt-get".to_string(), "update".to_string()][..])
541        );
542    }
543
544    #[test]
545    fn test_add_args() {
546        let add = AddArgs::new(vec!["app.tar.gz".to_string()], "/app");
547        assert!(add.has_archive());
548        assert!(!add.has_url());
549
550        let add_url = AddArgs::new(vec!["https://example.com/file.txt".to_string()], "/app");
551        assert!(add_url.has_url());
552        assert!(!add_url.has_archive());
553    }
554}