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 "{prefix}has an invalid value hooks-max-size={v} for `{}`. The value \
91 must be an unsigned integer.",
92 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 "{prefix}has the file `{}` which has a size which did not parse: {msg}",
118 diff.name,
119 ));
120 0
123 });
124
125 if new_size > max_size {
126 result.add_error(format!(
127 "{prefix}creates blob {} at `{}` with size {new_size} bytes ({:.2} KiB) which \
128 is greater than the maximum size {max_size} bytes ({:.2} KiB). If the file \
129 is intended to be committed, set the `hooks-max-size` attribute on its path.",
130 diff.new_blob,
131 diff.name,
132 new_size as f64 / 1024.,
133 max_size as f64 / 1024.,
134 ));
135 }
136 }
137
138 Ok(result)
139 }
140}
141
142#[cfg(feature = "config")]
143pub(crate) mod config {
144 use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
145 use serde::Deserialize;
146 #[cfg(test)]
147 use serde_json::json;
148
149 use crate::CheckSize;
150
151 #[derive(Deserialize, Debug)]
167 pub struct CheckSizeConfig {
168 #[serde(default)]
169 max_size: Option<usize>,
170 }
171
172 impl IntoCheck for CheckSizeConfig {
173 type Check = CheckSize;
174
175 fn into_check(self) -> Self::Check {
176 let mut builder = CheckSize::builder();
177
178 if let Some(max_size) = self.max_size {
179 builder.max_size(max_size);
180 }
181
182 builder
183 .build()
184 .expect("configuration mismatch for `CheckSize`")
185 }
186 }
187
188 register_checks! {
189 CheckSizeConfig {
190 "check_size" => CommitCheckConfig,
191 "check_size/topic" => TopicCheckConfig,
192 },
193 }
194
195 #[test]
196 fn test_check_size_config_empty() {
197 let json = json!({});
198 let check: CheckSizeConfig = serde_json::from_value(json).unwrap();
199
200 assert_eq!(check.max_size, None);
201
202 let check = check.into_check();
203
204 assert_eq!(check.max_size, 1 << 20);
205 }
206
207 #[test]
208 fn test_check_size_config_all_fields() {
209 let json = json!({
210 "max_size": 1000,
211 });
212 let check: CheckSizeConfig = serde_json::from_value(json).unwrap();
213
214 assert_eq!(check.max_size, Some(1000));
215
216 let check = check.into_check();
217
218 assert_eq!(check.max_size, 1000);
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use git_checks_core::{Check, TopicCheck};
225
226 use crate::test::*;
227 use crate::CheckSize;
228
229 const CHECK_SIZE_COMMIT: &str = "1464c62cc09b01a8e86a8512dd400b705c760c42";
230 const ADD_SUBMODULE_TOPIC: &str = "fe90ee22ae3ce4b4dc41f8d0876e59355ff1e21c";
231 const DELETE_TOPIC: &str = "5237811676fc8026fd5b16d37cb6d8ea7d3a2e48";
232 const FIX_TOPIC: &str = "cb03f0d95897e93dcb089790f9cafd1ee7987922";
233
234 #[test]
235 fn test_check_size_builder_default() {
236 assert!(CheckSize::builder().build().is_ok());
237 }
238
239 #[test]
240 fn test_check_size_name_commit() {
241 let check = CheckSize::default();
242 assert_eq!(Check::name(&check), "check-size");
243 }
244
245 #[test]
246 fn test_check_size_name_topic() {
247 let check = CheckSize::default();
248 assert_eq!(TopicCheck::name(&check), "check-size");
249 }
250
251 #[test]
252 fn test_check_size() {
253 let check = CheckSize::builder().max_size(46).build().unwrap();
254 let result = run_check("test_check_size", CHECK_SIZE_COMMIT, check);
255 test_result_errors(result, &[
256 "commit a61fd3759b61a4a1f740f3fe656bc42151cefbdd creates blob \
257 293071f2f4dd15bb57904e08bf6529e748e4075a at `increased-limit` with size 273 bytes \
258 (0.27 KiB) which is greater than the maximum size 200 bytes (0.20 KiB). If the file \
259 is intended to be committed, set the `hooks-max-size` attribute on its path.",
260 "commit a61fd3759b61a4a1f740f3fe656bc42151cefbdd creates blob \
261 4fa03f0211ccd20b0285314d9469ccbee1edd81c at `large-file` with size 48 bytes (0.05 \
262 KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If the file is \
263 intended to be committed, set the `hooks-max-size` attribute on its path.",
264 "commit 112e9b34401724bff57f68cf47c5065d4342b263 has an invalid value \
265 hooks-max-size=not-a-number for `bad-attr-value`. The value must be an unsigned \
266 integer.",
267 "commit 1464c62cc09b01a8e86a8512dd400b705c760c42 creates blob \
268 921aae7a6949c74bc4bd53b4122fcd7ee3c819c6 at `no-value` with size 50 bytes (0.05 KiB) \
269 which is greater than the maximum size 46 bytes (0.04 KiB). If the file is intended \
270 to be committed, set the `hooks-max-size` attribute on its path.",
271 ]);
272 }
273
274 #[test]
275 fn test_check_size_topic() {
276 let check = CheckSize::builder().max_size(46).build().unwrap();
277 let result = run_topic_check("test_check_size_topic", CHECK_SIZE_COMMIT, check);
278 test_result_errors(result, &[
279 "has an invalid value hooks-max-size=not-a-number for `bad-attr-value`. The value \
280 must be an unsigned integer.",
281 "creates blob 293071f2f4dd15bb57904e08bf6529e748e4075a at `increased-limit` with size \
282 273 bytes (0.27 KiB) which is greater than the maximum size 200 bytes (0.20 KiB). If \
283 the file is intended to be committed, set the `hooks-max-size` attribute on its \
284 path.",
285 "creates blob 4fa03f0211ccd20b0285314d9469ccbee1edd81c at `large-file` with size 48 \
286 bytes (0.05 KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If the \
287 file is intended to be committed, set the `hooks-max-size` attribute on its path.",
288 "creates blob 921aae7a6949c74bc4bd53b4122fcd7ee3c819c6 at `no-value` with size \
289 50 bytes (0.05 KiB) which is greater than the maximum size 46 bytes (0.04 KiB). If \
290 the file is intended to be committed, set the `hooks-max-size` attribute on its \
291 path.",
292 ]);
293 }
294
295 #[test]
296 fn test_check_size_submodule() {
297 let check = CheckSize::builder().max_size(1024).build().unwrap();
298 run_check_ok("test_check_size_submodule", ADD_SUBMODULE_TOPIC, check);
299 }
300
301 #[test]
302 fn test_check_size_delete_file() {
303 let check = CheckSize::builder().max_size(46).build().unwrap();
304 let conf = make_check_conf(&check);
305
306 let result = test_check_base(
307 "test_check_size_delete_file",
308 DELETE_TOPIC,
309 CHECK_SIZE_COMMIT,
310 &conf,
311 );
312 test_result_ok(result);
313 }
314
315 #[test]
316 fn test_check_size_topic_delete_file() {
317 let check = CheckSize::builder().max_size(46).build().unwrap();
318 run_topic_check_ok("test_check_size_topic_delete_file", DELETE_TOPIC, check);
319 }
320
321 #[test]
322 fn test_check_size_topic_fixed() {
323 let check = CheckSize::builder().max_size(46).build().unwrap();
324 run_topic_check_ok("test_check_size_topic_fixed", FIX_TOPIC, check);
325 }
326}