dofigen_lib/
linter.rs

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