git_checks/
check_size.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::*;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
14enum CheckSizeError {
15    #[error("failed to get the size of the {} blob: {}", blob, output)]
16    CatFile { blob: CommitId, output: String },
17}
18
19impl CheckSizeError {
20    fn cat_file(blob: CommitId, output: &[u8]) -> Self {
21        CheckSizeError::CatFile {
22            blob,
23            output: String::from_utf8_lossy(output).into(),
24        }
25    }
26}
27
28/// Checks that files committed to the tree do not exceed a specified size.
29///
30/// The check can be configured using the `hooks-max-size` attribute to change the maximum size
31/// allowed for specific files.
32#[derive(Builder, Debug, Clone, Copy)]
33#[builder(field(private))]
34pub struct CheckSize {
35    /// The maximum size of blobs allowed in the repository.
36    ///
37    /// Configuration: Optional
38    /// Default: 2^20 bytes (1 MiB)
39    #[builder(default = "1 << 20")]
40    max_size: usize,
41}
42
43impl CheckSize {
44    /// Create a new builder.
45    pub fn builder() -> CheckSizeBuilder {
46        Default::default()
47    }
48}
49
50impl Default for CheckSize {
51    fn default() -> Self {
52        CheckSize {
53            max_size: 1 << 20,
54        }
55    }
56}
57
58impl ContentCheck for CheckSize {
59    fn name(&self) -> &str {
60        "check-size"
61    }
62
63    fn check(
64        &self,
65        ctx: &CheckGitContext,
66        content: &dyn Content,
67    ) -> Result<CheckResult, Box<dyn Error>> {
68        let mut result = CheckResult::new();
69
70        for diff in content.diffs() {
71            if let StatusChange::Deleted = diff.status {
72                continue;
73            }
74
75            // Ignore submodules.
76            if diff.new_mode == "160000" {
77                continue;
78            }
79
80            let size_attr = ctx.check_attr("hooks-max-size", diff.name.as_path())?;
81
82            let prefix = commit_prefix(content);
83
84            let max_size = match size_attr {
85                // Explicity unset means "unlimited".
86                AttributeState::Unset => continue,
87                AttributeState::Value(ref v) => {
88                    v.parse().unwrap_or_else(|_| {
89                        result.add_error(format!(
90                            "{}has an invalid value hooks-max-size={} for `{}`. The value must be \
91                             an unsigned integer.",
92                            prefix, v, diff.name,
93                        ));
94                        self.max_size
95                    })
96                },
97                _ => self.max_size,
98            };
99
100            let cat_file = ctx
101                .git()
102                .arg("cat-file")
103                .arg("-s")
104                .arg(diff.new_blob.as_str())
105                .output()
106                .map_err(|err| GitError::subcommand("cat-file -s", err))?;
107            if !cat_file.status.success() {
108                return Err(
109                    CheckSizeError::cat_file(diff.new_blob.clone(), &cat_file.stderr).into(),
110                );
111            }
112            let new_size: usize = String::from_utf8_lossy(&cat_file.stdout)
113                .trim()
114                .parse()
115                .unwrap_or_else(|msg| {
116                    result.add_error(format!(
117                        "{}has the file `{}` which has a size which did not parse: {}",
118                        prefix, diff.name, msg,
119                    ));
120                    // We failed to parse the size from git, so don't bother checking its size. The
121                    // attribute needs fixed first.
122                    0
123                });
124
125            if new_size > max_size {
126                result.add_error(format!(
127                    "{}creates blob {} at `{}` with size {} bytes ({:.2} KiB) which is greater \
128                     than the maximum size {} bytes ({:.2} KiB). If the file is intended to be \
129                     committed, set the `hooks-max-size` attribute on its path.",
130                    prefix,
131                    diff.new_blob,
132                    diff.name,
133                    new_size,
134                    new_size as f64 / 1024.,
135                    max_size,
136                    max_size as f64 / 1024.,
137                ));
138            }
139        }
140
141        Ok(result)
142    }
143}
144
145#[cfg(feature = "config")]
146pub(crate) mod config {
147    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
148    use serde::Deserialize;
149    #[cfg(test)]
150    use serde_json::json;
151
152    use crate::CheckSize;
153
154    /// Configuration for the `CheckSize` check.
155    ///
156    /// The `max_size` key is a non-negative integer for the default maximum size if an attribute
157    /// does not specify a different size. Defaults to 1048576 (2²⁰) bytes or 1 megabyte.
158    ///
159    /// This check is registered as a commit check with the name `"check_size"` and a topic check
160    /// with the name `"check_size/topic"`.
161    ///
162    /// # Example
163    ///
164    /// ```json
165    /// {
166    ///     "max_size": 1048576
167    /// }
168    /// ```
169    #[derive(Deserialize, Debug)]
170    pub struct CheckSizeConfig {
171        #[serde(default)]
172        max_size: Option<usize>,
173    }
174
175    impl IntoCheck for CheckSizeConfig {
176        type Check = CheckSize;
177
178        fn into_check(self) -> Self::Check {
179            let mut builder = CheckSize::builder();
180
181            if let Some(max_size) = self.max_size {
182                builder.max_size(max_size);
183            }
184
185            builder
186                .build()
187                .expect("configuration mismatch for `CheckSize`")
188        }
189    }
190
191    register_checks! {
192        CheckSizeConfig {
193            "check_size" => CommitCheckConfig,
194            "check_size/topic" => TopicCheckConfig,
195        },
196    }
197
198    #[test]
199    fn test_check_size_config_empty() {
200        let json = json!({});
201        let check: CheckSizeConfig = serde_json::from_value(json).unwrap();
202
203        assert_eq!(check.max_size, None);
204
205        let check = check.into_check();
206
207        assert_eq!(check.max_size, 1 << 20);
208    }
209
210    #[test]
211    fn test_check_size_config_all_fields() {
212        let json = json!({
213            "max_size": 1000,
214        });
215        let check: CheckSizeConfig = serde_json::from_value(json).unwrap();
216
217        assert_eq!(check.max_size, Some(1000));
218
219        let check = check.into_check();
220
221        assert_eq!(check.max_size, 1000);
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use git_checks_core::{Check, TopicCheck};
228
229    use crate::test::*;
230    use crate::CheckSize;
231
232    const CHECK_SIZE_COMMIT: &str = "1464c62cc09b01a8e86a8512dd400b705c760c42";
233    const ADD_SUBMODULE_TOPIC: &str = "fe90ee22ae3ce4b4dc41f8d0876e59355ff1e21c";
234    const DELETE_TOPIC: &str = "5237811676fc8026fd5b16d37cb6d8ea7d3a2e48";
235    const FIX_TOPIC: &str = "cb03f0d95897e93dcb089790f9cafd1ee7987922";
236
237    #[test]
238    fn test_check_size_builder_default() {
239        assert!(CheckSize::builder().build().is_ok());
240    }
241
242    #[test]
243    fn test_check_size_name_commit() {
244        let check = CheckSize::default();
245        assert_eq!(Check::name(&check), "check-size");
246    }
247
248    #[test]
249    fn test_check_size_name_topic() {
250        let check = CheckSize::default();
251        assert_eq!(TopicCheck::name(&check), "check-size");
252    }
253
254    #[test]
255    fn test_check_size() {
256        let check = CheckSize::builder().max_size(46).build().unwrap();
257        let result = run_check("test_check_size", CHECK_SIZE_COMMIT, check);
258        test_result_errors(result, &[
259            "commit a61fd3759b61a4a1f740f3fe656bc42151cefbdd creates blob \
260             293071f2f4dd15bb57904e08bf6529e748e4075a at `increased-limit` with size 273 bytes \
261             (0.27 KiB) which is greater than the maximum size 200 bytes (0.20 KiB). If the file \
262             is intended to be committed, set the `hooks-max-size` attribute on its path.",
263            "commit a61fd3759b61a4a1f740f3fe656bc42151cefbdd creates blob \
264             4fa03f0211ccd20b0285314d9469ccbee1edd81c at `large-file` with size 48 bytes (0.05 \
265             KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If the file is \
266             intended to be committed, set the `hooks-max-size` attribute on its path.",
267            "commit 112e9b34401724bff57f68cf47c5065d4342b263 has an invalid value \
268             hooks-max-size=not-a-number for `bad-attr-value`. The value must be an unsigned \
269             integer.",
270            "commit 1464c62cc09b01a8e86a8512dd400b705c760c42 creates blob \
271             921aae7a6949c74bc4bd53b4122fcd7ee3c819c6 at `no-value` with size 50 bytes (0.05 KiB) \
272             which is greater than the maximum size 46 bytes (0.04 KiB). If the file is intended \
273             to be committed, set the `hooks-max-size` attribute on its path.",
274        ]);
275    }
276
277    #[test]
278    fn test_check_size_topic() {
279        let check = CheckSize::builder().max_size(46).build().unwrap();
280        let result = run_topic_check("test_check_size_topic", CHECK_SIZE_COMMIT, check);
281        test_result_errors(result, &[
282            "has an invalid value hooks-max-size=not-a-number for `bad-attr-value`. The value \
283             must be an unsigned integer.",
284            "creates blob 293071f2f4dd15bb57904e08bf6529e748e4075a at `increased-limit` with size \
285             273 bytes (0.27 KiB) which is greater than the maximum size 200 bytes (0.20 KiB). If \
286             the file is intended to be committed, set the `hooks-max-size` attribute on its \
287             path.",
288            "creates blob 4fa03f0211ccd20b0285314d9469ccbee1edd81c at `large-file` with size 48 \
289             bytes (0.05 KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If the \
290             file is intended to be committed, set the `hooks-max-size` attribute on its path.",
291            "creates blob 921aae7a6949c74bc4bd53b4122fcd7ee3c819c6 at `no-value` with size \
292             50 bytes (0.05 KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If \
293             the file is intended to be committed, set the `hooks-max-size` attribute on its \
294             path.",
295        ]);
296    }
297
298    #[test]
299    fn test_check_size_submodule() {
300        let check = CheckSize::builder().max_size(1024).build().unwrap();
301        run_check_ok("test_check_size_submodule", ADD_SUBMODULE_TOPIC, check);
302    }
303
304    #[test]
305    fn test_check_size_delete_file() {
306        let check = CheckSize::builder().max_size(46).build().unwrap();
307        let conf = make_check_conf(&check);
308
309        let result = test_check_base(
310            "test_check_size_delete_file",
311            DELETE_TOPIC,
312            CHECK_SIZE_COMMIT,
313            &conf,
314        );
315        test_result_ok(result);
316    }
317
318    #[test]
319    fn test_check_size_topic_delete_file() {
320        let check = CheckSize::builder().max_size(46).build().unwrap();
321        run_topic_check_ok("test_check_size_topic_delete_file", DELETE_TOPIC, check);
322    }
323
324    #[test]
325    fn test_check_size_topic_fixed() {
326        let check = CheckSize::builder().max_size(46).build().unwrap();
327        run_topic_check_ok("test_check_size_topic_fixed", FIX_TOPIC, check);
328    }
329}