git_checks/
third_party.rs

1// Copyright Kitware, Inc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use std::collections::BTreeSet;
10
11use derive_builder::Builder;
12use git_checks_core::impl_prelude::*;
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16enum ThirdPartyError {
17    #[error(
18        "failed to list revisions to find the root commit of {} for {}: {}",
19        commit,
20        import,
21        output
22    )]
23    FindRoot {
24        import: String,
25        commit: CommitId,
26        output: String,
27    },
28    #[error(
29        "failed to get the tree object for {} (expected) for {}: {}",
30        commit,
31        import,
32        output
33    )]
34    ExpectedTreeObject {
35        import: String,
36        commit: CommitId,
37        output: String,
38    },
39    #[error(
40        "failed to get the tree object for {} (actual) for {}: {}",
41        commit,
42        import,
43        output
44    )]
45    ActualTreeObject {
46        import: String,
47        commit: CommitId,
48        output: String,
49    },
50    #[error("unexpected output from `git ls-tree`: {}", output)]
51    LsTreeOutput { output: String },
52}
53
54impl ThirdPartyError {
55    fn find_root(import: String, commit: CommitId, output: &[u8]) -> Self {
56        ThirdPartyError::FindRoot {
57            import,
58            commit,
59            output: String::from_utf8_lossy(output).into(),
60        }
61    }
62
63    fn expected_tree_object(import: String, commit: CommitId, output: &[u8]) -> Self {
64        ThirdPartyError::ExpectedTreeObject {
65            import,
66            commit,
67            output: String::from_utf8_lossy(output).into(),
68        }
69    }
70
71    fn actual_tree_object(import: String, commit: CommitId, output: &[u8]) -> Self {
72        ThirdPartyError::ActualTreeObject {
73            import,
74            commit,
75            output: String::from_utf8_lossy(output).into(),
76        }
77    }
78
79    fn ls_tree_output(output: String) -> Self {
80        ThirdPartyError::LsTreeOutput {
81            output,
82        }
83    }
84}
85
86/// Description of a third party package imported using Kitware's third party import process.
87///
88/// The workflow used at Kitware for third party packages is to keep all changes tracked in
89/// separate repositories. This makes tracking patches to the projects easier to manage and extract
90/// for submission to the appropriate upstream project.
91///
92/// When a project is imported, it uses a separate history which contains only snapshots of the
93/// tracked repository. When imported into a project, it can select a subset of files to keep, drop
94/// extra metadata into the import, or perform other transformations as necessary. Whatever the
95/// result of that is, it is added as a new commit on the history of the tracking branch for the
96/// project. This is then merged into the main project using a subtree strategy to move the project
97/// to the correct place.
98///
99/// This check checks to make sure that any modifications in the main project's imported location of
100/// the third party project are made on the tracking branch.
101#[derive(Builder, Debug, Clone)]
102#[builder(field(private))]
103pub struct ThirdParty {
104    /// The name of the imported project.
105    ///
106    /// Configuration: Required
107    #[builder(setter(into))]
108    pub name: String,
109    /// The path the third party project lives once merged.
110    ///
111    /// Configuration: Required
112    #[builder(setter(into))]
113    pub path: String,
114    /// The root commit of the third party tracking branch.
115    ///
116    /// Configuration: Required
117    #[builder(setter(into))]
118    pub root: String,
119    /// The location of the utility to use for importing this project.
120    ///
121    /// Configuration: Required
122    #[builder(setter(into))]
123    pub utility: String,
124}
125
126impl ThirdParty {
127    /// Create a new third party import configuration.
128    pub fn builder() -> ThirdPartyBuilder {
129        Default::default()
130    }
131}
132
133/// Whether a commit is on the import branch or not.
134enum CheckRefResult {
135    /// Indicates the commit is on the import branch.
136    IsImport,
137    /// Indicates the commit is not part of the import branch.
138    ///
139    /// Stores its error message.
140    Rejected(String),
141}
142
143impl CheckRefResult {
144    /// Whether the result is an import or not.
145    #[allow(clippy::match_like_matches_macro)]
146    fn is_import(&self) -> bool {
147        if let CheckRefResult::IsImport = *self {
148            true
149        } else {
150            false
151        }
152    }
153
154    /// Add the import result to a check result.
155    fn add_result(self, result: &mut CheckResult) {
156        if let CheckRefResult::Rejected(err) = self {
157            result.add_error(err);
158        }
159    }
160}
161
162impl Check for ThirdParty {
163    fn name(&self) -> &str {
164        "third-party"
165    }
166
167    fn check(&self, ctx: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
168        let mut result = CheckResult::new();
169
170        // A flag to indicate that we need to check the commit for an evil merge.
171        let mut check_tree = false;
172        let mut names_checked = BTreeSet::new();
173
174        for diff in &commit.diffs {
175            let name_path = diff.name.as_path();
176
177            if !name_path.starts_with(&self.path) {
178                continue;
179            }
180
181            // Avoid checking the same path multiple times. This can occur when an evil merge and
182            // at least one of its parents modify the same path.
183            if !names_checked.insert(diff.name.as_bytes()) {
184                continue;
185            }
186
187            let check_ref = |sha1: &CommitId| -> Result<_, Box<dyn Error>> {
188                let rev_list = ctx
189                    .git()
190                    .arg("rev-list")
191                    .arg("--first-parent")
192                    .arg("--max-parents=0")
193                    .arg(sha1.as_str())
194                    .output()
195                    .map_err(|err| GitError::subcommand("rev-list", err))?;
196                if !rev_list.status.success() {
197                    return Err(ThirdPartyError::find_root(
198                        self.name.clone(),
199                        sha1.clone(),
200                        &rev_list.stderr,
201                    )
202                    .into());
203                }
204                let refs = String::from_utf8_lossy(&rev_list.stdout);
205                let is_import = self.root == refs.trim();
206
207                Ok(if is_import {
208                    CheckRefResult::IsImport
209                } else {
210                    let msg = format!(
211                        "commit {} not allowed; the `{}` file is maintained by the third party \
212                         utilities; please use `{}` to update this file.",
213                        commit.sha1, diff.name, self.utility,
214                    );
215
216                    CheckRefResult::Rejected(msg)
217                })
218            };
219
220            if commit.parents.len() == 2 {
221                let is_import = check_ref(&commit.parents[1])?.is_import();
222
223                if is_import {
224                    // Check that the merge commit is not "evil" for the import directory.
225                    check_tree = true;
226                } else {
227                    check_ref(&commit.sha1)?.add_result(&mut result);
228                }
229            } else {
230                check_ref(&commit.sha1)?.add_result(&mut result);
231            }
232        }
233
234        if check_tree {
235            // Get the tree of the import branch.
236            let rev_parse = ctx
237                .git()
238                .arg("rev-parse")
239                .arg(format!("{}^{{tree}}", commit.parents[1]))
240                .output()
241                .map_err(|err| GitError::subcommand("rev-parse", err))?;
242            if !rev_parse.status.success() {
243                return Err(ThirdPartyError::expected_tree_object(
244                    self.name.clone(),
245                    commit.parents[1].clone(),
246                    &rev_parse.stderr,
247                )
248                .into());
249            }
250            let expected_tree = String::from_utf8_lossy(&rev_parse.stdout);
251
252            // Get the tree of the imported tree in the merge commit.
253            let ls_tree = ctx
254                .git()
255                .arg("ls-tree")
256                .arg(commit.sha1.as_str())
257                .arg(&self.path)
258                .output()
259                .map_err(|err| GitError::subcommand("ls-tree", err))?;
260            if !ls_tree.status.success() {
261                return Err(ThirdPartyError::actual_tree_object(
262                    self.name.clone(),
263                    commit.sha1.clone(),
264                    &ls_tree.stderr,
265                )
266                .into());
267            }
268            let ls_tree_output = String::from_utf8_lossy(&ls_tree.stdout);
269            let actual_tree = ls_tree_output
270                .split_whitespace()
271                .nth(2)
272                .ok_or_else(|| ThirdPartyError::ls_tree_output(ls_tree_output.clone().into()))?;
273
274            // Ensure the trees match.
275            if actual_tree != expected_tree.trim() {
276                let msg = format!(
277                    "commit {} not allowed; the `{}` directory contains changes not on the import \
278                     branch; merge conflicts should not happen and indicate that the import \
279                     directory was manually edited at some point. Please find and revert the bad \
280                     edit, apply it to the imported repository (if necessary), and then run the \
281                     import utility.",
282                    commit.sha1, self.path,
283                );
284
285                result.add_error(msg);
286            }
287        }
288
289        Ok(result)
290    }
291}
292
293#[cfg(feature = "config")]
294pub(crate) mod config {
295    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck};
296    use serde::Deserialize;
297    #[cfg(test)]
298    use serde_json::json;
299
300    #[cfg(test)]
301    use crate::test;
302    use crate::ThirdParty;
303
304    /// Configuration for the `ThirdParty` check.
305    ///
306    /// All keys are required and strings. The `name` and `script` keys are informational and only
307    /// appear in messages. Any modifications at `path` are checked to ensure that they are tracked
308    /// on an "import branch" rooted with the given commit specified by the `root` key.
309    ///
310    /// This check is registered as a commit check with the name `"third_party"`.
311    ///
312    /// # Example
313    ///
314    /// ```json
315    /// {
316    ///     "name": "extlib",
317    ///     "path": "path/to/import/of/extlib",
318    ///     "root": "root commit",
319    ///     "script": "path/to/update/script"
320    /// }
321    /// ```
322    #[derive(Deserialize, Debug)]
323    pub struct ThirdPartyConfig {
324        name: String,
325        path: String,
326        root: String,
327        utility: String,
328    }
329
330    impl IntoCheck for ThirdPartyConfig {
331        type Check = ThirdParty;
332
333        fn into_check(self) -> Self::Check {
334            ThirdParty::builder()
335                .name(self.name)
336                .path(self.path)
337                .root(self.root)
338                .utility(self.utility)
339                .build()
340                .expect("configuration mismatch for `ThirdParty`")
341        }
342    }
343
344    register_checks! {
345        ThirdPartyConfig {
346            "third_party" => CommitCheckConfig,
347        },
348    }
349
350    #[test]
351    fn test_third_party_config_empty() {
352        let json = json!({});
353        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
354        test::check_missing_json_field(err, "name");
355    }
356
357    #[test]
358    fn test_third_party_config_name_is_required() {
359        let exp_path = "path/to/import/of/extlib";
360        let exp_root = "root commit";
361        let exp_utility = "path/to/update/utility";
362        let json = json!({
363            "path": exp_path,
364            "root": exp_root,
365            "utility": exp_utility,
366        });
367        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
368        test::check_missing_json_field(err, "name");
369    }
370
371    #[test]
372    fn test_third_party_config_path_is_required() {
373        let exp_name = "extlib";
374        let exp_root = "root commit";
375        let exp_utility = "path/to/update/utility";
376        let json = json!({
377            "name": exp_name,
378            "root": exp_root,
379            "utility": exp_utility,
380        });
381        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
382        test::check_missing_json_field(err, "path");
383    }
384
385    #[test]
386    fn test_third_party_config_root_is_required() {
387        let exp_name = "extlib";
388        let exp_path = "path/to/import/of/extlib";
389        let exp_utility = "path/to/update/utility";
390        let json = json!({
391            "name": exp_name,
392            "path": exp_path,
393            "utility": exp_utility,
394        });
395        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
396        test::check_missing_json_field(err, "root");
397    }
398
399    #[test]
400    fn test_third_party_config_utility_is_required() {
401        let exp_name = "extlib";
402        let exp_path = "path/to/import/of/extlib";
403        let exp_root = "root commit";
404        let json = json!({
405            "name": exp_name,
406            "path": exp_path,
407            "root": exp_root,
408        });
409        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
410        test::check_missing_json_field(err, "utility");
411    }
412
413    #[test]
414    fn test_third_party_config_minimum_fields() {
415        let exp_name = "extlib";
416        let exp_path = "path/to/import/of/extlib";
417        let exp_root = "root commit";
418        let exp_utility = "path/to/update/utility";
419        let json = json!({
420            "name": exp_name,
421            "path": exp_path,
422            "root": exp_root,
423            "utility": exp_utility,
424        });
425        let check: ThirdPartyConfig = serde_json::from_value(json).unwrap();
426
427        assert_eq!(check.name, exp_name);
428        assert_eq!(check.path, exp_path);
429        assert_eq!(check.root, exp_root);
430        assert_eq!(check.utility, exp_utility);
431
432        let check = check.into_check();
433
434        assert_eq!(check.name, exp_name);
435        assert_eq!(check.path, exp_path);
436        assert_eq!(check.root, exp_root);
437        assert_eq!(check.utility, exp_utility);
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use git_checks_core::Check;
444
445    use crate::test::*;
446    use crate::ThirdParty;
447
448    const BASE_COMMIT: &str = "26576e49345a141eca310af92737e489c9baac24";
449    const VALID_UPDATE_TOPIC: &str = "0bd161c8187d4f727a7acc17020711dcc139b166";
450    const INVALID_UPDATE_TOPIC: &str = "af154fdff05c871125f2db03eccbdde8571d484e";
451    const EVIL_UPDATE_TOPIC: &str = "add18e5ab9a67303337cb2754c675fb2e0a45a79";
452    const EVIL_UPDATE_TOPIC_AND_PARENT: &str = "1c6a384e064b0fc5a80685216f24dd702bdfa5c7";
453
454    #[test]
455    fn test_third_party_builder_default() {
456        assert!(ThirdParty::builder().build().is_err());
457    }
458
459    #[test]
460    fn test_third_party_builder_name_is_required() {
461        assert!(ThirdParty::builder()
462            .path("check_size")
463            .root("d50197ebd7167b0941d34405686164068db0b77b")
464            .utility("./update.sh")
465            .build()
466            .is_err());
467    }
468
469    #[test]
470    fn test_third_party_builder_path_is_required() {
471        assert!(ThirdParty::builder()
472            .name("check_size")
473            .root("d50197ebd7167b0941d34405686164068db0b77b")
474            .utility("./update.sh")
475            .build()
476            .is_err());
477    }
478
479    #[test]
480    fn test_third_party_builder_root_is_required() {
481        assert!(ThirdParty::builder()
482            .name("check_size")
483            .path("check_size")
484            .utility("./update.sh")
485            .build()
486            .is_err());
487    }
488
489    #[test]
490    fn test_third_party_builder_utility_is_required() {
491        assert!(ThirdParty::builder()
492            .name("check_size")
493            .path("check_size")
494            .root("d50197ebd7167b0941d34405686164068db0b77b")
495            .build()
496            .is_err());
497    }
498
499    #[test]
500    fn test_third_party_builder_minimum_fields() {
501        assert!(ThirdParty::builder()
502            .name("check_size")
503            .path("check_size")
504            .root("d50197ebd7167b0941d34405686164068db0b77b")
505            .utility("./update.sh")
506            .build()
507            .is_ok());
508    }
509
510    #[test]
511    fn test_third_party_name_commit() {
512        let check = ThirdParty::builder()
513            .name("check_size")
514            .path("check_size")
515            .root("d50197ebd7167b0941d34405686164068db0b77b")
516            .utility("./update.sh")
517            .build()
518            .unwrap();
519        assert_eq!(Check::name(&check), "third-party");
520    }
521
522    fn make_third_party_check() -> ThirdParty {
523        ThirdParty::builder()
524            .name("check_size")
525            .path("check_size")
526            .root("d50197ebd7167b0941d34405686164068db0b77b")
527            .utility("./update.sh")
528            .build()
529            .unwrap()
530    }
531
532    #[test]
533    fn test_third_party_valid_update() {
534        let check = make_third_party_check();
535        let conf = make_check_conf(&check);
536
537        let result = test_check_base(
538            "test_third_party_valid_update",
539            VALID_UPDATE_TOPIC,
540            BASE_COMMIT,
541            &conf,
542        );
543        test_result_ok(result);
544    }
545
546    #[test]
547    fn test_third_party_invalid_update() {
548        let check = make_third_party_check();
549        let conf = make_check_conf(&check);
550
551        let result = test_check_base(
552            "test_third_party_invalid_update",
553            INVALID_UPDATE_TOPIC,
554            BASE_COMMIT,
555            &conf,
556        );
557        test_result_errors(result, &[
558            "commit af154fdff05c871125f2db03eccbdde8571d484e not allowed; the \
559             `check_size/increased-limit` file is maintained by the third party utilities; please \
560             use `./update.sh` to update this file.",
561        ]);
562    }
563
564    #[test]
565    fn test_third_party_invalid_update_evil() {
566        let check = make_third_party_check();
567        let conf = make_check_conf(&check);
568
569        let result = test_check_base(
570            "test_third_party_invalid_update_evil",
571            EVIL_UPDATE_TOPIC,
572            BASE_COMMIT,
573            &conf,
574        );
575        test_result_errors(
576            result,
577            &[
578                "commit add18e5ab9a67303337cb2754c675fb2e0a45a79 not allowed; the `check_size` \
579                 directory contains changes not on the import branch; merge conflicts should not \
580                 happen and indicate that the import directory was manually edited at some point. \
581                 Please find and revert the bad edit, apply it to the imported repository (if \
582                 necessary), and then run the import utility.",
583            ],
584        );
585    }
586
587    #[test]
588    fn test_third_party_invalid_update_evil_and_parent() {
589        let check = make_third_party_check();
590        let conf = make_check_conf(&check);
591
592        let result = test_check_base(
593            "test_third_party_invalid_update_evil_and_parent",
594            EVIL_UPDATE_TOPIC_AND_PARENT,
595            BASE_COMMIT,
596            &conf,
597        );
598        test_result_errors(result, &[
599            "commit af154fdff05c871125f2db03eccbdde8571d484e not allowed; the \
600             `check_size/increased-limit` file is maintained by the third party utilities; please \
601             use `./update.sh` to update this file.",
602            "commit 1c6a384e064b0fc5a80685216f24dd702bdfa5c7 not allowed; the \
603             `check_size/increased-limit` file is maintained by the third party utilities; please \
604             use `./update.sh` to update this file.",
605        ]);
606    }
607}