git-checks 4.0.1

Checks to run against a topic in git to enforce coding standards.
Documentation
// Copyright Kitware, Inc.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use crates::git_checks_core::impl_prelude::*;
use crates::thiserror::Error;

#[derive(Debug, Error)]
enum CheckSizeError {
    #[error("failed to get the size of the {} blob: {}", blob, output)]
    CatFile { blob: CommitId, output: String },
}

impl CheckSizeError {
    fn cat_file(blob: CommitId, output: &[u8]) -> Self {
        CheckSizeError::CatFile {
            blob,
            output: String::from_utf8_lossy(output).into(),
        }
    }
}

/// Checks that files committed to the tree do not exceed a specified size.
///
/// The check can be configured using the `hooks-max-size` attribute to change the maximum size
/// allowed for specific files.
#[derive(Builder, Debug, Clone, Copy)]
#[builder(field(private))]
pub struct CheckSize {
    /// The maximum size of blobs allowed in the repository.
    ///
    /// Configuration: Optional
    /// Default: 2^20 bytes (1 MiB)
    #[builder(default = "1 << 20")]
    max_size: usize,
}

impl CheckSize {
    /// Create a new builder.
    pub fn builder() -> CheckSizeBuilder {
        CheckSizeBuilder::default()
    }
}

impl Default for CheckSize {
    fn default() -> Self {
        CheckSize {
            max_size: 1 << 20,
        }
    }
}

impl ContentCheck for CheckSize {
    fn name(&self) -> &str {
        "check-size"
    }

    fn check(
        &self,
        ctx: &CheckGitContext,
        content: &dyn Content,
    ) -> Result<CheckResult, Box<dyn Error>> {
        let mut result = CheckResult::new();

        for diff in content.diffs() {
            if let StatusChange::Deleted = diff.status {
                continue;
            }

            // Ignore submodules.
            if diff.new_mode == "160000" {
                continue;
            }

            let size_attr = ctx.check_attr("hooks-max-size", diff.name.as_path())?;

            let prefix = commit_prefix(content);

            let max_size = match size_attr {
                // Explicity unset means "unlimited".
                AttributeState::Unset => continue,
                AttributeState::Value(ref v) => {
                    v.parse().unwrap_or_else(|_| {
                        result.add_error(format!(
                            "{}has an invalid value hooks-max-size={} for `{}`. The value must be \
                             an unsigned integer.",
                            prefix, v, diff.name,
                        ));
                        self.max_size
                    })
                },
                _ => self.max_size,
            };

            let cat_file = ctx
                .git()
                .arg("cat-file")
                .arg("-s")
                .arg(diff.new_blob.as_str())
                .output()
                .map_err(|err| GitError::subcommand("cat-file -s", err))?;
            if !cat_file.status.success() {
                return Err(
                    CheckSizeError::cat_file(diff.new_blob.clone(), &cat_file.stderr).into(),
                );
            }
            let new_size: usize = String::from_utf8_lossy(&cat_file.stdout)
                .trim()
                .parse()
                .unwrap_or_else(|msg| {
                    result.add_error(format!(
                        "{}has the file `{}` which has a size which did not parse: {}",
                        prefix, diff.name, msg,
                    ));
                    // We failed to parse the size from git, so don't bother checking its size. The
                    // attribute needs fixed first.
                    0
                });

            if new_size > max_size {
                result.add_error(format!(
                    "{}creates blob {} at `{}` with size {} bytes ({:.2} KiB) which is greater \
                     than the maximum size {} bytes ({:.2} KiB). If the file is intended to be \
                     committed, set the `hooks-max-size` attribute on its path.",
                    prefix,
                    diff.new_blob,
                    diff.name,
                    new_size,
                    new_size as f64 / 1024.,
                    max_size,
                    max_size as f64 / 1024.,
                ));
            }
        }

        Ok(result)
    }
}

#[cfg(feature = "config")]
pub(crate) mod config {
    use crates::git_checks_config::{CommitCheckConfig, IntoCheck, TopicCheckConfig};
    use crates::inventory;
    #[cfg(test)]
    use crates::serde_json;

    use CheckSize;

    /// Configuration for the `CheckSize` check.
    ///
    /// The `max_size` key is a non-negative integer for the default maximum size if an attribute
    /// does not specify a different size. Defaults to 1048576 (2²⁰) bytes or 1 megabyte.
    ///
    /// This check is registered as a commit check with the name `"check_size"` and a topic check
    /// with the name `"check_size/topic"`.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///     "max_size": 1048576
    /// }
    /// ```
    #[derive(Deserialize, Debug)]
    pub struct CheckSizeConfig {
        #[serde(default)]
        max_size: Option<usize>,
    }

