git-checks 4.2.1

Checks to run against a topic in git to enforce coding standards.
Documentation
// Copyright Kitware, Inc.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use derive_builder::Builder;
use git_checks_core::impl_prelude::*;

use std::char::REPLACEMENT_CHARACTER;

const UNICODE_BIDI_CHARS: &[char] = &[
    '\u{202A}', '\u{202B}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{202C}',
    '\u{2069}',
];

/// A check which denies commits which add text lines containing bidirectional control characters.
///
/// Files may be marked as binary by unsetting the `text` attribute. All content is assumed to be
/// UTF-8 encoded.
#[derive(Builder, Debug, Default, Clone, Copy)]
#[builder(field(private))]
pub struct RejectBiDi {
    /// Whether bidirectional control characters are allowed at all.
    ///
    /// Configuration: Optional
    /// Default: false
    #[builder(default = "false")]
    allow: bool,
}

impl RejectBiDi {
    /// Create a new builder.
    pub fn builder() -> RejectBiDiBuilder {
        RejectBiDiBuilder::default()
    }
}

impl ContentCheck for RejectBiDi {
    fn name(&self) -> &str {
        "reject-bidi"
    }

    fn check(
        &self,
        ctx: &CheckGitContext,
        content: &dyn Content,
    ) -> Result<CheckResult, Box<dyn Error>> {
        let mut result = CheckResult::new();

        for diff in content.diffs() {
            match diff.status {
                StatusChange::Added | StatusChange::Modified(_) => (),
                _ => continue,
            }

            let diff_attr = ctx.check_attr("diff", diff.name.as_path())?;
            if let AttributeState::Unset = diff_attr {
                // Binary files should not be handled here.
                continue;
            }

            let patch = match content.path_diff(&diff.name) {
                Ok(s) => s,
                Err(err) => {
                    result.add_alert(
                        format!(
                            "{}failed to get the diff for file `{}`: {}.",
                            commit_prefix(content),
                            diff.name,
                            err,
                        ),
                        true,
                    );
                    continue;
                },
            };

            for line in patch.lines().filter(|line| line.starts_with('+')) {
                let line_bidi_free: String = line
                    .chars()
                    .map(|c| {
                        if UNICODE_BIDI_CHARS.contains(&c) {
                            REPLACEMENT_CHARACTER
                        } else {
                            c
                        }
                    })
                    .collect();
                if line_bidi_free != line {
                    let safe_line = line_bidi_free[1..]
                        .replace('\\', "\\\\")
                        .replace('`', "\\`");
                    if self.allow {
                        result.add_warning(format!(
                            "{}Unicode bidirectional control character(s) added in `{}`: `{}`.",
                            commit_prefix_str(content, "needs checked;"),
                            diff.name,
                            safe_line,
                        ));
                    } else {
                        result.add_error(format!(
                            "{}Unicode bidirectional control character(s) added in `{}`: `{}`.",
                            commit_prefix_str(content, "not allowed;"),
                            diff.name,
                            safe_line,
                        ));
                    }
                }
            }
        }

        Ok(result)
    }
}

#[cfg(feature = "config")]
pub(crate) mod config {
    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
    use serde::Deserialize;
    #[cfg(test)]
    use serde_json::json;

    use crate::RejectBiDi;

    /// Configuration for the `RejectBiDi` check.
    ///
    /// The `allow` key is a boolean as to whether the check should raise an error rather than a
    /// warning. Defaults to `false`.
    ///
    /// This check is registered as a commit check with the name `"reject_bidi"` and a topic check
    /// with the name `"reject_bidi/topic"`.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///     "allow": true
    /// }
    /// ```
    #[derive(Deserialize, Debug)]
    pub struct RejectBiDiConfig {
        #[serde(default)]
        allow: bool,
    }

    impl IntoCheck for RejectBiDiConfig {
        type Check = RejectBiDi;

        fn into_check(self) -> Self::Check {
            let mut builder = RejectBiDi::builder();

            builder.allow(self.allow);

            builder
                .build()
                .expect("configuration mismatch for `RejectBiDi`")
        }
    }

    register_checks! {
        RejectBiDiConfig {
            "reject_bidi" => CommitCheckConfig,
            "reject_bidi/topic" => TopicCheckConfig,
        },
    }

    #[test]
    fn test_reject_bidi_config_empty() {
        let json = json!({});
        let check: RejectBiDiConfig = serde_json::from_value(json).unwrap();

        assert!(!check.allow);

        let check = check.into_check();

        assert!(!check.allow);
    }

    #[test]
    fn test_reject_bidi_config_all_fields() {
        let json = json!({
            "allow": true,
        });
        let check: RejectBiDiConfig = serde_json::from_value(json).unwrap();

        assert!(check.allow);

        let check = check.into_check();

        assert!(check.allow);
    }
}

#[cfg(test)]
mod tests {
    use git_checks_core::{Check, TopicCheck};

    use crate::test::*;
    use crate::RejectBiDi;

    const BAD_TOPIC: &str = "678c1deeade619d52c5b0990bb05af79017f2787";
    const DELETE_TOPIC: &str = "0a5b9308fcedec797d70ba78bb1b92a7b7943828";
    const FIX_TOPIC: &str = "0a5b9308fcedec797d70ba78bb1b92a7b7943828";

    #[test]
    fn test_reject_bidi_builder_default() {
        assert!(RejectBiDi::builder().build().is_ok());
    }

    #[test]
    fn test_reject_bidi_name_commit() {
        let check = RejectBiDi::default();
        assert_eq!(Check::name(&check), "reject-bidi");
    }

    #[test]
    fn test_reject_bidi_name_topic() {
        let check = RejectBiDi::default();
        assert_eq!(TopicCheck::name(&check), "reject-bidi");
    }

    #[test]
    fn test_reject_bidi() {
        let check = RejectBiDi::default();
        let result = run_check("test_reject_bidi", BAD_TOPIC, check);
        test_result_errors(result, &[
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
        ]);
    }

    #[test]
    fn test_reject_bidi_allow() {
        let check = RejectBiDi::builder().allow(true).build().unwrap();
        let result = run_check("test_reject_bidi_allow", BAD_TOPIC, check);
        test_result_warnings(result, &[
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
        ]);
    }

    #[test]
    fn test_reject_bidi_topic() {
        let check = RejectBiDi::default();
        let result = run_topic_check("test_reject_bidi_topic", BAD_TOPIC, check);
        test_result_errors(
            result,
            &[
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
            ],
        );
    }

    #[test]
    fn test_reject_bidi_topic_allow() {
        let check = RejectBiDi::builder().allow(true).build().unwrap();
        let result = run_topic_check("test_reject_bidi_topic_allow", BAD_TOPIC, check);
        test_result_warnings(
            result,
            &[
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
                 \u{fffd}`.",
            ],
        );
    }

    #[test]
    fn test_reject_bidi_delete_file() {
        let check = RejectBiDi::default();
        let conf = make_check_conf(&check);

        let result = test_check_base(
            "test_reject_bidi_delete_file",
            DELETE_TOPIC,
            BAD_TOPIC,
            &conf,
        );
        test_result_ok(result);
    }

    #[test]
    fn test_reject_bidi_delete_file_topic() {
        let check = RejectBiDi::default();
        let result = run_topic_check("test_reject_bidi_delete_file_topic", DELETE_TOPIC, check);
        test_result_ok(result);
    }

    #[test]
    fn test_reject_bidi_topic_fixed() {
        let check = RejectBiDi::default();
        run_topic_check_ok("test_reject_bidi_topic_fixed", FIX_TOPIC, check);
    }
}