1use 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#[derive(Builder, Debug, Clone)]
102#[builder(field(private))]
103pub struct ThirdParty {
104 #[builder(setter(into))]
108 pub name: String,
109 #[builder(setter(into))]
113 pub path: String,
114 #[builder(setter(into))]
118 pub root: String,
119 #[builder(setter(into))]
123 pub utility: String,
124}
125
126impl ThirdParty {
127 pub fn builder() -> ThirdPartyBuilder {
129 Default::default()
130 }
131}
132
133enum CheckRefResult {
135 IsImport,
137 Rejected(String),
141}
142
143impl CheckRefResult {
144 #[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 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 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 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_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 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 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 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 #[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}