    impl IntoCheck for CheckSizeConfig {
        type Check = CheckSize;

        fn into_check(self) -> Self::Check {
            let mut builder = CheckSize::builder();

            if let Some(max_size) = self.max_size {
                builder.max_size(max_size);
            }

            builder
                .build()
                .expect("configuration mismatch for `CheckSize`")
        }
    }

    register_checks! {
        CheckSizeConfig {
            "check_size" => CommitCheckConfig,
            "check_size/topic" => TopicCheckConfig,
        },
    }

    #[test]
    fn test_check_size_config_empty() {
        let json = json!({});
        let check: CheckSizeConfig = serde_json::from_value(json).unwrap();

        assert_eq!(check.max_size, None);
    }

    #[test]
    fn test_check_size_config_all_fields() {
        let json = json!({
            "max_size": 1000,
        });
        let check: CheckSizeConfig = serde_json::from_value(json).unwrap();

        assert_eq!(check.max_size, Some(1000));
    }
}

#[cfg(test)]
mod tests {
    use test::*;
    use CheckSize;

    const CHECK_SIZE_COMMIT: &str = "1464c62cc09b01a8e86a8512dd400b705c760c42";
    const ADD_SUBMODULE_TOPIC: &str = "fe90ee22ae3ce4b4dc41f8d0876e59355ff1e21c";
    const FIX_TOPIC: &str = "cb03f0d95897e93dcb089790f9cafd1ee7987922";

    #[test]
    fn test_check_size_builder_default() {
        assert!(CheckSize::builder().build().is_ok());
    }

    #[test]
    fn test_check_size() {
        let check = CheckSize::builder().max_size(46).build().unwrap();
        let result = run_check("test_check_size", CHECK_SIZE_COMMIT, check);
        test_result_errors(result, &[
            "commit a61fd3759b61a4a1f740f3fe656bc42151cefbdd creates blob \
             293071f2f4dd15bb57904e08bf6529e748e4075a at `increased-limit` with size 273 bytes \
             (0.27 KiB) which is greater than the maximum size 200 bytes (0.20 KiB). If the file \
             is intended to be committed, set the `hooks-max-size` attribute on its path.",
            "commit a61fd3759b61a4a1f740f3fe656bc42151cefbdd creates blob \
             4fa03f0211ccd20b0285314d9469ccbee1edd81c at `large-file` with size 48 bytes (0.05 \
             KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If the file is \
             intended to be committed, set the `hooks-max-size` attribute on its path.",
            "commit 112e9b34401724bff57f68cf47c5065d4342b263 has an invalid value \
             hooks-max-size=not-a-number for `bad-attr-value`. The value must be an unsigned \
             integer.",
            "commit 1464c62cc09b01a8e86a8512dd400b705c760c42 creates blob \
             921aae7a6949c74bc4bd53b4122fcd7ee3c819c6 at `no-value` with size 50 bytes (0.05 KiB) \
             which is greater than the maximum size 46 bytes (0.04 KiB). If the file is intended \
             to be committed, set the `hooks-max-size` attribute on its path.",
        ]);
    }

    #[test]
    fn test_check_size_topic() {
        let check = CheckSize::builder().max_size(46).build().unwrap();
        let result = run_topic_check("test_check_size_topic", CHECK_SIZE_COMMIT, check);
        test_result_errors(result, &[
            "has an invalid value hooks-max-size=not-a-number for `bad-attr-value`. The value \
             must be an unsigned integer.",
            "creates blob 293071f2f4dd15bb57904e08bf6529e748e4075a at `increased-limit` with size \
             273 bytes (0.27 KiB) which is greater than the maximum size 200 bytes (0.20 KiB). If \
             the file is intended to be committed, set the `hooks-max-size` attribute on its \
             path.",
            "creates blob 4fa03f0211ccd20b0285314d9469ccbee1edd81c at `large-file` with size 48 \
             bytes (0.05 KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If the \
             file is intended to be committed, set the `hooks-max-size` attribute on its path.",
            "creates blob 921aae7a6949c74bc4bd53b4122fcd7ee3c819c6 at `no-value` with size \
             50 bytes (0.05 KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If \
             the file is intended to be committed, set the `hooks-max-size` attribute on its \
             path.",
        ]);
    }

    #[test]
    fn test_check_size_submodule() {
        let check = CheckSize::builder().max_size(1024).build().unwrap();
        run_check_ok("test_check_size_submodule", ADD_SUBMODULE_TOPIC, check);
    }

    #[test]
    fn test_check_size_topic_fixed() {
        let check = CheckSize::builder().max_size(46).build().unwrap();
        run_topic_check_ok("test_check_size_topic_fixed", FIX_TOPIC, check);
    }
}