git_checks/
reject_bidi.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 derive_builder::Builder;
10use git_checks_core::impl_prelude::*;
11
12use std::char::REPLACEMENT_CHARACTER;
13
14const UNICODE_BIDI_CHARS: &[char] = &[
15    '\u{202A}', '\u{202B}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{202C}',
16    '\u{2069}',
17];
18
19/// A check which denies commits which add text lines containing bidirectional control characters.
20///
21/// Files may be marked as binary by unsetting the `text` attribute. All content is assumed to be
22/// UTF-8 encoded.
23#[derive(Builder, Debug, Default, Clone, Copy)]
24#[builder(field(private))]
25pub struct RejectBiDi {
26    /// Whether bidirectional control characters are allowed at all.
27    ///
28    /// Configuration: Optional
29    /// Default: false
30    #[builder(default = "false")]
31    allow: bool,
32}
33
34impl RejectBiDi {
35    /// Create a new builder.
36    pub fn builder() -> RejectBiDiBuilder {
37        Default::default()
38    }
39}
40
41impl ContentCheck for RejectBiDi {
42    fn name(&self) -> &str {
43        "reject-bidi"
44    }
45
46    fn check(
47        &self,
48        ctx: &CheckGitContext,
49        content: &dyn Content,
50    ) -> Result<CheckResult, Box<dyn Error>> {
51        let mut result = CheckResult::new();
52
53        for diff in content.diffs() {
54            match diff.status {
55                StatusChange::Added | StatusChange::Modified(_) => (),
56                _ => continue,
57            }
58
59            let diff_attr = ctx.check_attr("diff", diff.name.as_path())?;
60            if let AttributeState::Unset = diff_attr {
61                // Binary files should not be handled here.
62                continue;
63            }
64
65            let patch = match content.path_diff(&diff.name) {
66                Ok(s) => s,
67                Err(err) => {
68                    result.add_alert(
69                        format!(
70                            "{}failed to get the diff for file `{}`: {}.",
71                            commit_prefix(content),
72                            diff.name,
73                            err,
74                        ),
75                        true,
76                    );
77                    continue;
78                },
79            };
80
81            for line in patch.lines().filter(|line| line.starts_with('+')) {
82                let line_bidi_free: String = line
83                    .chars()
84                    .map(|c| {
85                        if UNICODE_BIDI_CHARS.contains(&c) {
86                            REPLACEMENT_CHARACTER
87                        } else {
88                            c
89                        }
90                    })
91                    .collect();
92                if line_bidi_free != line {
93                    let safe_line = line_bidi_free[1..]
94                        .replace('\\', "\\\\")
95                        .replace('`', "\\`");
96                    if self.allow {
97                        result.add_warning(format!(
98                            "{}Unicode bidirectional control character(s) added in `{}`: `{}`.",
99                            commit_prefix_str(content, "needs checked;"),
100                            diff.name,
101                            safe_line,
102                        ));
103                    } else {
104                        result.add_error(format!(
105                            "{}Unicode bidirectional control character(s) added in `{}`: `{}`.",
106                            commit_prefix_str(content, "not allowed;"),
107                            diff.name,
108                            safe_line,
109                        ));
110                    }
111                }
112            }
113        }
114
115        Ok(result)
116    }
117}
118
119#[cfg(feature = "config")]
120pub(crate) mod config {
121    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
122    use serde::Deserialize;
123    #[cfg(test)]
124    use serde_json::json;
125
126    use crate::RejectBiDi;
127
128    /// Configuration for the `RejectBiDi` check.
129    ///
130    /// The `allow` key is a boolean as to whether the check should raise an error rather than a
131    /// warning. Defaults to `false`.
132    ///
133    /// This check is registered as a commit check with the name `"reject_bidi"` and a topic check
134    /// with the name `"reject_bidi/topic"`.
135    ///
136    /// # Example
137    ///
138    /// ```json
139    /// {
140    ///     "allow": true
141    /// }
142    /// ```
143    #[derive(Deserialize, Debug)]
144    pub struct RejectBiDiConfig {
145        #[serde(default)]
146        allow: bool,
147    }
148
149    impl IntoCheck for RejectBiDiConfig {
150        type Check = RejectBiDi;
151
152        fn into_check(self) -> Self::Check {
153            let mut builder = RejectBiDi::builder();
154
155            builder.allow(self.allow);
156
157            builder
158                .build()
159                .expect("configuration mismatch for `RejectBiDi`")
160        }
161    }
162
163    register_checks! {
164        RejectBiDiConfig {
165            "reject_bidi" => CommitCheckConfig,
166            "reject_bidi/topic" => TopicCheckConfig,
167        },
168    }
169
170    #[test]
171    fn test_reject_bidi_config_empty() {
172        let json = json!({});
173        let check: RejectBiDiConfig = serde_json::from_value(json).unwrap();
174
175        assert!(!check.allow);
176
177        let check = check.into_check();
178
179        assert!(!check.allow);
180    }
181
182    #[test]
183    fn test_reject_bidi_config_all_fields() {
184        let json = json!({
185            "allow": true,
186        });
187        let check: RejectBiDiConfig = serde_json::from_value(json).unwrap();
188
189        assert!(check.allow);
190
191        let check = check.into_check();
192
193        assert!(check.allow);
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use git_checks_core::{Check, TopicCheck};
200
201    use crate::test::*;
202    use crate::RejectBiDi;
203
204    const BAD_TOPIC: &str = "678c1deeade619d52c5b0990bb05af79017f2787";
205    const DELETE_TOPIC: &str = "0a5b9308fcedec797d70ba78bb1b92a7b7943828";
206    const FIX_TOPIC: &str = "0a5b9308fcedec797d70ba78bb1b92a7b7943828";
207
208    #[test]
209    fn test_reject_bidi_builder_default() {
210        assert!(RejectBiDi::builder().build().is_ok());
211    }
212
213    #[test]
214    fn test_reject_bidi_name_commit() {
215        let check = RejectBiDi::default();
216        assert_eq!(Check::name(&check), "reject-bidi");
217    }
218
219    #[test]
220    fn test_reject_bidi_name_topic() {
221        let check = RejectBiDi::default();
222        assert_eq!(TopicCheck::name(&check), "reject-bidi");
223    }
224
225    #[test]
226    fn test_reject_bidi() {
227        let check = RejectBiDi::default();
228        let result = run_check("test_reject_bidi", BAD_TOPIC, check);
229        test_result_errors(result, &[
230            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
231             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
232            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
233             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
234            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
235             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
236            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
237             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
238            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
239             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
240            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
241             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
242            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
243             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
244            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
245             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
246        ]);
247    }
248
249    #[test]
250    fn test_reject_bidi_allow() {
251        let check = RejectBiDi::builder().allow(true).build().unwrap();
252        let result = run_check("test_reject_bidi_allow", BAD_TOPIC, check);
253        test_result_warnings(result, &[
254            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
255             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
256            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
257             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
258            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
259             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
260            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
261             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
262            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
263             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
264            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
265             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
266            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
267             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
268            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
269             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
270        ]);
271    }
272
273    #[test]
274    fn test_reject_bidi_topic() {
275        let check = RejectBiDi::default();
276        let result = run_topic_check("test_reject_bidi_topic", BAD_TOPIC, check);
277        test_result_errors(
278            result,
279            &[
280                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
281                 \u{fffd}`.",
282                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
283                 \u{fffd}`.",
284                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
285                 \u{fffd}`.",
286                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
287                 \u{fffd}`.",
288                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
289                 \u{fffd}`.",
290                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
291                 \u{fffd}`.",
292                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
293                 \u{fffd}`.",
294                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
295                 \u{fffd}`.",
296            ],
297        );
298    }
299
300    #[test]
301    fn test_reject_bidi_topic_allow() {
302        let check = RejectBiDi::builder().allow(true).build().unwrap();
303        let result = run_topic_check("test_reject_bidi_topic_allow", BAD_TOPIC, check);
304        test_result_warnings(
305            result,
306            &[
307                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
308                 \u{fffd}`.",
309                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
310                 \u{fffd}`.",
311                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
312                 \u{fffd}`.",
313                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
314                 \u{fffd}`.",
315                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
316                 \u{fffd}`.",
317                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
318                 \u{fffd}`.",
319                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
320                 \u{fffd}`.",
321                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
322                 \u{fffd}`.",
323            ],
324        );
325    }
326
327    #[test]
328    fn test_reject_bidi_delete_file() {
329        let check = RejectBiDi::default();
330        let conf = make_check_conf(&check);
331
332        let result = test_check_base(
333            "test_reject_bidi_delete_file",
334            DELETE_TOPIC,
335            BAD_TOPIC,
336            &conf,
337        );
338        test_result_ok(result);
339    }
340
341    #[test]
342    fn test_reject_bidi_delete_file_topic() {
343        let check = RejectBiDi::default();
344        let result = run_topic_check("test_reject_bidi_delete_file_topic", DELETE_TOPIC, check);
345        test_result_ok(result);
346    }
347
348    #[test]
349    fn test_reject_bidi_topic_fixed() {
350        let check = RejectBiDi::default();
351        run_topic_check_ok("test_reject_bidi_topic_fixed", FIX_TOPIC, check);
352    }
353}