Skip to main content

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 `{}` {msg}.",
126                    commit_prefix(content),
127                    diff.name,
128                ));
129            }
130        }
131
132        Ok(result)
133    }
134}
135
136#[cfg(feature = "config")]
137pub(crate) mod config {
138    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
139    use serde::Deserialize;
140    #[cfg(test)]
141    use serde_json::json;
142
143    use crate::CheckExecutablePermissions;
144
145    /// Configuration for the `CheckExecutablePermissions` check.
146    ///
147    /// The `extensions` key is a list of strings, defaulting to an empty list. These extensions
148    /// are used to detect executable files on Windows since other platforms can usually be
149    /// detected by the file contents.
150    ///
151    /// This check is registered as a commit check with the name `"check_executable_permissions"
152    /// and as a topic check with the name `"check_executable_permissions/topic"`.
153    ///
154    /// # Example
155    ///
156    /// ```json
157    /// {
158    ///     "extensions": [
159    ///         "bat",
160    ///         "exe",
161    ///         "cmd"
162    ///     ]
163    /// }
164    /// ```
165    #[derive(Deserialize, Debug)]
166    pub struct CheckExecutablePermissionsConfig {
167        #[serde(default)]
168        extensions: Option<Vec<String>>,
169    }
170
171    impl IntoCheck for CheckExecutablePermissionsConfig {
172        type Check = CheckExecutablePermissions;
173
174        fn into_check(self) -> Self::Check {
175            let mut builder = CheckExecutablePermissions::builder();
176
177            if let Some(extensions) = self.extensions {
178                builder.extensions(extensions);
179            }
180
181            builder
182                .build()
183                .expect("configuration mismatch for `CheckExecutablePermissions`")
184        }
185    }
186
187    register_checks! {
188        CheckExecutablePermissionsConfig {
189            "check_executable_permissions" => CommitCheckConfig,
190            "check_executable_permissions/topic" => TopicCheckConfig,
191        },
192    }
193
194    #[test]
195    fn test_check_executable_permissions_config_empty() {
196        let json = json!({});
197        let check: CheckExecutablePermissionsConfig = serde_json::from_value(json).unwrap();
198
199        assert_eq!(check.extensions, None);
200
201        let check = check.into_check();
202
203        itertools::assert_equal(&check.extensions, &[] as &[&str]);
204    }
205
206    #[test]
207    fn test_check_executable_permissions_config_all_fields() {
208        let exp_ext: String = "md".into();
209        let json = json!({
210            "extensions": [exp_ext],
211        });
212        let check: CheckExecutablePermissionsConfig = serde_json::from_value(json).unwrap();
213
214        itertools::assert_equal(&check.extensions, &Some([exp_ext.clone()]));
215
216        let check = check.into_check();
217
218        itertools::assert_equal(&check.extensions, &[exp_ext]);
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use git_checks_core::{Check, TopicCheck};
225
226    use crate::test::*;
227    use crate::CheckExecutablePermissions;
228
229    const BAD_TOPIC: &str = "6ad8d4932466efc57ecccd3c80def3737b5d7e9a";
230    const BINARY_TOPIC: &str = "f81d125d44faeb76d8d06de1394f35ab03d4ebf8";
231    const DELETE_TOPIC: &str = "8e007a6e84b7b2ecc2f613a653997c436c6671f4";
232    const DELETE_BINARY_TOPIC: &str = "02ba9984453024e1ca70fb1ab4c51ebd41801c47";
233    const FIX_TOPIC: &str = "bea46a67f75380f1c17c25c7f89ffa9f47b27c06";
234    const BINARY_FIX_TOPIC: &str = "3ddce524eb8aff0e6e0b7b6475d64347d0d6a57f";
235    const LFS_TOPIC: &str = "58b4868402bf3f2e6160af345052c812f4cbe36f";
236    const SYMLINK_COMMIT: &str = "00ffdf352196c16a453970de022a8b4343610ccf";
237
238    #[test]
239    fn test_check_executable_permissions_builder_default() {
240        assert!(CheckExecutablePermissions::builder().build().is_ok());
241    }
242
243    #[test]
244    fn test_check_executable_permissions_name_commit() {
245        let check = CheckExecutablePermissions::default();
246        assert_eq!(Check::name(&check), "check-executable-permissions");
247    }
248
249    #[test]
250    fn test_check_executable_permissions_name_topic() {
251        let check = CheckExecutablePermissions::default();
252        assert_eq!(TopicCheck::name(&check), "check-executable-permissions");
253    }
254
255    fn check_executable_permissions_check(ext: &str) -> CheckExecutablePermissions {
256        CheckExecutablePermissions::builder()
257            .extensions([ext].iter().cloned())
258            .build()
259            .unwrap()
260    }
261
262    #[test]
263    fn test_check_executable_permissions() {
264        let check = check_executable_permissions_check(".exe");
265        let result = run_check("test_check_executable_permissions", BAD_TOPIC, check);
266        test_result_errors(
267            result,
268            &[
269                "commit 6ad8d4932466efc57ecccd3c80def3737b5d7e9a adds `is-exec` with executable \
270                 permissions, but the file does not look executable.",
271                "commit 6ad8d4932466efc57ecccd3c80def3737b5d7e9a adds `not-exec-shebang` without \
272                 executable permissions, but the file looks executable.",
273                "commit 6ad8d4932466efc57ecccd3c80def3737b5d7e9a adds `not-exec.exe` without \
274                 executable permissions, but the file looks executable.",
275            ],
276        );
277    }
278
279    #[test]
280    fn test_check_executable_permissions_binary() {
281        let check = check_executable_permissions_check(".exe");
282        let result = run_check(
283            "test_check_executable_permissions_binary",
284            BINARY_TOPIC,
285            check,
286        );
287        test_result_errors(result, &[
288            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `elf-header` without executable \
289             permissions, but the file looks executable.",
290            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `macho-cigam-header` without \
291             executable permissions, but the file looks executable.",
292            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `macho-fat-cigam-header` \
293             without executable permissions, but the file looks executable.",
294            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `macho-fat-magic-header` \
295             without executable permissions, but the file looks executable.",
296            "commit f5fd493ca51556d6cd0c42dfc8003925d77441f3 adds `macho-magic-header` without \
297             executable permissions, but the file looks executable.",
298            "commit f3ea55a336feec4bc6c970695c5662fadea67054 adds `ar-header` with executable \
299             permissions, but the file does not look executable.",
300            "commit f81d125d44faeb76d8d06de1394f35ab03d4ebf8 adds `pe-le-header` without \
301             executable permissions, but the file looks executable.",
302        ]);
303    }
304
305    #[test]
306    fn test_check_executable_permissions_delete_files() {
307        let check = check_executable_permissions_check(".exe");
308        let conf = make_check_conf(&check);
309
310        let result = test_check_base(
311            "test_check_executable_permissions_delete_files",
312            DELETE_TOPIC,
313            BAD_TOPIC,
314            &conf,
315        );
316        test_result_ok(result);
317    }
318
319    #[test]
320    fn test_check_executable_permissions_binary_delete_files() {
321        let check = check_executable_permissions_check(".exe");
322        let conf = make_check_conf(&check);
323
324        let result = test_check_base(
325            "test_check_executable_permissions_binary_delete_files",
326            DELETE_BINARY_TOPIC,
327            BINARY_TOPIC,
328            &conf,
329        );
330        test_result_ok(result);
331    }
332
333    #[test]
334    fn test_check_executable_permissions_topic() {
335        let check = check_executable_permissions_check(".exe");
336        let result = run_topic_check("test_check_executable_permissions_topic", BAD_TOPIC, check);
337        test_result_errors(
338            result,
339            &[
340                "adds `is-exec` with executable permissions, but the file does not look \
341                 executable.",
342                "adds `not-exec-shebang` without executable permissions, but the file looks \
343                 executable.",
344                "adds `not-exec.exe` without executable permissions, but the file looks \
345                 executable.",
346            ],
347        );
348    }
349
350    #[test]
351    fn test_check_executable_permissions_topic_delete_files() {
352        let check = check_executable_permissions_check(".exe");
353        run_topic_check_ok(
354            "test_check_executable_permissions_topic_delete_files",
355            DELETE_TOPIC,
356            check,
357        );
358    }
359
360    #[test]
361    fn test_check_executable_permissions_topic_binary_delete_files() {
362        let check = check_executable_permissions_check(".exe");
363        run_topic_check_ok(
364            "test_check_executable_permissions_topic_binary_delete_files",
365            DELETE_BINARY_TOPIC,
366            check,
367        );
368    }
369
370    #[test]
371    fn test_check_executable_permissions_topic_fixed() {
372        let check = check_executable_permissions_check(".exe");
373        run_topic_check_ok(
374            "test_check_executable_permissions_topic_fixed",
375            FIX_TOPIC,
376            check,
377        );
378    }
379
380    #[test]
381    fn test_check_executable_permissions_topic_binary_fixed() {
382        let check = check_executable_permissions_check(".exe");
383        run_topic_check_ok(
384            "test_check_executable_permissions_topic_binary_fixed",
385            BINARY_FIX_TOPIC,
386            check,
387        );
388    }
389
390    #[test]
391    fn test_check_executable_permissions_lfs() {
392        let check = check_executable_permissions_check(".lfs");
393        let conf = make_check_conf(&check);
394        let result = test_check_base(
395            "test_check_executable_permissions_lfs",
396            LFS_TOPIC,
397            BAD_TOPIC,
398            &conf,
399        );
400        test_result_ok(result);
401    }
402
403    #[test]
404    fn test_check_executable_permissions_ignore_symlinks() {
405        let check = CheckExecutablePermissions::default();
406        run_check_ok(
407            "test_check_executable_permissions_ignore_symlinks",
408            SYMLINK_COMMIT,
409            check,
410        );
411    }
412
413    #[test]
414    fn test_check_executable_permissions_ignore_symlinks_topic() {
415        let check = CheckExecutablePermissions::default();
416        run_topic_check_ok(
417            "test_check_executable_permissions_ignore_symlinks_topic",
418            SYMLINK_COMMIT,
419            check,
420        );
421    }
422}