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