dofigen_lib/
linter.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::dofigen_struct::*;
4
5#[derive(Debug, Clone, PartialEq)]
6struct StageDependency {
7    stage: String,
8    path: String,
9    origin: Vec<String>,
10}
11
12macro_rules! linter_path {
13    ($session:expr_2021, $part:expr_2021, $block:block) => {
14        $session.push_path_part($part);
15        $block
16        $session.pop_path_part();
17    };
18}
19
20trait Linter {
21    fn analyze(&self, session: &mut LintSession);
22}
23
24impl Linter for Dofigen {
25    fn analyze(&self, session: &mut LintSession) {
26        linter_path!(session, "builders".into(), {
27            for (name, builder) in self.builders.iter() {
28                linter_path!(session, name.clone(), {
29                    if name == "runtime" {
30                        session.add_message(
31                            MessageLevel::Error,
32                            "The builder name 'runtime' is reserved".into(),
33                        );
34                    }
35                    builder.analyze(session);
36                });
37            }
38        });
39
40        self.stage.analyze(session);
41
42        // Check root user in runtime stage
43        if let Some(user) = &self.stage.user {
44            if user.user == "root" || user.uid() == Some(0) {
45                session.messages.push(LintMessage {
46                    level: MessageLevel::Warn,
47                    message: "The runtime user should not be root".into(),
48                    path: vec!["user".into()],
49                });
50            }
51        }
52
53        session.check_dependencies();
54    }
55}
56
57impl Linter for Stage {
58    fn analyze(&self, session: &mut LintSession) {
59        let name = session.current_path.last().cloned();
60
61        // Check empty stage
62        if let Some(name) = name.clone() {
63            if self.copy.is_empty() && self.run.run.is_empty() && self.root.is_none() {
64                session.add_message(
65                    MessageLevel::Warn,
66                    format!("The builder '{}' is empty and should be removed", name),
67                );
68            }
69        }
70
71        let name = name.unwrap_or("runtime".to_string());
72
73        let dependencies = self.get_dependencies(&session.current_path);
74        session.messages.append(
75            &mut dependencies
76                .iter()
77                .filter(|dep| dep.stage == "runtime")
78                .map(|dep| LintMessage {
79                    level: MessageLevel::Error,
80                    message: format!("The stage '{}' can't depend on the 'runtime'", &name,),
81                    path: dep.origin.clone(),
82                })
83                .collect(),
84        );
85        let cache_paths = session.get_stage_cache_paths(self);
86        session.stage_infos.insert(
87            name,
88            StageLintInfo {
89                dependencies,
90                cache_paths,
91            },
92        );
93
94        // Check the use of fromContext
95        self.from.analyze(session);
96
97        linter_path!(session, "copy".into(), {
98            for (position, copy) in self.copy.iter().enumerate() {
99                linter_path!(session, position.to_string(), {
100                    copy.analyze(session);
101                });
102            }
103        });
104
105        if let Some(root) = &self.root {
106            linter_path!(session, "root".into(), {
107                root.analyze(session);
108            });
109        }
110
111        self.run.analyze(session);
112
113        // Check if the user is using the username instead of the UID
114        if let Some(user) = &self.user {
115            if user.uid().is_none() {
116                linter_path!(session, "user".into(), {
117                    session.add_message(
118                        MessageLevel::Warn,
119                        "UID should be used instead of username".to_string(),
120                    );
121                });
122            }
123        }
124    }
125}
126
127impl Linter for CopyResource {
128    fn analyze(&self, session: &mut LintSession) {
129        match self {
130            CopyResource::Copy(copy) => copy.analyze(session),
131            _ => {}
132        }
133    }
134}
135
136impl Linter for Copy {
137    fn analyze(&self, session: &mut LintSession) {
138        self.from.analyze(session);
139    }
140}
141
142impl Linter for Run {
143    fn analyze(&self, session: &mut LintSession) {
144        if self.run.is_empty() {
145            if !self.bind.is_empty() {
146                linter_path!(session, "bind".into(), {
147                    session.add_message(
148                        MessageLevel::Warn,
149                        "The run list is empty but there are bind definitions".to_string(),
150                    );
151                });
152            }
153
154            if !self.cache.is_empty() {
155                linter_path!(session, "cache".into(), {
156                    session.add_message(
157                        MessageLevel::Warn,
158                        "The run list is empty but there are cache definitions".to_string(),
159                    );
160                });
161            }
162        }
163
164        linter_path!(session, "run".into(), {
165            for (position, command) in self.run.iter().enumerate() {
166                linter_path!(session, position.to_string(), {
167                    if command.starts_with("cd ") {
168                        session.add_message(
169                            MessageLevel::Warn,
170                            "Avoid using 'cd' in the run command".to_string(),
171                        );
172                    }
173                });
174            }
175        });
176
177        linter_path!(session, "bind".into(), {
178            for (position, bind) in self.bind.iter().enumerate() {
179                linter_path!(session, position.to_string(), {
180                    bind.from.analyze(session);
181                });
182            }
183        });
184
185        linter_path!(session, "cache".into(), {
186            for (position, cache) in self.cache.iter().enumerate() {
187                linter_path!(session, position.to_string(), {
188                    cache.from.analyze(session);
189                });
190            }
191        });
192    }
193}
194
195impl Linter for FromContext {
196    fn analyze(&self, session: &mut LintSession) {
197        match self {
198            FromContext::FromImage(image) => {
199                linter_path!(session, "fromImage".into(), {
200                    image.analyze(session);
201                });
202            }
203            FromContext::FromBuilder(builder) => {
204                linter_path!(session, "fromBuilder".into(), {
205                    check_from_arg_placeholders(session, builder);
206                });
207            }
208            FromContext::FromContext(Some(context)) => {
209                if contains_arg_placeholder(context) {
210                    return;
211                }
212                let mut message =
213                    "Prefer to use fromImage and fromBuilder instead of fromContext".to_string();
214                // Check if it's main stage `FROM` or builder stage `FROM` (builders/<name>/from)
215                let is_stage_from = session.current_path.is_empty()
216                    || session.current_path[0] == "builders" && session.current_path.len() == 2;
217                if !is_stage_from {
218                    message.push_str(" (unless it's really from a build context: https://docs.docker.com/reference/cli/docker/buildx/build/#build-context)");
219                }
220                linter_path!(session, "fromContext".into(), {
221                    session.add_message(MessageLevel::Warn, message);
222                });
223            }
224            _ => {}
225        }
226    }
227}
228
229impl Linter for ImageName {
230    fn analyze(&self, session: &mut LintSession) {
231        if let Some(host) = &self.host {
232            linter_path!(session, "host".into(), {
233                check_from_arg_placeholders(session, host);
234            });
235        }
236        linter_path!(session, "path".into(), {
237            check_from_arg_placeholders(session, &self.path);
238        });
239        if let Some(version) = &self.version {
240            match version {
241                ImageVersion::Tag(tag) => {
242                    linter_path!(session, "tag".into(), {
243                        check_from_arg_placeholders(session, tag);
244                    });
245                }
246                ImageVersion::Digest(digest) => {
247                    linter_path!(session, "digest".into(), {
248                        check_from_arg_placeholders(session, digest);
249                    });
250                }
251            }
252        }
253    }
254}
255
256trait StageDependencyGetter {
257    fn get_dependencies(&self, origin: &Vec<String>) -> Vec<StageDependency>;
258}
259
260impl StageDependencyGetter for Stage {
261    fn get_dependencies(&self, origin: &Vec<String>) -> Vec<StageDependency> {
262        let mut dependencies = vec![];
263        if let FromContext::FromBuilder(builder) = &self.from {
264            dependencies.push(StageDependency {
265                stage: builder.clone(),
266                path: "/".into(),
267                origin: [origin.clone(), vec!["from".into()]].concat(),
268            });
269        }
270        for (position, copy) in self.copy.iter().enumerate() {
271            dependencies.append(&mut copy.get_dependencies(
272                &[origin.clone(), vec!["copy".into(), position.to_string()]].concat(),
273            ));
274        }
275        dependencies.append(&mut self.run.get_dependencies(origin));
276        if let Some(root) = &self.root {
277            dependencies.append(
278                &mut root.get_dependencies(&[origin.clone(), vec!["root".into()]].concat()),
279            );
280        }
281        dependencies
282    }
283}
284
285impl StageDependencyGetter for Run {
286    fn get_dependencies(&self, origin: &Vec<String>) -> Vec<StageDependency> {
287        let mut dependencies = vec![];
288        for (position, cache) in self.cache.iter().enumerate() {
289            if let FromContext::FromBuilder(builder) = &cache.from {
290                dependencies.push(StageDependency {
291                    stage: builder.clone(),
292                    path: cache.source.clone().unwrap_or("/".into()),
293                    origin: [origin.clone(), vec!["cache".into(), position.to_string()]].concat(),
294                });
295            }
296        }
297        for (position, bind) in self.bind.iter().enumerate() {
298            if let FromContext::FromBuilder(builder) = &bind.from {
299                dependencies.push(StageDependency {
300                    stage: builder.clone(),
301                    path: bind.source.clone().unwrap_or("/".into()),
302                    origin: [origin.clone(), vec!["bind".into(), position.to_string()]].concat(),
303                });
304            }
305        }
306        dependencies
307    }
308}
309
310impl StageDependencyGetter for CopyResource {
311    fn get_dependencies(&self, origin: &Vec<String>) -> Vec<StageDependency> {
312        match self {
313            CopyResource::Copy(copy) => match &copy.from {
314                FromContext::FromBuilder(builder) => copy
315                    .paths
316                    .iter()
317                    .map(|path| StageDependency {
318                        stage: builder.clone(),
319                        path: path.clone(),
320                        origin: origin.clone(),
321                    })
322                    .collect(),
323                _ => vec![],
324            },
325            _ => vec![],
326        }
327    }
328}
329
330#[derive(Debug, Clone, PartialEq, Default)]
331pub struct LintSession {
332    current_path: Vec<String>,
333    messages: Vec<LintMessage>,
334    stage_infos: HashMap<String, StageLintInfo>,
335    recursive_stage_dependencies: HashMap<String, Vec<String>>,
336}
337
338impl LintSession {
339    fn push_path_part(&mut self, part: String) {
340        self.current_path.push(part);
341    }
342
343    fn pop_path_part(&mut self) {
344        self.current_path.pop();
345    }
346
347    fn add_message(&mut self, level: MessageLevel, message: String) {
348        self.messages.push(LintMessage {
349            level,
350            message,
351            path: self.current_path.clone(),
352        });
353    }
354
355    pub fn messages(&self) -> Vec<LintMessage> {
356        self.messages.clone()
357    }
358
359    pub fn get_sorted_builders(&mut self) -> Vec<String> {
360        let mut stages: HashMap<String, Vec<String>> = self
361            .stage_infos
362            .clone()
363            .keys()
364            .map(|name| {
365                (
366                    name.clone(),
367                    self.get_stage_recursive_dependencies(name.clone()),
368                )
369            })
370            .collect();
371
372        let mut sorted: Vec<String> = vec![];
373
374        loop {
375            let mut part: Vec<String> = stages
376                .extract_if(|_name, deps| deps.is_empty())
377                .map(|(name, _deps)| name)
378                .collect();
379
380            if part.is_empty() {
381                // TODO: log circular dependency
382                break;
383            }
384
385            part.sort();
386
387            for name in part.iter() {
388                for deps in stages.values_mut() {
389                    deps.retain(|dep| dep != name);
390                }
391            }
392
393            sorted.append(&mut part);
394
395            if stages.is_empty() {
396                break;
397            }
398        }
399
400        sorted
401            .into_iter()
402            .filter(|name| name != "runtime")
403            .collect()
404    }
405
406    pub fn get_stage_recursive_dependencies(&mut self, stage: String) -> Vec<String> {
407        self.resolve_stage_recursive_dependencies(&mut vec![stage])
408    }
409
410    fn resolve_stage_recursive_dependencies(&mut self, path: &mut Vec<String>) -> Vec<String> {
411        let stage = &path.last().expect("The path is empty").clone();
412        if let Some(dependencies) = self.recursive_stage_dependencies.get(stage) {
413            return dependencies.clone();
414        }
415        let mut deps = HashSet::new();
416        let dependencies = self
417            .stage_infos
418            .get(stage)
419            .expect(format!("The stage info not found for stage '{}'", stage).as_str())
420            .dependencies
421            .clone();
422        for dependency in dependencies {
423            let dep_stage = &dependency.stage;
424            if path.contains(dep_stage) {
425                self.messages.push(LintMessage {
426                    level: MessageLevel::Error,
427                    message: format!(
428                        "Circular dependency detected: {} -> {}",
429                        path.join(" -> "),
430                        dependency.stage
431                    ),
432                    path: dependency.origin.clone(),
433                });
434                continue;
435            }
436            deps.insert(dep_stage.clone());
437            if self.stage_infos.contains_key(dep_stage) {
438                path.push(dep_stage.clone());
439                deps.extend(self.resolve_stage_recursive_dependencies(path));
440                path.pop();
441            } // the else is already managed in check_dependencies
442        }
443        let deps: Vec<String> = deps.into_iter().collect();
444        self.recursive_stage_dependencies
445            .insert(stage.clone(), deps.clone());
446        deps
447    }
448
449    /// Checks if dependencies are using path that are in cache
450    fn check_dependencies(&mut self) {
451        let dependencies = self
452            .stage_infos
453            .values()
454            .flat_map(|info| info.dependencies.clone())
455            .collect::<Vec<_>>();
456
457        let caches = self
458            .stage_infos
459            .iter()
460            .map(|(name, info)| (name.clone(), info.cache_paths.clone()))
461            .collect::<HashMap<_, _>>();
462
463        // Check if there is unused builders
464        let used_builders = dependencies
465            .iter()
466            .map(|dep| dep.stage.clone())
467            .collect::<HashSet<_>>();
468
469        let unused_builders = self
470            .stage_infos
471            .keys()
472            .filter(|name| name != &"runtime")
473            .map(|name| name.clone())
474            .filter(|name| !used_builders.contains(name))
475            .collect::<HashSet<_>>();
476
477        linter_path!(self, "builders".into(), {
478            for builder in unused_builders {
479                linter_path!(self, builder.clone(), {
480                    self.add_message(
481                        MessageLevel::Warn,
482                        format!(
483                            "The builder '{}' is not used and should be removed",
484                            builder
485                        ),
486                    );
487                });
488            }
489        });
490
491        for dependency in dependencies {
492            if let Some(paths) = caches.get(&dependency.stage) {
493                paths
494                    .iter()
495                    .filter(|path| dependency.path.starts_with(*path))
496                    .for_each(|path| {
497                        self.messages.push(LintMessage {
498                            level: MessageLevel::Error,
499                            message: format!(
500                                "Use of the '{}' builder cache path '{}'",
501                                dependency.stage, path
502                            ),
503                            path: dependency.origin.clone(),
504                        });
505                    });
506            } else {
507                self.messages.push(LintMessage {
508                    level: MessageLevel::Error,
509                    message: format!("The builder '{}' not found", dependency.stage),
510                    path: dependency.origin.clone(),
511                });
512            }
513        }
514    }
515
516    fn get_stage_cache_paths(&mut self, stage: &Stage) -> Vec<String> {
517        let mut paths = vec![];
518        paths.append(&mut self.get_run_cache_paths(
519            &stage.run,
520            &self.current_path.clone(),
521            &stage.workdir,
522        ));
523        if let Some(root) = &stage.root {
524            paths.append(&mut self.get_run_cache_paths(
525                root,
526                &[self.current_path.clone(), vec!["root".into()]].concat(),
527                &stage.workdir,
528            ));
529        }
530        paths
531    }
532
533    fn get_run_cache_paths(
534        &mut self,
535        run: &Run,
536        path: &Vec<String>,
537        workdir: &Option<String>,
538    ) -> Vec<String> {
539        let mut cache_paths = vec![];
540        for (position, cache) in run.cache.iter().enumerate() {
541            let target = cache.target.clone();
542            cache_paths.push(if target.starts_with("/") {
543                target.clone()
544            } else {
545                if let Some(workdir) = workdir {
546                    format!("{}/{}", workdir, target)
547                }
548                else {
549                    self.messages.push(LintMessage {
550                        level: MessageLevel::Warn,
551                        message: "The cache target should be absolute or a workdir should be defined in the stage".to_string(),
552                        path: [path.clone(), vec!["cache".into(), position.to_string()]].concat(),
553                    });
554                    target.clone()
555                }
556            });
557        }
558        cache_paths
559    }
560
561    ////////// Statics //////////
562
563    /// Analyze the given Dofigen configuration and return a lint session
564    pub fn analyze(dofigen: &Dofigen) -> Self {
565        let mut session = Self::default();
566        dofigen.analyze(&mut session);
567
568        session
569    }
570}
571
572pub fn check_from_arg_placeholders(session: &mut LintSession, value: &str) {
573    if contains_arg_placeholder(value) {
574        session.add_message(
575            MessageLevel::Error,
576            "Use fromContext when using global arg.".to_string(),
577        );
578    }
579}
580
581pub fn contains_arg_placeholder(value: &str) -> bool {
582    value.contains("$")
583}
584
585#[derive(Debug, Clone, PartialEq)]
586pub struct StageLintInfo {
587    dependencies: Vec<StageDependency>,
588    cache_paths: Vec<String>,
589}
590
591#[derive(Debug, Clone, PartialEq)]
592pub struct LintMessage {
593    pub level: MessageLevel,
594    pub path: Vec<String>,
595    pub message: String,
596}
597
598#[derive(Debug, Clone, PartialEq)]
599pub enum MessageLevel {
600    Warn,
601    Error,
602}
603
604#[cfg(test)]
605mod test {
606    use crate::Dofigen;
607
608    use super::*;
609    use pretty_assertions_sorted::assert_eq_sorted;
610
611    mod stage_dependencies {
612        use super::*;
613
614        #[test]
615        fn builders_dependencies() {
616            let dofigen = Dofigen {
617                builders: HashMap::from([
618                    (
619                        "builder1".into(),
620                        Stage {
621                            copy: vec![CopyResource::Copy(Copy {
622                                from: FromContext::FromBuilder("builder2".into()),
623                                paths: vec!["/path/to/copy".into()],
624                                options: Default::default(),
625                                ..Default::default()
626                            })],
627                            ..Default::default()
628                        },
629                    ),
630                    (
631                        "builder2".into(),
632                        Stage {
633                            copy: vec![CopyResource::Copy(Copy {
634                                from: FromContext::FromBuilder("builder3".into()),
635                                paths: vec!["/path/to/copy".into()],
636                                options: Default::default(),
637                                ..Default::default()
638                            })],
639                            ..Default::default()
640                        },
641                    ),
642                    (
643                        "builder3".into(),
644                        Stage {
645                            run: Run {
646                                run: vec!["echo Hello".into()].into(),
647                                ..Default::default()
648                            },
649                            ..Default::default()
650                        },
651                    ),
652                ]),
653                stage: Stage {
654                    copy: vec![CopyResource::Copy(Copy {
655                        from: FromContext::FromBuilder("builder1".into()),
656                        paths: vec!["/path/to/copy".into()],
657                        ..Default::default()
658                    })],
659                    ..Default::default()
660                },
661                ..Default::default()
662            };
663
664            let mut lint_session = LintSession::analyze(&dofigen);
665
666            let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
667            dependencies.sort();
668            assert_eq_sorted!(dependencies, vec!["builder1", "builder2", "builder3"]);
669
670            dependencies = lint_session.get_stage_recursive_dependencies("builder1".into());
671            dependencies.sort();
672            assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]);
673
674            dependencies = lint_session.get_stage_recursive_dependencies("builder2".into());
675            assert_eq_sorted!(dependencies, vec!["builder3"]);
676
677            dependencies = lint_session.get_stage_recursive_dependencies("builder3".into());
678            assert_eq_sorted!(dependencies, Vec::<String>::new());
679
680            let builders = lint_session.get_sorted_builders();
681
682            assert_eq_sorted!(builders, vec!["builder3", "builder2", "builder1",]);
683
684            assert_eq_sorted!(lint_session.messages, vec![]);
685        }
686
687        #[test]
688        fn builders_circular_dependencies() {
689            let dofigen = Dofigen {
690                builders: HashMap::from([
691                    (
692                        "builder1".into(),
693                        Stage {
694                            copy: vec![CopyResource::Copy(Copy {
695                                from: FromContext::FromBuilder("builder2".into()),
696                                paths: vec!["/path/to/copy".into()],
697                                options: Default::default(),
698                                ..Default::default()
699                            })],
700                            ..Default::default()
701                        },
702                    ),
703                    (
704                        "builder2".into(),
705                        Stage {
706                            copy: vec![CopyResource::Copy(Copy {
707                                from: FromContext::FromBuilder("builder3".into()),
708                                paths: vec!["/path/to/copy".into()],
709                                options: Default::default(),
710                                ..Default::default()
711                            })],
712                            ..Default::default()
713                        },
714                    ),
715                    (
716                        "builder3".into(),
717                        Stage {
718                            copy: vec![CopyResource::Copy(Copy {
719                                from: FromContext::FromBuilder("builder1".into()),
720                                paths: vec!["/path/to/copy".into()],
721                                options: Default::default(),
722                                ..Default::default()
723                            })],
724                            ..Default::default()
725                        },
726                    ),
727                ]),
728                ..Default::default()
729            };
730
731            let mut lint_session = LintSession::analyze(&dofigen);
732
733            let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
734            dependencies.sort();
735            assert_eq_sorted!(dependencies, Vec::<String>::new());
736
737            dependencies = lint_session.get_stage_recursive_dependencies("builder1".into());
738            dependencies.sort();
739            assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]);
740
741            dependencies = lint_session.get_stage_recursive_dependencies("builder2".into());
742            assert_eq_sorted!(dependencies, vec!["builder3"]);
743
744            dependencies = lint_session.get_stage_recursive_dependencies("builder3".into());
745            assert_eq_sorted!(dependencies, Vec::<String>::new());
746
747            let mut builders = lint_session.get_sorted_builders();
748            builders.sort();
749
750            assert_eq_sorted!(builders, vec!["builder1", "builder2", "builder3"]);
751
752            assert_eq_sorted!(
753                lint_session.messages,
754                vec![LintMessage {
755                    level: MessageLevel::Error,
756                    path: vec![
757                        "builders".into(),
758                        "builder3".into(),
759                        "copy".into(),
760                        "0".into(),
761                    ],
762                    message:
763                        "Circular dependency detected: builder1 -> builder2 -> builder3 -> builder1"
764                            .into(),
765                },]
766            );
767        }
768
769        #[test]
770        fn builder_named_runtime() {
771            let dofigen = Dofigen {
772                builders: HashMap::from([(
773                    "runtime".into(),
774                    Stage {
775                        run: Run {
776                            run: vec!["echo Hello".into()].into(),
777                            ..Default::default()
778                        },
779                        ..Default::default()
780                    },
781                )]),
782                ..Default::default()
783            };
784
785            let mut lint_session = LintSession::analyze(&dofigen);
786
787            let mut builders = lint_session.get_sorted_builders();
788            builders.sort();
789
790            assert_eq_sorted!(builders, Vec::<String>::new());
791
792            assert_eq_sorted!(
793                lint_session.messages,
794                vec![LintMessage {
795                    level: MessageLevel::Error,
796                    path: vec!["builders".into(), "runtime".into(),],
797                    message: "The builder name 'runtime' is reserved".into(),
798                },]
799            );
800        }
801
802        #[test]
803        fn builder_not_found() {
804            let dofigen = Dofigen {
805                stage: Stage {
806                    from: FromContext::FromBuilder("builder1".into()),
807                    ..Default::default()
808                },
809                ..Default::default()
810            };
811
812            let mut lint_session = LintSession::analyze(&dofigen);
813
814            let builders = lint_session.get_sorted_builders();
815
816            assert_eq_sorted!(builders, Vec::<String>::new());
817
818            assert_eq_sorted!(
819                lint_session.messages,
820                vec![LintMessage {
821                    level: MessageLevel::Error,
822                    path: vec!["from".into(),],
823                    message: "The builder 'builder1' not found".into(),
824                },]
825            );
826        }
827
828        #[test]
829        fn dependency_to_runtime() {
830            let dofigen = Dofigen {
831                builders: HashMap::from([(
832                    "builder".into(),
833                    Stage {
834                        copy: vec![CopyResource::Copy(Copy {
835                            from: FromContext::FromBuilder("runtime".into()),
836                            paths: vec!["/path/to/copy".into()],
837                            ..Default::default()
838                        })],
839                        ..Default::default()
840                    },
841                )]),
842                stage: Stage {
843                    run: Run {
844                        run: vec!["echo Hello".into()].into(),
845                        ..Default::default()
846                    },
847                    ..Default::default()
848                },
849                ..Default::default()
850            };
851
852            let mut lint_session = LintSession::analyze(&dofigen);
853
854            let mut builders = lint_session.get_sorted_builders();
855            builders.sort();
856
857            assert_eq_sorted!(builders, vec!["builder"]);
858
859            assert_eq_sorted!(
860                lint_session.messages,
861                vec![
862                    LintMessage {
863                        level: MessageLevel::Error,
864                        path: vec![
865                            "builders".into(),
866                            "builder".into(),
867                            "copy".into(),
868                            "0".into()
869                        ],
870                        message: "The stage 'builder' can't depend on the 'runtime'".into(),
871                    },
872                    LintMessage {
873                        level: MessageLevel::Warn,
874                        path: vec!["builders".into(), "builder".into(),],
875                        message: "The builder 'builder' is not used and should be removed".into(),
876                    }
877                ]
878            );
879        }
880
881        #[test]
882        fn dependency_to_cache_path() {
883            let dofigen = Dofigen {
884                builders: HashMap::from([
885                    (
886                        "builder1".into(),
887                        Stage {
888                            run: Run {
889                                run: vec!["echo Hello".into()].into(),
890                                cache: vec![Cache {
891                                    target: "/path/to/cache".into(),
892                                    ..Default::default()
893                                }],
894                                ..Default::default()
895                            },
896                            ..Default::default()
897                        },
898                    ),
899                    (
900                        "builder2".into(),
901                        Stage {
902                            copy: vec![CopyResource::Copy(Copy {
903                                from: FromContext::FromBuilder("builder1".into()),
904                                paths: vec!["/path/to/cache/test".into()],
905                                ..Default::default()
906                            })],
907                            ..Default::default()
908                        },
909                    ),
910                ]),
911                stage: Stage {
912                    from: FromContext::FromBuilder("builder2".into()),
913                    ..Default::default()
914                },
915                ..Default::default()
916            };
917
918            let mut lint_session = LintSession::analyze(&dofigen);
919
920            let mut builders = lint_session.get_sorted_builders();
921            builders.sort();
922
923            assert_eq_sorted!(builders, vec!["builder1", "builder2"]);
924
925            assert_eq_sorted!(
926                lint_session.messages,
927                vec![LintMessage {
928                    level: MessageLevel::Error,
929                    path: vec![
930                        "builders".into(),
931                        "builder2".into(),
932                        "copy".into(),
933                        "0".into()
934                    ],
935                    message: "Use of the 'builder1' builder cache path '/path/to/cache'".into(),
936                },]
937            );
938        }
939
940        #[test]
941        fn runtime_dependencies() {
942            let dofigen = Dofigen {
943                builders: HashMap::from([
944                    (
945                        "install-deps".to_string(),
946                        Stage {
947                            from: FromContext::FromImage(ImageName {
948                                path: "php".to_string(),
949                                version: Some(ImageVersion::Tag("8.3-fpm-alpine".to_string())),
950                                ..Default::default()
951                            }),
952                            run: Run {
953                                run: vec!["echo coucou".to_string()],
954                                ..Default::default()
955                            },
956                            ..Default::default()
957                        },
958                    ),
959                    (
960                        "install-php-ext".to_string(),
961                        Stage {
962                            from: FromContext::FromBuilder("install-deps".to_string()),
963                            run: Run {
964                                run: vec!["echo coucou".to_string()],
965                                ..Default::default()
966                            },
967                            ..Default::default()
968                        },
969                    ),
970                    (
971                        "get-composer".to_string(),
972                        Stage {
973                            from: FromContext::FromImage(ImageName {
974                                path: "composer".to_string(),
975                                version: Some(ImageVersion::Tag("latest".to_string())),
976                                ..Default::default()
977                            }),
978                            run: Run {
979                                run: vec!["echo coucou".to_string()],
980                                ..Default::default()
981                            },
982                            ..Default::default()
983                        },
984                    ),
985                ]),
986                stage: Stage {
987                    from: FromContext::FromBuilder("install-php-ext".to_string()),
988                    copy: vec![CopyResource::Copy(Copy {
989                        from: FromContext::FromBuilder("get-composer".to_string()),
990                        paths: vec!["/usr/bin/composer".to_string()],
991                        options: CopyOptions {
992                            target: Some("/bin/".to_string()),
993                            ..Default::default()
994                        },
995                        ..Default::default()
996                    })],
997                    ..Default::default()
998                },
999                ..Default::default()
1000            };
1001
1002            let mut lint_session = LintSession::analyze(&dofigen);
1003
1004            let mut dependencies =
1005                lint_session.get_stage_recursive_dependencies("install-deps".into());
1006            dependencies.sort();
1007            assert_eq_sorted!(dependencies, Vec::<String>::new());
1008
1009            dependencies = lint_session.get_stage_recursive_dependencies("install-php-ext".into());
1010            assert_eq_sorted!(dependencies, vec!["install-deps"]);
1011
1012            dependencies = lint_session.get_stage_recursive_dependencies("get-composer".into());
1013            assert_eq_sorted!(dependencies, Vec::<String>::new());
1014
1015            dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
1016            dependencies.sort();
1017            assert_eq_sorted!(
1018                dependencies,
1019                vec!["get-composer", "install-deps", "install-php-ext"]
1020            );
1021
1022            let mut builders = lint_session.get_sorted_builders();
1023            builders.sort();
1024
1025            assert_eq_sorted!(
1026                builders,
1027                vec!["get-composer", "install-deps", "install-php-ext"]
1028            );
1029
1030            assert_eq_sorted!(lint_session.messages, vec![]);
1031        }
1032    }
1033
1034    mod builder {
1035        use super::*;
1036
1037        #[test]
1038        fn empty() {
1039            let dofigen = Dofigen {
1040                builders: HashMap::from([(
1041                    "builder".into(),
1042                    Stage {
1043                        from: FromContext::FromImage(ImageName {
1044                            path: "php".into(),
1045                            ..Default::default()
1046                        }),
1047                        ..Default::default()
1048                    },
1049                )]),
1050                stage: Stage {
1051                    from: FromContext::FromBuilder("builder".into()),
1052                    ..Default::default()
1053                },
1054                ..Default::default()
1055            };
1056
1057            let lint_session = LintSession::analyze(&dofigen);
1058
1059            assert_eq_sorted!(
1060                lint_session.messages,
1061                vec![LintMessage {
1062                    level: MessageLevel::Warn,
1063                    path: vec!["builders".into(), "builder".into()],
1064                    message: "The builder 'builder' is empty and should be removed".into(),
1065                },]
1066            );
1067        }
1068
1069        #[test]
1070        fn unused() {
1071            let dofigen = Dofigen {
1072                builders: HashMap::from([(
1073                    "builder".into(),
1074                    Stage {
1075                        from: FromContext::FromImage(ImageName {
1076                            ..Default::default()
1077                        }),
1078                        run: Run {
1079                            run: vec!["echo Hello".into()],
1080                            ..Default::default()
1081                        },
1082                        ..Default::default()
1083                    },
1084                )]),
1085                ..Default::default()
1086            };
1087
1088            let lint_session = LintSession::analyze(&dofigen);
1089
1090            assert_eq_sorted!(
1091                lint_session.messages,
1092                vec![LintMessage {
1093                    level: MessageLevel::Warn,
1094                    path: vec!["builders".into(), "builder".into()],
1095                    message: "The builder 'builder' is not used and should be removed".into(),
1096                },]
1097            );
1098        }
1099    }
1100
1101    mod user {
1102        use super::*;
1103
1104        #[test]
1105        fn uid() {
1106            let dofigen = Dofigen {
1107                stage: Stage {
1108                    user: Some(User::new("1000")),
1109                    ..Default::default()
1110                },
1111                ..Default::default()
1112            };
1113
1114            let lint_session = LintSession::analyze(&dofigen);
1115
1116            assert_eq_sorted!(lint_session.messages, vec![]);
1117        }
1118
1119        #[test]
1120        fn username() {
1121            let dofigen = Dofigen {
1122                stage: Stage {
1123                    user: Some(User::new("test")),
1124                    ..Default::default()
1125                },
1126                ..Default::default()
1127            };
1128
1129            let lint_session = LintSession::analyze(&dofigen);
1130
1131            assert_eq_sorted!(
1132                lint_session.messages,
1133                vec![LintMessage {
1134                    level: MessageLevel::Warn,
1135                    path: vec!["user".into()],
1136                    message: "UID should be used instead of username".into(),
1137                },]
1138            );
1139        }
1140    }
1141
1142    mod from_context {
1143        use super::*;
1144
1145        #[test]
1146        fn stage_and_copy() {
1147            let dofigen = Dofigen {
1148                stage: Stage {
1149                    from: FromContext::FromContext(Some("php:8.3-fpm-alpine".into())),
1150                    copy: vec![CopyResource::Copy(Copy {
1151                        from: FromContext::FromContext(Some("composer:latest".into())),
1152                        paths: vec!["/usr/bin/composer".into()],
1153                        ..Default::default()
1154                    })],
1155                    ..Default::default()
1156                },
1157                ..Default::default()
1158            };
1159
1160            let lint_session = LintSession::analyze(&dofigen);
1161
1162            assert_eq_sorted!(lint_session.messages, vec![
1163                LintMessage {
1164                    level: MessageLevel::Warn,
1165                    path: vec!["fromContext".into()],
1166                    message: "Prefer to use fromImage and fromBuilder instead of fromContext".into(),   
1167                },
1168                LintMessage {
1169                    level: MessageLevel::Warn,
1170                    path: vec!["copy".into(), "0".into(), "fromContext".into()],
1171                    message: "Prefer to use fromImage and fromBuilder instead of fromContext (unless it's really from a build context: https://docs.docker.com/reference/cli/docker/buildx/build/#build-context)".into(),
1172                }
1173            ]);
1174        }
1175
1176        #[test]
1177        fn root_bind() {
1178            let dofigen = Dofigen {
1179                builders: HashMap::from([(
1180                    "builder".into(),
1181                    Stage {
1182                        root: Some(Run {
1183                            bind: vec![Bind {
1184                                from: FromContext::FromContext(Some("builder".into())),
1185                                source: Some("/path/to/bind".into()),
1186                                target: "/path/to/target".into(),
1187                                ..Default::default()
1188                            }],
1189                            run: vec!["echo Hello".into()],
1190                            ..Default::default()
1191                        }),
1192                        ..Default::default()
1193                    },
1194                )]),
1195                stage: Stage {
1196                    from: FromContext::FromBuilder("builder".into()),
1197                    ..Default::default()
1198                },
1199                ..Default::default()
1200            };
1201
1202            let lint_session = LintSession::analyze(&dofigen);
1203
1204            assert_eq_sorted!(lint_session.messages, vec![
1205                LintMessage {
1206                    level: MessageLevel::Warn,
1207                    path: vec![
1208                        "builders".into(),
1209                        "builder".into(),
1210                        "root".into(),
1211                        "bind".into(),
1212                        "0".into(),
1213                        "fromContext".into(),
1214                    ],
1215                    message: "Prefer to use fromImage and fromBuilder instead of fromContext (unless it's really from a build context: https://docs.docker.com/reference/cli/docker/buildx/build/#build-context)".into(),
1216                }
1217            ]);
1218        }
1219
1220        #[test]
1221        fn with_global_arg() {
1222            let dofigen = Dofigen {
1223                global_arg: HashMap::from([("VERSION".into(), "21".into())]),
1224                stage: Stage {
1225                    from: FromContext::FromContext(Some("eclipse-temurin:${VERSION}".into())),
1226                    ..Default::default()
1227                },
1228                ..Default::default()
1229            };
1230
1231            let lint_session = LintSession::analyze(&dofigen);
1232
1233            assert_eq_sorted!(lint_session.messages, vec![]);
1234        }
1235
1236        #[test]
1237        fn from_image_with_global_arg() {
1238            let dofigen = Dofigen {
1239                global_arg: HashMap::from([("VERSION".into(), "21".into())]),
1240                stage: Stage {
1241                    from: FromContext::FromImage(ImageName {
1242                        path: "eclipse-temurin".into(),
1243                        version: Some(ImageVersion::Tag("${VERSION}".into())),
1244                        ..Default::default()
1245                    }),
1246                    ..Default::default()
1247                },
1248                ..Default::default()
1249            };
1250
1251            let lint_session = LintSession::analyze(&dofigen);
1252
1253            assert_eq_sorted!(
1254                lint_session.messages,
1255                vec![LintMessage {
1256                    level: MessageLevel::Error,
1257                    path: vec!["fromImage".into(), "tag".into(),],
1258                    message: "Use fromContext when using global arg.".into(),
1259                }]
1260            );
1261        }
1262    }
1263
1264    mod run {
1265        use super::*;
1266
1267        #[test]
1268        fn empty_run() {
1269            let dofigen = Dofigen {
1270                stage: Stage {
1271                    run: Run {
1272                        bind: vec![Bind {
1273                            source: Some("/path/to/bind".into()),
1274                            target: "/path/to/target".into(),
1275                            ..Default::default()
1276                        }],
1277                        cache: vec![Cache {
1278                            source: Some("/path/to/cache".into()),
1279                            target: "/path/to/target".into(),
1280                            ..Default::default()
1281                        }],
1282                        ..Default::default()
1283                    },
1284                    ..Default::default()
1285                },
1286                ..Default::default()
1287            };
1288
1289            let lint_session = LintSession::analyze(&dofigen);
1290
1291            assert_eq_sorted!(
1292                lint_session.messages,
1293                vec![
1294                    LintMessage {
1295                        level: MessageLevel::Warn,
1296                        message: "The run list is empty but there are bind definitions".into(),
1297                        path: vec!["bind".into()],
1298                    },
1299                    LintMessage {
1300                        level: MessageLevel::Warn,
1301                        message: "The run list is empty but there are cache definitions".into(),
1302                        path: vec!["cache".into()],
1303                    },
1304                ]
1305            );
1306        }
1307    }
1308}