git_checks/
check_executable_permissions.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 crate::binary_format;
13
14/// Checks whether a file's executable permissions matches its contents.
15///
16/// Files which look executable but are not marked as such or vice versa are rejected.
17#[derive(Builder, Debug, Default, Clone)]
18#[builder(field(private))]
19pub struct CheckExecutablePermissions {
20    #[builder(private)]
21    #[builder(setter(name = "_extensions"))]
22    #[builder(default)]
23    extensions: Vec<String>,
24}
25
26impl CheckExecutablePermissionsBuilder {
27    /// Extensions considered to indicate an executable file.
28    ///
29    /// Really only intended for Windows where executable permissions do not exist.
30    ///
31    /// Configuration: Optional
32    /// Default: `Vec::new()`
33    pub fn extensions<I>(&mut self, extensions: I) -> &mut Self
34    where
35        I: IntoIterator,
36        I::Item: Into<String>,
37    {
38        self.extensions = Some(extensions.into_iter().map(Into::into).collect());
39        self
40    }
41}
42
43impl CheckExecutablePermissions {
44    /// Create a new builder.
45    pub fn builder() -> CheckExecutablePermissionsBuilder {
46        Default::default()
47    }
48}
49
50impl ContentCheck for CheckExecutablePermissions {
51    fn name(&self) -> &str {
52        "check-executable-permissions"
53    }
54
55    fn check(
56        &self,
57        ctx: &CheckGitContext,
58        content: &dyn Content,
59    ) -> Result<CheckResult, Box<dyn Error>> {
60        let mut result = CheckResult::new();
61
62        for diff in content.diffs() {
63            match diff.status {
64                StatusChange::Added | StatusChange::Modified(_) => (),
65                _ => continue,
66            }
67
68            // Ignore files which haven't changed their modes.
69            if diff.old_mode == diff.new_mode {
70                continue;
71            }
72
73            let is_executable = match diff.new_mode.as_str() {
74                "100755" => true,
75                "100644" => false,
76                _ => continue,
77            };
78
79            // LFS pointer files are defined as per its spec to have the same permission bit as the
80            // content it points to. Since this check can only access the pointer content, it is
81            // likely to be wrong here. Just ignore files specified to use the `lfs` filter.
82            let filter_attr = ctx.check_attr("filter", diff.name.as_path())?;
83            if let AttributeState::Value(filter_name) = filter_attr {
84                if filter_name == "lfs" {
85                    continue;
86                }
87            }
88
89            let executable_ext = self
90                .extensions
91                .iter()
92                .any(|ext| diff.name.as_str().ends_with(ext));
93            let looks_executable = if executable_ext {
94                true
95            } else {
96                let cat_file = ctx
97                    .git()
98                    .arg("cat-file")
99                    .arg("blob")
100                    .arg(diff.new_blob.as_str())
101                    .output()
102                    .map_err(|err| GitError::subcommand("cat-file", err))?;
103                let content = &cat_file.stdout;
104                let shebang = content.starts_with(b"#!/") || content.starts_with(b"#! /");
105                if shebang {
106                    true
107                } else {
108                    binary_format::detect_binary_format(content)
109                        .is_some_and(|fmt| fmt.is_executable())
110                }
111            };
112
113            let err = match (is_executable, looks_executable) {
114                (true, false) => {
115                    Some("with executable permissions, but the file does not look executable")
116                },
117                (false, true) => {
118                    Some("without executable permissions, but the file looks executable")
119                },
120                _ => None,
121            };
122
123            if let Some(msg) = err {
124                result.add_error(format!(
125                    "{}adds `{}` {}.",
126                    commit_prefix(content),
127                    diff.name,
128                    msg,
129                ));
130            }
131        }
132
133        Ok(result)
134    }
135}
136
137#[cfg(feature = "config")]
138pub(crate) mod config {
139    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
140    use serde::Deserialize;
141    #[cfg(test)]
142    use serde_json::json;
143
144    use crate::CheckExecutablePermissions;
145
146    /// Configuration for the `CheckExecutablePermissions` check.
147    ///
148    /// The `extensions` key is a list of strings, defaulting to an empty list. These extensions
149    /// are used to detect executable files on Windows since other platforms can usually be
150    /// detected by the file contents.
151    ///
152    /// This check is registered as a commit check with the name `"check_executable_permissions"
153    /// and as a topic check with the name `"check_executable_permissions/topic"`.
154    ///
155    /// # Example
156    ///
157    /// ```json
158    /// {
159    ///     "extensions": [
160    ///         "bat",
161    ///         "exe",
162    ///         "cmd"
163    ///     ]
164    /// }
165    /// ```
166    #[derive(Deserialize, Debug)]
167    pub struct CheckExecutablePermissionsConfig {
168        #[serde(default)]
169        extensions: Option<Vec<String>>,
170    }
171
172    impl IntoCheck for CheckExecutablePermissionsConfig {
173        type Check = CheckExecutablePermissions;
174
175        fn into_check(self) -> Self::Check {
176            let mut builder = CheckExecutablePermissions::builder();
177
178            if let Some(extensions) = self.extensions {
179                builder.extensions(extensions);
180            }
181
182            builder
183                .build()
184                .expect("configuration mismatch for `CheckExecutablePermissions`")
185        }
186    }
187
188    register_checks! {
189        CheckExecutablePermissionsConfig {
190            "check_executable_permissions" => CommitCheckConfig,
191            "check_executable_permissions/topic" => TopicCheckConfig,
192        },
193    }
194
195    #[test]
196    fn test_check_executable_permissions_config_empty() {
197        let json = json!({});
198        let check: CheckExecutablePermissionsConfig = serde_json::from_value(json).unwrap();
199
200        assert_eq!(check.extensions, None);
201
202        let check = check.into_check();
203
204        itertools::assert_equal(&check.extensions, &[] as &[&str]);
205    }
206
207    #[test]
208    fn test_check_executable_permissions_config_all_fields() {
209        let exp_ext: String = "md".into();
210        let json = json!({
211            "extensions": [exp_ext],
212        });
213        let check: CheckExecutablePermissionsConfig = serde_json::from_value(json).unwrap();
214
215        itertools::assert_equal(&check.extensions, &Some([exp_ext.clone()]));
216
217        let check = check.into_check();
218
219        itertools::assert_equal(&check.extensions, &[exp_ext]);
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use git_checks_core::{Check, TopicCheck};
226
227    use crate::test::*;
228    use crate::CheckExecutablePermissions;
229
230    const BAD_TOPIC: &str = "6ad8d4932466efc57ecccd3c80def3737b5d7e9a";
231    const BINARY_TOPIC: &str = "f81d125d44faeb76d8d06de1394f35ab03d4ebf8";
232    const DELETE_TOPIC: &str = "8e007a6e84b7b2ecc2f613a653997c436c6671f4";
233    const DELETE_BINARY_TOPIC: &str = "02ba9984453024e1ca70fb1ab4c51ebd41801c47";
234    const FIX_TOPIC: &str = "bea46a67f75380f1c17c25c7f89ffa9f47b27c06";
235    const BINARY_FIX_TOPIC: &str = "3ddce524eb8aff0e6e0b7b6475d64347d0d6a57f";
236    const LFS_TOPIC: &str = "58b4868402bf3f2e6160af345052c812f4cbe36f";
237    const SYMLINK_COMMIT: &str = "00ffdf352196c16a453970de022a8b4343610ccf";
238
239    #[test]
240    fn test_check_executable_permissions_builder_default() {
241        assert!(CheckExecutablePermissions::builder().build().is_ok());
242    }
243
244    #[test]
245    fn test_check_executable_permissions_name_commit() {
246        let check = CheckExecutablePermissions::default();
247        assert_eq!(Check::name(&check), "check-executable-permissions");
248    }
249
250    #[test]
251    fn test_check_executable_permissions_name_topic() {
252        let check = CheckExecutablePermissions::default();
253        assert_eq!(TopicCheck::name(&check), "check-executable-permissions");
254    }
255
256    fn check_executable_permissions_check(ext: &str) -> CheckExecutablePermissions {
257        CheckExecutablePermissions::builder()
258            .extensions([ext].iter().cloned())
259            .build()
260            .unwrap()
261    }
262
263    #[test]
264    fn test_check_executable_permissions() {
265        let check = check_executable_permissions_check(".exe");
266        let result = run_check("test_check_executable_permissions", BAD_TOPIC, check);
267        test_result_errors(
268            result,
269            &[
270                "commit 6ad8d4932466efc57ecccd3c80def3737b5d7e9a adds `is-exec` with executable \
271                 permissions, but the file does not look executable.",
272                "commit 6ad8d4932466efc57ecccd3c80def3737b5d7e9a adds `not-exec-shebang` without \
273                 executable permissions, but the file looks executable.",
274                "commit 6ad8d4932466efc57ecccd3c80def3737b5d7e9a adds `not-exec.exe` without \
275                 executable permissions, but the file looks executable.",
276            ],
277        );
278    }
279
280    #[test]
281    fn test_check_executable_permissions_binary() {
282        let check = check_executable_permissions_check(".exe");
283        let result = run_check(
284            "test_check_executable_permissions_binary",
285            BINARY_TOPIC,
286            check,
287        );
288        test_result_errors(result, &[
289            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `elf-header` without executable \
290             permissions, but the file looks executable.",
291            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `macho-cigam-header` without \
292             executable permissions, but the file looks executable.",
293            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `macho-fat-cigam-header` \
294             without executable permissions, but the file looks executable.",
295            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `macho-fat-magic-header` \
296             without executable permissions, but the file looks executable.",
297            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `macho-magic-header` without \
298             executable permissions, but the file looks executable.",
299            "commit f3ea55a336feec4bc6c970695c5662fadea67054 adds `ar-header` with executable \
300             permissions, but the file does not look executable.",
301            "commit f81d125d44faeb76d8d06de1394f35ab03d4ebf8 adds `pe-le-header` without \
302             executable permissions, but the file looks executable.",
303        ]);
304    }
305
306    #[test]
307    fn test_check_executable_permissions_delete_files() {
308        let check = check_executable_permissions_check(".exe");
309        let conf = make_check_conf(&check);
310
311        let result = test_check_base(
312            "test_check_executable_permissions_delete_files",
313            DELETE_TOPIC,
314            BAD_TOPIC,
315            &conf,
316        );
317        test_result_ok(result);
318    }
319
320    #[test]
321    fn test_check_executable_permissions_binary_delete_files() {
322        let check = check_executable_permissions_check(".exe");
323        let conf = make_check_conf(&check);
324
325        let result = test_check_base(
326            "test_check_executable_permissions_binary_delete_files",
327            DELETE_BINARY_TOPIC,
328            BINARY_TOPIC,
329            &conf,
330        );
331        test_result_ok(result);
332    }
333
334    #[test]
335    fn test_check_executable_permissions_topic() {
336        let check = check_executable_permissions_check(".exe");
337        let result = run_topic_check("test_check_executable_permissions_topic", BAD_TOPIC, check);
338        test_result_errors(
339            result,
340            &[
341                "adds `is-exec` with executable permissions, but the file does not look \
342                 executable.",
343                "adds `not-exec-shebang` without executable permissions, but the file looks \
344                 executable.",
345                "adds `not-exec.exe` without executable permissions, but the file looks \
346                 executable.",
347            ],
348        );
349    }
350
351    #[test]
352    fn test_check_executable_permissions_topic_delete_files() {
353        let check = check_executable_permissions_check(".exe");
354        run_topic_check_ok(
355            "test_check_executable_permissions_topic_delete_files",
356            DELETE_TOPIC,
357            check,
358        );
359    }
360
361    #[test]
362    fn test_check_executable_permissions_topic_binary_delete_files() {
363        let check = check_executable_permissions_check(".exe");
364        run_topic_check_ok(
365            "test_check_executable_permissions_topic_binary_delete_files",
366            DELETE_BINARY_TOPIC,
367            check,
368        );
369    }
370
371    #[test]
372    fn test_check_executable_permissions_topic_fixed() {
373        let check = check_executable_permissions_check(".exe");
374        run_topic_check_ok(
375            "test_check_executable_permissions_topic_fixed",
376            FIX_TOPIC,
377            check,
378        );
379    }
380
381    #[test]
382    fn test_check_executable_permissions_topic_binary_fixed() {
383        let check = check_executable_permissions_check(".exe");
384        run_topic_check_ok(
385            "test_check_executable_permissions_topic_binary_fixed",
386            BINARY_FIX_TOPIC,
387            check,
388        );
389    }
390
391    #[test]
392    fn test_check_executable_permissions_lfs() {
393        let check = check_executable_permissions_check(".lfs");
394        let conf = make_check_conf(&check);
395        let result = test_check_base(
396            "test_check_executable_permissions_lfs",
397            LFS_TOPIC,
398            BAD_TOPIC,
399            &conf,
400        );
401        test_result_ok(result);
402    }
403
404    #[test]
405    fn test_check_executable_permissions_ignore_symlinks() {
406        let check = CheckExecutablePermissions::default();
407        run_check_ok(
408            "test_check_executable_permissions_ignore_symlinks",
409            SYMLINK_COMMIT,
410            check,
411        );
412    }
413
414    #[test]
415    fn test_check_executable_permissions_ignore_symlinks_topic() {
416        let check = CheckExecutablePermissions::default();
417        run_topic_check_ok(
418            "test_check_executable_permissions_ignore_symlinks_topic",
419            SYMLINK_COMMIT,
420            check,
421        );
422    }
423}