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: Vec<(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        stages.sort_by(|(a_stage, a_deps), (b_stage, b_deps)| {
354            if a_deps.contains(b_stage) {
355                return std::cmp::Ordering::Greater;
356            }
357            if b_deps.contains(a_stage) {
358                return std::cmp::Ordering::Less;
359            }
360            a_stage.cmp(b_stage)
361        });
362
363        stages
364            .into_iter()
365            .map(|(stage, _)| stage)
366            .filter(|name| *name != "runtime")
367            .collect()
368    }
369
370    pub fn get_stage_recursive_dependencies(&mut self, stage: String) -> Vec<String> {
371        self.resolve_stage_recursive_dependencies(&mut vec![stage])
372    }
373
374    fn resolve_stage_recursive_dependencies(&mut self, path: &mut Vec<String>) -> Vec<String> {
375        let stage = &path.last().expect("The path is empty").clone();
376        if let Some(dependencies) = self.recursive_stage_dependencies.get(stage) {
377            return dependencies.clone();
378        }
379        let mut deps = HashSet::new();
380        let dependencies = self
381            .stage_infos
382            .get(stage)
383            .expect(format!("The stage info not found for stage '{}'", stage).as_str())
384            .dependencies
385            .clone();
386        for dependency in dependencies {
387            let dep_stage = &dependency.stage;
388            if path.contains(dep_stage) {
389                self.messages.push(LintMessage {
390                    level: MessageLevel::Error,
391                    message: format!(
392                        "Circular dependency detected: {} -> {}",
393                        path.join(" -> "),
394                        dependency.stage
395                    ),
396                    path: dependency.origin.clone(),
397                });
398                continue;
399            }
400            deps.insert(dep_stage.clone());
401            if self.stage_infos.contains_key(dep_stage) {
402                path.push(dep_stage.clone());
403                deps.extend(self.resolve_stage_recursive_dependencies(path));
404                path.pop();
405            } // the else is already managed in check_dependencies
406        }
407        let deps: Vec<String> = deps.into_iter().collect();
408        self.recursive_stage_dependencies
409            .insert(stage.clone(), deps.clone());
410        deps
411    }
412
413    /// Checks if dependencies are using path that are in cache
414    fn check_dependencies(&mut self) {
415        let dependencies = self
416            .stage_infos
417            .values()
418            .flat_map(|info| info.dependencies.clone())
419            .collect::<Vec<_>>();
420
421        let caches = self
422            .stage_infos
423            .iter()
424            .map(|(name, info)| (name.clone(), info.cache_paths.clone()))
425            .collect::<HashMap<_, _>>();
426
427        // Check if there is unused builders
428        let used_builders = dependencies
429            .iter()
430            .map(|dep| dep.stage.clone())
431            .collect::<HashSet<_>>();
432
433        let unused_builders = self
434            .stage_infos
435            .keys()
436            .filter(|name| name != &"runtime")
437            .map(|name| name.clone())
438            .filter(|name| !used_builders.contains(name))
439            .collect::<HashSet<_>>();
440
441        linter_path!(self, "builders".into(), {
442            for builder in unused_builders {
443                linter_path!(self, builder.clone(), {
444                    self.add_message(
445                        MessageLevel::Warn,
446                        format!(
447                            "The builder '{}' is not used and should be removed",
448                            builder
449                        ),
450                    );
451                });
452            }
453        });
454
455        for dependency in dependencies {
456            if let Some(paths) = caches.get(&dependency.stage) {
457                paths
458                    .iter()
459                    .filter(|path| dependency.path.starts_with(*path))
460                    .for_each(|path| {
461                        self.messages.push(LintMessage {
462                            level: MessageLevel::Error,
463                            message: format!(
464                                "Use of the '{}' builder cache path '{}'",
465                                dependency.stage, path
466                            ),
467                            path: dependency.origin.clone(),
468                        });
469                    });
470            } else {
471                self.messages.push(LintMessage {
472                    level: MessageLevel::Error,
473                    message: format!("The builder '{}' not found", dependency.stage),
474                    path: dependency.origin.clone(),
475                });
476            }
477        }
478    }
479
480    fn get_stage_cache_paths(&mut self, stage: &Stage) -> Vec<String> {
481        let mut paths = vec![];
482        paths.append(&mut self.get_run_cache_paths(
483            &stage.run,
484            &self.current_path.clone(),
485            &stage.workdir,
486        ));
487        if let Some(root) = &stage.root {
488            paths.append(&mut self.get_run_cache_paths(
489                root,
490                &[self.current_path.clone(), vec!["root".into()]].concat(),
491                &stage.workdir,
492            ));
493        }
494        paths
495    }
496
497    fn get_run_cache_paths(
498        &mut self,
499        run: &Run,
500        path: &Vec<String>,
501        workdir: &Option<String>,
502    ) -> Vec<String> {
503        let mut cache_paths = vec![];
504        for (position, cache) in run.cache.iter().enumerate() {
505            let target = cache.target.clone();
506            cache_paths.push(if target.starts_with("/") {
507                target.clone()
508            } else {
509                if let Some(workdir) = workdir {
510                    format!("{}/{}", workdir, target)
511                }
512                else {
513                    self.messages.push(LintMessage {
514                        level: MessageLevel::Warn,
515                        message: "The cache target should be absolute or a workdir should be defined in the stage".to_string(),
516                        path: [path.clone(), vec!["cache".into(), position.to_string()]].concat(),
517                    });
518                    target.clone()
519                }
520            });
521        }
522        cache_paths
523    }
524
525    ////////// Statics //////////
526
527    /// Analyze the given Dofigen configuration and return a lint session
528    pub fn analyze(dofigen: &Dofigen) -> Self {
529        let mut session = Self::default();
530        dofigen.analyze(&mut session);
531
532        session
533    }
534}
535
536#[derive(Debug, Clone, PartialEq)]
537pub struct StageLintInfo {
538    dependencies: Vec<StageDependency>,
539    cache_paths: Vec<String>,
540}
541
542#[derive(Debug, Clone, PartialEq)]
543pub struct LintMessage {
544    pub level: MessageLevel,
545    pub path: Vec<String>,
546    pub message: String,
547}
548
549#[derive(Debug, Clone, PartialEq)]
550pub enum MessageLevel {
551    Warn,
552    Error,
553}
554
555#[cfg(test)]
556mod test {
557    use crate::Dofigen;
558
559    use super::*;
560    use pretty_assertions_sorted::assert_eq_sorted;
561
562    mod stage_dependencies {
563        use super::*;
564
565        #[test]
566        fn builders_dependencies() {
567            let dofigen = Dofigen {
568                builders: HashMap::from([
569                    (
570                        "builder1".into(),
571                        Stage {
572                            copy: vec![CopyResource::Copy(Copy {
573                                from: FromContext::FromBuilder("builder2".into()),
574                                paths: vec!["/path/to/copy".into()],
575                                options: Default::default(),
576                                ..Default::default()
577                            })],
578                            ..Default::default()
579                        },
580                    ),
581                    (
582                        "builder2".into(),
583                        Stage {
584                            copy: vec![CopyResource::Copy(Copy {
585                                from: FromContext::FromBuilder("builder3".into()),
586                                paths: vec!["/path/to/copy".into()],
587                                options: Default::default(),
588                                ..Default::default()
589                            })],
590                            ..Default::default()
591                        },
592                    ),
593                    (
594                        "builder3".into(),
595                        Stage {
596                            run: Run {
597                                run: vec!["echo Hello".into()].into(),
598                                ..Default::default()
599                            },
600                            ..Default::default()
601                        },
602                    ),
603                ]),
604                stage: Stage {
605                    copy: vec![CopyResource::Copy(Copy {
606                        from: FromContext::FromBuilder("builder1".into()),
607                        paths: vec!["/path/to/copy".into()],
608                        ..Default::default()
609                    })],
610                    ..Default::default()
611                },
612                ..Default::default()
613            };
614
615            let mut lint_session = LintSession::analyze(&dofigen);
616
617            let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
618            dependencies.sort();
619            assert_eq_sorted!(dependencies, vec!["builder1", "builder2", "builder3"]);
620
621            dependencies = lint_session.get_stage_recursive_dependencies("builder1".into());
622            dependencies.sort();
623            assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]);
624
625            dependencies = lint_session.get_stage_recursive_dependencies("builder2".into());
626            assert_eq_sorted!(dependencies, vec!["builder3"]);
627
628            dependencies = lint_session.get_stage_recursive_dependencies("builder3".into());
629            assert_eq_sorted!(dependencies, Vec::<String>::new());
630
631            let mut builders = lint_session.get_sorted_builders();
632            builders.sort();
633
634            assert_eq_sorted!(builders, vec!["builder1", "builder2", "builder3"]);
635
636            assert_eq_sorted!(lint_session.messages, vec![]);
637        }
638
639        #[test]
640        fn builders_circular_dependencies() {
641            let dofigen = Dofigen {
642                builders: HashMap::from([
643                    (
644                        "builder1".into(),
645                        Stage {
646                            copy: vec![CopyResource::Copy(Copy {
647                                from: FromContext::FromBuilder("builder2".into()),
648                                paths: vec!["/path/to/copy".into()],
649                                options: Default::default(),
650                                ..Default::default()
651                            })],
652                            ..Default::default()
653                        },
654                    ),
655                    (
656                        "builder2".into(),
657                        Stage {
658                            copy: vec![CopyResource::Copy(Copy {
659                                from: FromContext::FromBuilder("builder3".into()),
660                                paths: vec!["/path/to/copy".into()],
661                                options: Default::default(),
662                                ..Default::default()
663                            })],
664                            ..Default::default()
665                        },
666                    ),
667                    (
668                        "builder3".into(),
669                        Stage {
670                            copy: vec![CopyResource::Copy(Copy {
671                                from: FromContext::FromBuilder("builder1".into()),
672                                paths: vec!["/path/to/copy".into()],
673                                options: Default::default(),
674                                ..Default::default()
675                            })],
676                            ..Default::default()
677                        },
678                    ),
679                ]),
680                ..Default::default()
681            };
682
683            let mut lint_session = LintSession::analyze(&dofigen);
684
685            let mut dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
686            dependencies.sort();
687            assert_eq_sorted!(dependencies, Vec::<String>::new());
688
689            dependencies = lint_session.get_stage_recursive_dependencies("builder1".into());
690            dependencies.sort();
691            assert_eq_sorted!(dependencies, vec!["builder2", "builder3"]);
692
693            dependencies = lint_session.get_stage_recursive_dependencies("builder2".into());
694            assert_eq_sorted!(dependencies, vec!["builder3"]);
695
696            dependencies = lint_session.get_stage_recursive_dependencies("builder3".into());
697            assert_eq_sorted!(dependencies, Vec::<String>::new());
698
699            let mut builders = lint_session.get_sorted_builders();
700            builders.sort();
701
702            assert_eq_sorted!(builders, vec!["builder1", "builder2", "builder3"]);
703
704            assert_eq_sorted!(
705                lint_session.messages,
706                vec![LintMessage {
707                    level: MessageLevel::Error,
708                    path: vec![
709                        "builders".into(),
710                        "builder3".into(),
711                        "copy".into(),
712                        "0".into(),
713                    ],
714                    message:
715                        "Circular dependency detected: builder1 -> builder2 -> builder3 -> builder1"
716                            .into(),
717                },]
718            );
719        }
720
721        #[test]
722        fn builder_named_runtime() {
723            let dofigen = Dofigen {
724                builders: HashMap::from([(
725                    "runtime".into(),
726                    Stage {
727                        run: Run {
728                            run: vec!["echo Hello".into()].into(),
729                            ..Default::default()
730                        },
731                        ..Default::default()
732                    },
733                )]),
734                ..Default::default()
735            };
736
737            let mut lint_session = LintSession::analyze(&dofigen);
738
739            let mut builders = lint_session.get_sorted_builders();
740            builders.sort();
741
742            assert_eq_sorted!(builders, Vec::<String>::new());
743
744            assert_eq_sorted!(
745                lint_session.messages,
746                vec![LintMessage {
747                    level: MessageLevel::Error,
748                    path: vec!["builders".into(), "runtime".into(),],
749                    message: "The builder name 'runtime' is reserved".into(),
750                },]
751            );
752        }
753
754        #[test]
755        fn builder_not_found() {
756            let dofigen = Dofigen {
757                stage: Stage {
758                    from: FromContext::FromBuilder("builder1".into()),
759                    ..Default::default()
760                },
761                ..Default::default()
762            };
763
764            let mut lint_session = LintSession::analyze(&dofigen);
765
766            let mut builders = lint_session.get_sorted_builders();
767            builders.sort();
768
769            assert_eq_sorted!(builders, Vec::<String>::new());
770
771            assert_eq_sorted!(
772                lint_session.messages,
773                vec![LintMessage {
774                    level: MessageLevel::Error,
775                    path: vec!["from".into(),],
776                    message: "The builder 'builder1' not found".into(),
777                },]
778            );
779        }
780
781        #[test]
782        fn dependency_to_runtime() {
783            let dofigen = Dofigen {
784                builders: HashMap::from([(
785                    "builder".into(),
786                    Stage {
787                        copy: vec![CopyResource::Copy(Copy {
788                            from: FromContext::FromBuilder("runtime".into()),
789                            paths: vec!["/path/to/copy".into()],
790                            ..Default::default()
791                        })],
792                        ..Default::default()
793                    },
794                )]),
795                stage: Stage {
796                    run: Run {
797                        run: vec!["echo Hello".into()].into(),
798                        ..Default::default()
799                    },
800                    ..Default::default()
801                },
802                ..Default::default()
803            };
804
805            let mut lint_session = LintSession::analyze(&dofigen);
806
807            let mut builders = lint_session.get_sorted_builders();
808            builders.sort();
809
810            assert_eq_sorted!(builders, vec!["builder"]);
811
812            assert_eq_sorted!(
813                lint_session.messages,
814                vec![
815                    LintMessage {
816                        level: MessageLevel::Error,
817                        path: vec![
818                            "builders".into(),
819                            "builder".into(),
820                            "copy".into(),
821                            "0".into()
822                        ],
823                        message: "The stage 'builder' can't depend on the 'runtime'".into(),
824                    },
825                    LintMessage {
826                        level: MessageLevel::Warn,
827                        path: vec!["builders".into(), "builder".into(),],
828                        message: "The builder 'builder' is not used and should be removed".into(),
829                    }
830                ]
831            );
832        }
833
834        #[test]
835        fn dependency_to_cache_path() {
836            let dofigen = Dofigen {
837                builders: HashMap::from([
838                    (
839                        "builder1".into(),
840                        Stage {
841                            run: Run {
842                                run: vec!["echo Hello".into()].into(),
843                                cache: vec![Cache {
844                                    target: "/path/to/cache".into(),
845                                    ..Default::default()
846                                }],
847                                ..Default::default()
848                            },
849                            ..Default::default()
850                        },
851                    ),
852                    (
853                        "builder2".into(),
854                        Stage {
855                            copy: vec![CopyResource::Copy(Copy {
856                                from: FromContext::FromBuilder("builder1".into()),
857                                paths: vec!["/path/to/cache/test".into()],
858                                ..Default::default()
859                            })],
860                            ..Default::default()
861                        },
862                    ),
863                ]),
864                stage: Stage {
865                    from: FromContext::FromBuilder("builder2".into()),
866                    ..Default::default()
867                },
868                ..Default::default()
869            };
870
871            let mut lint_session = LintSession::analyze(&dofigen);
872
873            let mut builders = lint_session.get_sorted_builders();
874            builders.sort();
875
876            assert_eq_sorted!(builders, vec!["builder1", "builder2"]);
877
878            assert_eq_sorted!(
879                lint_session.messages,
880                vec![LintMessage {
881                    level: MessageLevel::Error,
882                    path: vec![
883                        "builders".into(),
884                        "builder2".into(),
885                        "copy".into(),
886                        "0".into()
887                    ],
888                    message: "Use of the 'builder1' builder cache path '/path/to/cache'".into(),
889                },]
890            );
891        }
892
893        #[test]
894        fn runtime_dependencies() {
895            let dofigen = Dofigen {
896                builders: HashMap::from([
897                    (
898                        "install-deps".to_string(),
899                        Stage {
900                            from: FromContext::FromImage(ImageName {
901                                path: "php".to_string(),
902                                version: Some(ImageVersion::Tag("8.3-fpm-alpine".to_string())),
903                                ..Default::default()
904                            }),
905                            run: Run {
906                                run: vec!["echo coucou".to_string()],
907                                ..Default::default()
908                            },
909                            ..Default::default()
910                        },
911                    ),
912                    (
913                        "install-php-ext".to_string(),
914                        Stage {
915                            from: FromContext::FromBuilder("install-deps".to_string()),
916                            run: Run {
917                                run: vec!["echo coucou".to_string()],
918                                ..Default::default()
919                            },
920                            ..Default::default()
921                        },
922                    ),
923                    (
924                        "get-composer".to_string(),
925                        Stage {
926                            from: FromContext::FromImage(ImageName {
927                                path: "composer".to_string(),
928                                version: Some(ImageVersion::Tag("latest".to_string())),
929                                ..Default::default()
930                            }),
931                            run: Run {
932                                run: vec!["echo coucou".to_string()],
933                                ..Default::default()
934                            },
935                            ..Default::default()
936                        },
937                    ),
938                ]),
939                stage: Stage {
940                    from: FromContext::FromBuilder("install-php-ext".to_string()),
941                    copy: vec![CopyResource::Copy(Copy {
942                        from: FromContext::FromBuilder("get-composer".to_string()),
943                        paths: vec!["/usr/bin/composer".to_string()],
944                        options: CopyOptions {
945                            target: Some("/bin/".to_string()),
946                            ..Default::default()
947                        },
948                        ..Default::default()
949                    })],
950                    ..Default::default()
951                },
952                ..Default::default()
953            };
954
955            let mut lint_session = LintSession::analyze(&dofigen);
956
957            let mut dependencies =
958                lint_session.get_stage_recursive_dependencies("install-deps".into());
959            dependencies.sort();
960            assert_eq_sorted!(dependencies, Vec::<String>::new());
961
962            dependencies = lint_session.get_stage_recursive_dependencies("install-php-ext".into());
963            assert_eq_sorted!(dependencies, vec!["install-deps"]);
964
965            dependencies = lint_session.get_stage_recursive_dependencies("get-composer".into());
966            assert_eq_sorted!(dependencies, Vec::<String>::new());
967
968            dependencies = lint_session.get_stage_recursive_dependencies("runtime".into());
969            dependencies.sort();
970            assert_eq_sorted!(
971                dependencies,
972                vec!["get-composer", "install-deps", "install-php-ext"]
973            );
974
975            let mut builders = lint_session.get_sorted_builders();
976            builders.sort();
977
978            assert_eq_sorted!(
979                builders,
980                vec!["get-composer", "install-deps", "install-php-ext"]
981            );
982
983            assert_eq_sorted!(lint_session.messages, vec![]);
984        }
985    }
986
987    mod builder {
988        use super::*;
989
990        #[test]
991        fn empty() {
992            let dofigen = Dofigen {
993                builders: HashMap::from([(
994                    "builder".into(),
995                    Stage {
996                        from: FromContext::FromImage(ImageName {
997                            path: "php".into(),
998                            ..Default::default()
999                        }),
1000                        ..Default::default()
1001                    },
1002                )]),
1003                stage: Stage {
1004                    from: FromContext::FromBuilder("builder".into()),
1005                    ..Default::default()
1006                },
1007                ..Default::default()
1008            };
1009
1010            let lint_session = LintSession::analyze(&dofigen);
1011
1012            assert_eq_sorted!(
1013                lint_session.messages,
1014                vec![LintMessage {
1015                    level: MessageLevel::Warn,
1016                    path: vec!["builders".into(), "builder".into()],
1017                    message: "The builder 'builder' is empty and should be removed".into(),
1018                },]
1019            );
1020        }
1021
1022        #[test]
1023        fn unused() {
1024            let dofigen = Dofigen {
1025                builders: HashMap::from([(
1026                    "builder".into(),
1027                    Stage {
1028                        from: FromContext::FromImage(ImageName {
1029                            ..Default::default()
1030                        }),
1031                        run: Run {
1032                            run: vec!["echo Hello".into()],
1033                            ..Default::default()
1034                        },
1035                        ..Default::default()
1036                    },
1037                )]),
1038                ..Default::default()
1039            };
1040
1041            let lint_session = LintSession::analyze(&dofigen);
1042
1043            assert_eq_sorted!(
1044                lint_session.messages,
1045                vec![LintMessage {
1046                    level: MessageLevel::Warn,
1047                    path: vec!["builders".into(), "builder".into()],
1048                    message: "The builder 'builder' is not used and should be removed".into(),
1049                },]
1050            );
1051        }
1052    }
1053
1054    mod user {
1055        use super::*;
1056
1057        #[test]
1058        fn uid() {
1059            let dofigen = Dofigen {
1060                stage: Stage {
1061                    user: Some(User::new("1000")),
1062                    ..Default::default()
1063                },
1064                ..Default::default()
1065            };
1066
1067            let lint_session = LintSession::analyze(&dofigen);
1068
1069            assert_eq_sorted!(lint_session.messages, vec![]);
1070        }
1071
1072        #[test]
1073        fn username() {
1074            let dofigen = Dofigen {
1075                stage: Stage {
1076                    user: Some(User::new("test")),
1077                    ..Default::default()
1078                },
1079                ..Default::default()
1080            };
1081
1082            let lint_session = LintSession::analyze(&dofigen);
1083
1084            assert_eq_sorted!(
1085                lint_session.messages,
1086                vec![LintMessage {
1087                    level: MessageLevel::Warn,
1088                    path: vec!["user".into()],
1089                    message: "UID should be used instead of username".into(),
1090                },]
1091            );
1092        }
1093    }
1094
1095    mod from_context {
1096        use super::*;
1097
1098        #[test]
1099        fn stage_and_copy() {
1100            let dofigen = Dofigen {
1101                stage: Stage {
1102                    from: FromContext::FromContext(Some("php:8.3-fpm-alpine".into())),
1103                    copy: vec![CopyResource::Copy(Copy {
1104                        from: FromContext::FromContext(Some("composer:latest".into())),
1105                        paths: vec!["/usr/bin/composer".into()],
1106                        ..Default::default()
1107                    })],
1108                    ..Default::default()
1109                },
1110                ..Default::default()
1111            };
1112
1113            let lint_session = LintSession::analyze(&dofigen);
1114
1115            assert_eq_sorted!(lint_session.messages, vec![
1116                LintMessage {
1117                    level: MessageLevel::Warn,
1118                    path: vec!["fromContext".into()],
1119                    message: "Prefer to use fromImage and fromBuilder instead of fromContext".into(),   
1120                },
1121                LintMessage {
1122                    level: MessageLevel::Warn,
1123                    path: vec!["copy".into(), "0".into(), "fromContext".into()],
1124                    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(),
1125                }
1126            ]);
1127        }
1128
1129        #[test]
1130        fn root_bind() {
1131            let dofigen = Dofigen {
1132                builders: HashMap::from([(
1133                    "builder".into(),
1134                    Stage {
1135                        root: Some(Run {
1136                            bind: vec![Bind {
1137                                from: FromContext::FromContext(Some("builder".into())),
1138                                source: Some("/path/to/bind".into()),
1139                                target: "/path/to/target".into(),
1140                                ..Default::default()
1141                            }],
1142                            run: vec!["echo Hello".into()],
1143                            ..Default::default()
1144                        }),
1145                        ..Default::default()
1146                    },
1147                )]),
1148                stage: Stage {
1149                    from: FromContext::FromBuilder("builder".into()),
1150                    ..Default::default()
1151                },
1152                ..Default::default()
1153            };
1154
1155            let lint_session = LintSession::analyze(&dofigen);
1156
1157            assert_eq_sorted!(lint_session.messages, vec![
1158                LintMessage {
1159                    level: MessageLevel::Warn,
1160                    path: vec![
1161                        "builders".into(),
1162                        "builder".into(),
1163                        "root".into(),
1164                        "bind".into(),
1165                        "0".into(),
1166                        "fromContext".into(),
1167                    ],
1168                    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(),
1169                }
1170            ]);
1171        }
1172    }
1173
1174    mod run {
1175        use super::*;
1176
1177        #[test]
1178        fn empty_run() {
1179            let dofigen = Dofigen {
1180                stage: Stage {
1181                    run: Run {
1182                        bind: vec![Bind {
1183                            source: Some("/path/to/bind".into()),
1184                            target: "/path/to/target".into(),
1185                            ..Default::default()
1186                        }],
1187                        cache: vec![Cache {
1188                            source: Some("/path/to/cache".into()),
1189                            target: "/path/to/target".into(),
1190                            ..Default::default()
1191                        }],
1192                        ..Default::default()
1193                    },
1194                    ..Default::default()
1195                },
1196                ..Default::default()
1197            };
1198
1199            let lint_session = LintSession::analyze(&dofigen);
1200
1201            assert_eq_sorted!(
1202                lint_session.messages,
1203                vec![
1204                    LintMessage {
1205                        level: MessageLevel::Warn,
1206                        message: "The run list is empty but there are bind definitions".into(),
1207                        path: vec!["bind".into()],
1208                    },
1209                    LintMessage {
1210                        level: MessageLevel::Warn,
1211                        message: "The run list is empty but there are cache definitions".into(),
1212                        path: vec!["cache".into()],
1213                    },
1214                ]
1215            );
1216        }
1217    }
1218}