Skip to main content

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 `{}`: {err}.",
71                            commit_prefix(content),
72                            diff.name,
73                        ),
74                        true,
75                    );
76                    continue;
77                },
78            };
79
80            for line in patch.lines().filter(|line| line.starts_with('+')) {
81                let line_bidi_free: String = line
82                    .chars()
83                    .map(|c| {
84                        if UNICODE_BIDI_CHARS.contains(&c) {
85                            REPLACEMENT_CHARACTER
86                        } else {
87                            c
88                        }
89                    })
90                    .collect();
91                if line_bidi_free != line {
92                    let safe_line = line_bidi_free[1..]
93                        .replace('\\', "\\\\")
94                        .replace('`', "\\`");
95                    if self.allow {
96                        result.add_warning(format!(
97                            "{}Unicode bidirectional control character(s) added in `{}`: `{safe_line}`.",
98                            commit_prefix_str(content, "needs checked;"),
99                            diff.name,
100                        ));
101                    } else {
102                        result.add_error(format!(
103                            "{}Unicode bidirectional control character(s) added in `{}`: `{safe_line}`.",
104                            commit_prefix_str(content, "not allowed;"),
105                            diff.name,
106                        ));
107                    }
108                }
109            }
110        }
111
112        Ok(result)
113    }
114}
115
116#[cfg(feature = "config")]
117pub(crate) mod config {
118    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
119    use serde::Deserialize;
120    #[cfg(test)]
121    use serde_json::json;
122
123    use crate::RejectBiDi;
124
125    /// Configuration for the `RejectBiDi` check.
126    ///
127    /// The `allow` key is a boolean as to whether the check should raise an error rather than a
128    /// warning. Defaults to `false`.
129    ///
130    /// This check is registered as a commit check with the name `"reject_bidi"` and a topic check
131    /// with the name `"reject_bidi/topic"`.
132    ///
133    /// # Example
134    ///
135    /// ```json
136    /// {
137    ///     "allow": true
138    /// }
139    /// ```
140    #[derive(Deserialize, Debug)]
141    pub struct RejectBiDiConfig {
142        #[serde(default)]
143        allow: bool,
144    }
145
146    impl IntoCheck for RejectBiDiConfig {
147        type Check = RejectBiDi;
148
149        fn into_check(self) -> Self::Check {
150            let mut builder = RejectBiDi::builder();
151
152            builder.allow(self.allow);
153
154            builder
155                .build()
156                .expect("configuration mismatch for `RejectBiDi`")
157        }
158    }
159
160    register_checks! {
161        RejectBiDiConfig {
162            "reject_bidi" => CommitCheckConfig,
163            "reject_bidi/topic" => TopicCheckConfig,
164        },
165    }
166
167    #[test]
168    fn test_reject_bidi_config_empty() {
169        let json = json!({});
170        let check: RejectBiDiConfig = serde_json::from_value(json).unwrap();
171
172        assert!(!check.allow);
173
174        let check = check.into_check();
175
176        assert!(!check.allow);
177    }
178
179    #[test]
180    fn test_reject_bidi_config_all_fields() {
181        let json = json!({
182            "allow": true,
183        });
184        let check: RejectBiDiConfig = serde_json::from_value(json).unwrap();
185
186        assert!(check.allow);
187
188        let check = check.into_check();
189
190        assert!(check.allow);
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use git_checks_core::{Check, TopicCheck};
197
198    use crate::test::*;
199    use crate::RejectBiDi;
200
201    const BAD_TOPIC: &str = "678c1deeade619d52c5b0990bb05af79017f2787";
202    const DELETE_TOPIC: &str = "0a5b9308fcedec797d70ba78bb1b92a7b7943828";
203    const FIX_TOPIC: &str = "0a5b9308fcedec797d70ba78bb1b92a7b7943828";
204
205    #[test]
206    fn test_reject_bidi_builder_default() {
207        assert!(RejectBiDi::builder().build().is_ok());
208    }
209
210    #[test]
211    fn test_reject_bidi_name_commit() {
212        let check = RejectBiDi::default();
213        assert_eq!(Check::name(&check), "reject-bidi");
214    }
215
216    #[test]
217    fn test_reject_bidi_name_topic() {
218        let check = RejectBiDi::default();
219        assert_eq!(TopicCheck::name(&check), "reject-bidi");
220    }
221
222    #[test]
223    fn test_reject_bidi() {
224        let check = RejectBiDi::default();
225        let result = run_check("test_reject_bidi", BAD_TOPIC, check);
226        test_result_errors(result, &[
227            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
228             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
229            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
230             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
231            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
232             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
233            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
234             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
235            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
236             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
237            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
238             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
239            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
240             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
241            "commit 678c1deeade619d52c5b0990bb05af79017f2787 not allowed; Unicode bidirectional \
242             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
243        ]);
244    }
245
246    #[test]
247    fn test_reject_bidi_allow() {
248        let check = RejectBiDi::builder().allow(true).build().unwrap();
249        let result = run_check("test_reject_bidi_allow", BAD_TOPIC, check);
250        test_result_warnings(result, &[
251            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
252             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
253            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
254             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
255            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
256             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
257            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
258             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
259            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
260             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
261            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
262             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
263            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
264             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
265            "commit 678c1deeade619d52c5b0990bb05af79017f2787 needs checked; Unicode bidirectional \
266             control character(s) added in `has-bidi`: `bidi character: \u{fffd}`.",
267        ]);
268    }
269
270    #[test]
271    fn test_reject_bidi_topic() {
272        let check = RejectBiDi::default();
273        let result = run_topic_check("test_reject_bidi_topic", BAD_TOPIC, check);
274        test_result_errors(
275            result,
276            &[
277                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
278                 \u{fffd}`.",
279                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
280                 \u{fffd}`.",
281                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
282                 \u{fffd}`.",
283                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
284                 \u{fffd}`.",
285                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
286                 \u{fffd}`.",
287                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
288                 \u{fffd}`.",
289                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
290                 \u{fffd}`.",
291                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
292                 \u{fffd}`.",
293            ],
294        );
295    }
296
297    #[test]
298    fn test_reject_bidi_topic_allow() {
299        let check = RejectBiDi::builder().allow(true).build().unwrap();
300        let result = run_topic_check("test_reject_bidi_topic_allow", BAD_TOPIC, check);
301        test_result_warnings(
302            result,
303            &[
304                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
305                 \u{fffd}`.",
306                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
307                 \u{fffd}`.",
308                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
309                 \u{fffd}`.",
310                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
311                 \u{fffd}`.",
312                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
313                 \u{fffd}`.",
314                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
315                 \u{fffd}`.",
316                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
317                 \u{fffd}`.",
318                "Unicode bidirectional control character(s) added in `has-bidi`: `bidi character: \
319                 \u{fffd}`.",
320            ],
321        );
322    }
323
324    #[test]
325    fn test_reject_bidi_delete_file() {
326        let check = RejectBiDi::default();
327        let conf = make_check_conf(&check);
328
329        let result = test_check_base(
330            "test_reject_bidi_delete_file",
331            DELETE_TOPIC,
332            BAD_TOPIC,
333            &conf,
334        );
335        test_result_ok(result);
336    }
337
338    #[test]
339    fn test_reject_bidi_delete_file_topic() {
340        let check = RejectBiDi::default();
341        let result = run_topic_check("test_reject_bidi_delete_file_topic", DELETE_TOPIC, check);
342        test_result_ok(result);
343    }
344
345    #[test]
346    fn test_reject_bidi_topic_fixed() {
347        let check = RejectBiDi::default();
348        run_topic_check_ok("test_reject_bidi_topic_fixed", FIX_TOPIC, check);
349    }
350}