1use 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#[derive(Builder, Debug, Clone, Copy)]
33#[builder(field(private))]
34pub struct CheckSize {
35 #[builder(default = "1 << 20")]
40 max_size: usize,
41}
42
43impl CheckSize {
44 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 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 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 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 #[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}