1use std::collections::HashSet;
10use std::io::Write;
11use std::path::Path;
12use std::path::PathBuf;
13
14use crate::config::{ConfigFile, ConfigScope};
15use crate::dotfile::{
16 is_hfs_dot_gitattributes, is_hfs_dot_gitignore, is_hfs_dot_gitmodules, is_hfs_dot_mailmap,
17 is_ntfs_dot_gitattributes, is_ntfs_dot_gitignore, is_ntfs_dot_gitmodules, is_ntfs_dot_mailmap,
18};
19use crate::error::Result;
20use crate::objects::{parse_commit, parse_tree, ObjectId, ObjectKind, TreeEntry};
21use crate::odb::store::PackedObjects;
22use crate::odb::Odb;
23use url::{Host, Url};
24
25#[must_use]
27pub fn looks_like_command_line_option(s: &str) -> bool {
28 !s.is_empty() && s.as_bytes().first() == Some(&b'-')
29}
30
31#[must_use]
33pub fn tree_entry_is_gitmodules_blob(mode: u32, name: &[u8]) -> bool {
34 if mode == 0o120000 {
35 return false;
36 }
37 let Ok(name_str) = std::str::from_utf8(name) else {
38 return false;
39 };
40 is_hfs_dot_gitmodules(name_str) || is_ntfs_dot_gitmodules(name_str)
41}
42
43#[must_use]
45pub fn tree_entry_is_gitattributes_blob(mode: u32, name: &[u8]) -> bool {
46 if mode == 0o120000 {
47 return false;
48 }
49 let Ok(name_str) = std::str::from_utf8(name) else {
50 return false;
51 };
52 is_hfs_dot_gitattributes(name_str) || is_ntfs_dot_gitattributes(name_str)
53}
54
55fn is_hfs_or_ntfs_dot_gitmodules(name: &str) -> bool {
56 is_hfs_dot_gitmodules(name) || is_ntfs_dot_gitmodules(name)
57}
58
59fn is_hfs_or_ntfs_dot_gitattributes(name: &str) -> bool {
60 is_hfs_dot_gitattributes(name) || is_ntfs_dot_gitattributes(name)
61}
62
63#[must_use]
65fn tree_entry_name_is_dot_gitmodules(name: &str) -> bool {
66 if is_hfs_or_ntfs_dot_gitmodules(name) {
67 return true;
68 }
69 ntfs_backslash_suffix_matches(name, is_ntfs_dot_gitmodules)
70}
71
72fn ntfs_backslash_suffix_matches(name: &str, mut pred: impl FnMut(&str) -> bool) -> bool {
74 let mut rest = name;
75 while let Some(idx) = rest.find('\\') {
76 let after = &rest[idx + 1..];
77 if pred(after) {
78 return true;
79 }
80 rest = after;
81 }
82 false
83}
84
85#[must_use]
90pub fn tree_entry_reserved_dot_symlink(mode: u32, name: &[u8]) -> Option<&'static str> {
91 if mode != 0o120000 {
92 return None;
93 }
94 let Ok(name_str) = std::str::from_utf8(name) else {
95 return None;
96 };
97 if tree_entry_name_is_dot_gitmodules(name_str) {
98 return Some(".gitmodules");
99 }
100 if is_hfs_or_ntfs_dot_gitattributes(name_str) {
101 return Some(".gitattributes");
102 }
103 if is_hfs_dot_gitignore(name_str) || is_ntfs_dot_gitignore(name_str) {
104 return Some(".gitignore");
105 }
106 if is_hfs_dot_mailmap(name_str) || is_ntfs_dot_mailmap(name_str) {
107 return Some(".mailmap");
108 }
109 None
110}
111
112pub fn fsck_dot_special_tree_pass(
114 tree_oid: &ObjectId,
115 data: &[u8],
116 gitmodules_out: &mut HashSet<ObjectId>,
117 gitattributes_out: &mut HashSet<ObjectId>,
118) -> Result<Vec<DotFsckIssue>> {
119 let entries = parse_tree(data)?;
120 let mut issues = Vec::new();
121 for TreeEntry { mode, name, oid } in entries {
122 let Ok(name_str) = std::str::from_utf8(&name) else {
123 continue;
124 };
125 let is_symlink = mode == 0o120000;
126
127 if tree_entry_name_is_dot_gitmodules(name_str) {
128 if is_symlink {
129 issues.push(DotFsckIssue::TreeSymlink {
130 tree_oid: *tree_oid,
131 id: "gitmodulesSymlink",
132 detail: ".gitmodules is a symbolic link",
133 });
134 } else {
135 gitmodules_out.insert(oid);
136 }
137 }
138
139 if is_hfs_or_ntfs_dot_gitattributes(name_str) {
140 if is_symlink {
141 issues.push(DotFsckIssue::TreeSymlink {
142 tree_oid: *tree_oid,
143 id: "gitattributesSymlink",
144 detail: ".gitattributes is a symlink",
145 });
146 } else {
147 gitattributes_out.insert(oid);
148 }
149 }
150
151 if is_symlink {
152 if is_hfs_dot_gitignore(name_str) || is_ntfs_dot_gitignore(name_str) {
153 issues.push(DotFsckIssue::TreeSymlink {
154 tree_oid: *tree_oid,
155 id: "gitignoreSymlink",
156 detail: ".gitignore is a symlink",
157 });
158 }
159 if is_hfs_dot_mailmap(name_str) || is_ntfs_dot_mailmap(name_str) {
160 issues.push(DotFsckIssue::TreeSymlink {
161 tree_oid: *tree_oid,
162 id: "mailmapSymlink",
163 detail: ".mailmap is a symlink",
164 });
165 }
166 }
167 }
168 Ok(issues)
169}
170
171#[derive(Debug, Clone)]
173pub enum DotFsckIssue {
174 TreeSymlink {
176 tree_oid: ObjectId,
178 id: &'static str,
180 detail: &'static str,
182 },
183 NonBlobDotFile {
185 oid: ObjectId,
187 kind: ObjectKind,
189 id: &'static str,
191 detail: &'static str,
193 },
194 BlobGitmodules {
196 blob_oid: ObjectId,
198 id: &'static str,
200 detail: String,
202 },
203 BlobGitattributes {
205 blob_oid: ObjectId,
207 id: &'static str,
209 detail: &'static str,
211 },
212}
213
214impl DotFsckIssue {
215 #[must_use]
217 pub fn format_line(&self) -> String {
218 match self {
219 DotFsckIssue::TreeSymlink {
220 tree_oid,
221 id,
222 detail,
223 } => {
224 let prefix = if *id == "gitmodulesSymlink" {
225 "error"
226 } else {
227 "warning"
228 };
229 format!("{prefix} in tree {}: {}: {}", tree_oid.to_hex(), id, detail)
230 }
231 DotFsckIssue::NonBlobDotFile {
232 oid,
233 kind,
234 id,
235 detail,
236 } => format!(
237 "error in {} {}: {}: {}",
238 kind.as_str(),
239 oid.to_hex(),
240 id,
241 detail
242 ),
243 DotFsckIssue::BlobGitmodules {
244 blob_oid,
245 id,
246 detail,
247 } => {
248 let prefix = if *id == "gitmodulesParse" {
249 "warning"
250 } else {
251 "error"
252 };
253 format!("{prefix} in blob {}: {}: {}", blob_oid.to_hex(), id, detail)
254 }
255 DotFsckIssue::BlobGitattributes {
256 blob_oid,
257 id,
258 detail,
259 } => format!("error in blob {}: {}: {}", blob_oid.to_hex(), id, detail),
260 }
261 }
262
263 #[must_use]
265 pub fn is_error_severity(&self) -> bool {
266 !matches!(
267 self,
268 DotFsckIssue::BlobGitmodules {
269 id: "gitmodulesParse",
270 ..
271 } | DotFsckIssue::TreeSymlink {
272 id: "gitattributesSymlink" | "gitignoreSymlink" | "mailmapSymlink",
273 ..
274 }
275 )
276 }
277}
278
279fn gitmodules_blob_unparseable(data: &[u8]) -> bool {
281 for raw in data.split(|b| *b == b'\n') {
282 let line = trim_bytes(raw);
283 if line.is_empty() || line[0] == b'#' || line[0] == b';' {
284 continue;
285 }
286 if line.first() == Some(&b'[') && !line.contains(&b']') {
287 return true;
288 }
289 }
290 false
291}
292
293fn trim_bytes(mut s: &[u8]) -> &[u8] {
294 while let Some((&f, r)) = s.split_first() {
295 if f == b' ' || f == b'\t' {
296 s = r;
297 } else {
298 break;
299 }
300 }
301 while let Some((&l, r)) = s.split_last() {
302 if l == b' ' || l == b'\t' || l == b'\r' {
303 s = r;
304 } else {
305 break;
306 }
307 }
308 s
309}
310
311pub fn fsck_dot_special_object(
313 oid: &ObjectId,
314 kind: ObjectKind,
315 data: &[u8],
316 gitmodules_oids: &HashSet<ObjectId>,
317 gitattributes_oids: &HashSet<ObjectId>,
318) -> Vec<DotFsckIssue> {
319 let mut out = Vec::new();
320 if gitmodules_oids.contains(oid) {
321 if kind != ObjectKind::Blob {
322 out.push(DotFsckIssue::NonBlobDotFile {
323 oid: *oid,
324 kind,
325 id: "gitmodulesBlob",
326 detail: "non-blob found at .gitmodules",
327 });
328 return out;
329 }
330 if let Some(msg) = validate_gitmodules_blob_line(data) {
331 let (id, detail) = split_fsck_colon(&msg);
332 out.push(DotFsckIssue::BlobGitmodules {
333 blob_oid: *oid,
334 id,
335 detail: detail.to_string(),
336 });
337 } else {
338 let text = std::str::from_utf8(data).unwrap_or("");
339 let strict_bad =
340 ConfigFile::parse(Path::new(".gitmodules"), text, ConfigScope::Local).is_err();
341 if strict_bad || gitmodules_blob_unparseable(data) {
342 out.push(DotFsckIssue::BlobGitmodules {
343 blob_oid: *oid,
344 id: "gitmodulesParse",
345 detail: "could not parse gitmodules blob".to_string(),
346 });
347 }
348 }
349 }
350 if gitattributes_oids.contains(oid) {
351 if kind != ObjectKind::Blob {
352 out.push(DotFsckIssue::NonBlobDotFile {
353 oid: *oid,
354 kind,
355 id: "gitattributesBlob",
356 detail: "non-blob found at .gitattributes",
357 });
358 return out;
359 }
360 if data.len() > ATTR_MAX_FILE_SIZE {
361 out.push(DotFsckIssue::BlobGitattributes {
362 blob_oid: *oid,
363 id: "gitattributesLarge",
364 detail: ".gitattributes too large to parse",
365 });
366 } else {
367 let mut ptr = 0usize;
368 while ptr < data.len() {
369 let rest = &data[ptr..];
370 let line_end = rest.iter().position(|&b| b == b'\n').unwrap_or(rest.len());
371 if line_end >= ATTR_MAX_LINE_LENGTH {
372 out.push(DotFsckIssue::BlobGitattributes {
373 blob_oid: *oid,
374 id: "gitattributesLineLength",
375 detail: ".gitattributes has too long lines to parse",
376 });
377 break;
378 }
379 ptr += line_end;
380 if ptr < data.len() && data[ptr] == b'\n' {
381 ptr += 1;
382 }
383 }
384 }
385 }
386 out
387}
388
389#[derive(Debug, Default)]
391pub struct DotFsckTracker {
392 pub gitmodules_found: HashSet<ObjectId>,
394 pub gitmodules_done: HashSet<ObjectId>,
396 pub gitattributes_found: HashSet<ObjectId>,
398 pub gitattributes_done: HashSet<ObjectId>,
400}
401
402impl DotFsckTracker {
403 pub fn on_tree(&mut self, tree_oid: &ObjectId, data: &[u8]) -> Result<Vec<DotFsckIssue>> {
405 fsck_dot_special_tree_pass(
406 tree_oid,
407 data,
408 &mut self.gitmodules_found,
409 &mut self.gitattributes_found,
410 )
411 }
412
413 pub fn on_object(
415 &mut self,
416 oid: &ObjectId,
417 kind: ObjectKind,
418 data: &[u8],
419 ) -> Vec<DotFsckIssue> {
420 let need_gm = self.gitmodules_found.contains(oid) && !self.gitmodules_done.contains(oid);
421 let need_ga =
422 self.gitattributes_found.contains(oid) && !self.gitattributes_done.contains(oid);
423 if !need_gm && !need_ga {
424 return Vec::new();
425 }
426 if need_gm {
427 self.gitmodules_done.insert(*oid);
428 }
429 if need_ga {
430 self.gitattributes_done.insert(*oid);
431 }
432 fsck_dot_special_object(
433 oid,
434 kind,
435 data,
436 &self.gitmodules_found,
437 &self.gitattributes_found,
438 )
439 }
440
441 pub fn finish_pending(&mut self, odb: &Odb) -> Result<Vec<DotFsckIssue>> {
443 self.finish_pending_resolve(|oid| odb.read(oid).ok().map(|o| (o.kind, o.data)))
444 }
445
446 pub fn finish_pending_resolve<F>(&mut self, mut resolve: F) -> Result<Vec<DotFsckIssue>>
448 where
449 F: FnMut(&ObjectId) -> Option<(ObjectKind, Vec<u8>)>,
450 {
451 let mut out = Vec::new();
452 let pending_gm: Vec<ObjectId> = self
453 .gitmodules_found
454 .difference(&self.gitmodules_done)
455 .copied()
456 .collect();
457 let pending_ga: Vec<ObjectId> = self
458 .gitattributes_found
459 .difference(&self.gitattributes_done)
460 .copied()
461 .collect();
462
463 for oid in pending_gm {
464 self.gitmodules_done.insert(oid);
465 let Some((kind, data)) = resolve(&oid) else {
466 continue;
467 };
468 out.extend(fsck_dot_special_object(
469 &oid,
470 kind,
471 &data,
472 &self.gitmodules_found,
473 &self.gitattributes_found,
474 ));
475 }
476 for oid in pending_ga {
477 if self.gitattributes_done.contains(&oid) {
478 continue;
479 }
480 self.gitattributes_done.insert(oid);
481 let Some((kind, data)) = resolve(&oid) else {
482 continue;
483 };
484 out.extend(fsck_dot_special_object(
485 &oid,
486 kind,
487 &data,
488 &self.gitmodules_found,
489 &self.gitattributes_found,
490 ));
491 }
492 Ok(out)
493 }
494}
495
496fn split_fsck_colon(msg: &str) -> (&'static str, &str) {
497 let Some((a, b)) = msg.split_once(": ") else {
498 return ("gitmodules", msg);
499 };
500 match a {
501 "gitmodulesName" => ("gitmodulesName", b),
502 "gitmodulesUrl" => ("gitmodulesUrl", b),
503 "gitmodulesPath" => ("gitmodulesPath", b),
504 "gitmodulesUpdate" => ("gitmodulesUpdate", b),
505 _ => ("gitmodules", msg),
506 }
507}
508
509pub fn write_gitmodules_cli_option_warnings(
511 w: &mut dyn Write,
512 content: &str,
513) -> std::io::Result<()> {
514 if let Ok(config) = ConfigFile::parse(Path::new(".gitmodules"), content, ConfigScope::Local) {
515 let mut any = false;
516 for entry in &config.entries {
517 let key = &entry.key;
518 let Some(rest) = key.strip_prefix("submodule.") else {
519 continue;
520 };
521 let Some(last_dot) = rest.rfind('.') else {
522 continue;
523 };
524 let var = &rest[last_dot + 1..];
525 if var != "path" && var != "url" {
526 continue;
527 }
528 let Some(value) = entry.value.as_deref() else {
529 continue;
530 };
531 if looks_like_command_line_option(value) {
532 writeln!(
533 w,
534 "{}",
535 crate::diagnostics::warning_line(&format!(
536 "ignoring '{key}' which may be interpreted as a command-line option: {value}"
537 ))
538 )?;
539 any = true;
540 }
541 }
542 if any {
543 return Ok(());
544 }
545 }
546
547 let mut subsection: Option<&str> = None;
549 for line in content.lines() {
550 let trimmed = line.trim();
551 if trimmed.starts_with('[') {
552 subsection = None;
553 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
554 let inner = inner.trim();
555 if let Some(rest) = inner.strip_prefix("submodule") {
556 let rest = rest.trim();
557 let name = rest
558 .strip_prefix('"')
559 .and_then(|s| s.strip_suffix('"'))
560 .unwrap_or(rest);
561 if !name.is_empty() {
562 subsection = Some(name);
563 }
564 }
565 }
566 continue;
567 }
568 let Some((raw_key, raw_val)) = trimmed.split_once('=') else {
569 continue;
570 };
571 let key = raw_key.trim();
572 if key != "path" && key != "url" {
573 continue;
574 }
575 let mut val = raw_val.trim();
576 if val.len() >= 2 && val.starts_with('"') && val.ends_with('"') {
577 val = &val[1..val.len() - 1];
578 }
579 if looks_like_command_line_option(val) {
580 let key_full = match subsection {
581 Some(name) => format!("submodule.{name}.{key}"),
582 None => key.to_string(),
583 };
584 writeln!(
585 w,
586 "{}",
587 crate::diagnostics::warning_line(&format!(
588 "ignoring '{key_full}' which may be interpreted as a command-line option: {val}"
589 ))
590 )?;
591 }
592 }
593 Ok(())
594}
595
596#[must_use]
598pub fn check_submodule_name(name: &str) -> bool {
599 if name.is_empty() {
600 return false;
601 }
602 let b = name.as_bytes();
603 if b.len() >= 2
605 && b[0] == b'.'
606 && b[1] == b'.'
607 && (b.len() == 2 || b[2] == b'/' || b[2] == b'\\')
608 {
609 return false;
610 }
611 let mut i = 0usize;
612 while i < b.len() {
613 let c = b[i];
614 i += 1;
615 if c == b'/' || c == b'\\' {
616 let j = i;
617 if b.len() >= j + 2
618 && b[j] == b'.'
619 && b[j + 1] == b'.'
620 && (j + 2 >= b.len() || b[j + 2] == b'/' || b[j + 2] == b'\\')
621 {
622 return false;
623 }
624 }
625 }
626 true
627}
628
629fn is_xplatform_dir_sep(b: u8) -> bool {
630 b == b'/' || b == b'\\'
631}
632
633fn starts_with_dot_dot_slash(url: &str) -> bool {
634 let b = url.as_bytes();
635 b.len() >= 3 && b[0] == b'.' && b[1] == b'.' && is_xplatform_dir_sep(b[2])
636}
637
638fn starts_with_dot_slash(url: &str) -> bool {
639 let b = url.as_bytes();
640 b.len() >= 2 && b[0] == b'.' && is_xplatform_dir_sep(b[1])
641}
642
643fn submodule_url_is_relative(url: &str) -> bool {
644 starts_with_dot_slash(url) || starts_with_dot_dot_slash(url)
645}
646
647fn hex_val(b: u8) -> Option<u8> {
648 match b {
649 b'0'..=b'9' => Some(b - b'0'),
650 b'a'..=b'f' => Some(b - b'a' + 10),
651 b'A'..=b'F' => Some(b - b'A' + 10),
652 _ => None,
653 }
654}
655
656fn percent_decode_git_style(input: &str) -> Option<Vec<u8>> {
658 let b = input.as_bytes();
659 let mut out = Vec::with_capacity(b.len());
660 let mut i = 0usize;
661 while i < b.len() {
662 if b[i] == b'%' {
663 if i + 2 >= b.len() {
664 return None;
665 }
666 let hi = hex_val(b[i + 1])?;
667 let lo = hex_val(b[i + 2])?;
668 out.push((hi << 4) | lo);
669 i += 3;
670 } else {
671 out.push(b[i]);
672 i += 1;
673 }
674 }
675 Some(out)
676}
677
678fn count_leading_dotdots(url: &str) -> (usize, &str) {
680 let mut n = 0usize;
681 let mut s = url;
682 loop {
683 if starts_with_dot_dot_slash(s) {
684 n += 1;
685 s = &s[3..];
686 continue;
687 }
688 if starts_with_dot_slash(s) {
689 s = &s[2..];
690 continue;
691 }
692 break;
693 }
694 (n, s)
695}
696
697fn url_to_curl_transport_url(url: &str) -> Option<&str> {
698 url.strip_prefix("http::")
699 .or_else(|| url.strip_prefix("https::"))
700 .or_else(|| url.strip_prefix("ftp::"))
701 .or_else(|| url.strip_prefix("ftps::"))
702 .or_else(|| {
703 if url.starts_with("http://")
704 || url.starts_with("https://")
705 || url.starts_with("ftp://")
706 || url.starts_with("ftps://")
707 {
708 Some(url)
709 } else {
710 None
711 }
712 })
713}
714
715#[must_use]
717pub fn check_submodule_url(url: &str) -> bool {
718 if looks_like_command_line_option(url) {
719 return false;
720 }
721
722 if submodule_url_is_relative(url) || url.starts_with("git://") {
723 let Some(decoded) = percent_decode_git_style(url) else {
724 return false;
725 };
726 if decoded.contains(&b'\n') {
727 return false;
728 }
729 let (n, rest) = count_leading_dotdots(url);
730 if n > 0 {
731 let rb = rest.as_bytes();
732 if !rb.is_empty() && (rb[0] == b':' || rb[0] == b'/') {
733 return false;
734 }
735 }
736 return true;
737 }
738
739 if let Some(curl_url) = url_to_curl_transport_url(url) {
740 if (curl_url.starts_with("http://") || curl_url.starts_with("https://"))
741 && curl_url.contains(":///")
742 {
743 return false;
744 }
745 let Ok(parsed) = Url::parse(curl_url) else {
746 return false;
747 };
748 if !matches!(
749 parsed.scheme(),
750 "http" | "https" | "ftp" | "ftps" | "ws" | "wss"
751 ) {
752 return false;
753 }
754 if parsed.host_str().is_none() {
755 return false;
756 }
757 match parsed.host() {
758 Some(Host::Domain(d)) if d.contains(':') => return false,
759 None => return false,
760 _ => {}
761 }
762 if parsed.path().starts_with(':') {
763 return false;
764 }
765 let normalized = parsed.as_str();
766 let Some(decoded) = percent_decode_git_style(normalized) else {
767 return false;
768 };
769 !decoded.contains(&b'\n')
770 } else {
771 true
772 }
773}
774
775pub const ATTR_MAX_LINE_LENGTH: usize = 2048;
777
778pub const ATTR_MAX_FILE_SIZE: usize = 100 * 1024 * 1024;
780
781fn submodule_update_is_command(value: &str) -> bool {
783 !value.is_empty() && value.starts_with('!')
784}
785
786fn raw_gitmodules_submodule_names(content: &str) -> Vec<String> {
787 let mut out = Vec::new();
788 for line in content.lines() {
789 let trimmed = line.trim();
790 if !trimmed.starts_with('[') {
791 continue;
792 }
793 let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
794 continue;
795 };
796 let inner = inner.trim();
797 let Some(rest) = inner.strip_prefix("submodule") else {
798 continue;
799 };
800 let rest = rest.trim();
801 let name = rest
802 .strip_prefix('"')
803 .and_then(|s| s.strip_suffix('"'))
804 .unwrap_or(rest);
805 if !name.is_empty() {
806 out.push(name.to_string());
807 }
808 }
809 out
810}
811
812pub fn validate_gitmodules_blob_line(data: &[u8]) -> Option<String> {
814 let Ok(text) = std::str::from_utf8(data) else {
815 return None;
816 };
817
818 let mut worst: Option<String> = None;
819
820 if let Ok(config) = ConfigFile::parse(Path::new(".gitmodules"), text, ConfigScope::Local) {
821 for entry in &config.entries {
822 let key = &entry.key;
823 let Some(rest) = key.strip_prefix("submodule.") else {
824 continue;
825 };
826 let Some(last_dot) = rest.rfind('.') else {
827 continue;
828 };
829 let name = &rest[..last_dot];
830 let var = &rest[last_dot + 1..];
831
832 if !check_submodule_name(name) {
833 worst.get_or_insert_with(|| {
834 format!("gitmodulesName: disallowed submodule name: {name}")
835 });
836 }
837
838 let Some(value) = entry.value.as_deref() else {
839 continue;
840 };
841
842 match var {
843 "url" => {
844 if !check_submodule_url(value) {
845 worst.get_or_insert_with(|| {
846 format!("gitmodulesUrl: disallowed submodule url: {value}")
847 });
848 }
849 }
850 "path" => {
851 if looks_like_command_line_option(value) {
852 worst = Some(format!(
853 "gitmodulesPath: disallowed submodule path: {value}"
854 ));
855 }
856 }
857 "update" if submodule_update_is_command(value) => {
858 worst.get_or_insert_with(|| {
859 format!("gitmodulesUpdate: disallowed submodule update setting: {value}")
860 });
861 }
862 _ => {}
863 }
864 }
865 }
866
867 for name in raw_gitmodules_submodule_names(text) {
872 if !check_submodule_name(&name) {
873 worst.get_or_insert_with(|| {
874 format!("gitmodulesName: disallowed submodule name: {name}")
875 });
876 }
877 }
878
879 worst
880}
881
882fn collect_gitmodules_blobs_from_tree(
883 odb: &Odb,
884 tree_oid: ObjectId,
885 seen_trees: &mut HashSet<ObjectId>,
886) -> Result<HashSet<ObjectId>> {
887 let mut blobs = HashSet::new();
888 let mut stack = vec![tree_oid];
889 while let Some(tid) = stack.pop() {
890 if !seen_trees.insert(tid) {
891 continue;
892 }
893 let obj = odb.read(&tid)?;
894 if obj.kind != ObjectKind::Tree {
895 continue;
896 }
897 let entries = parse_tree(&obj.data)?;
898 for TreeEntry { mode, name, oid } in entries {
899 if tree_entry_is_gitmodules_blob(mode, &name) {
900 blobs.insert(oid);
901 } else if mode == 0o040000 {
902 stack.push(oid);
903 }
904 }
905 }
906 Ok(blobs)
907}
908
909pub fn verify_gitmodules_for_commit(odb: &Odb, commit_oid: ObjectId) -> Result<Option<String>> {
911 let obj = odb.read(&commit_oid)?;
912 if obj.kind != ObjectKind::Commit {
913 return Ok(None);
914 }
915 let commit = parse_commit(&obj.data)?;
916 let mut seen_trees = HashSet::new();
917 let blobs = collect_gitmodules_blobs_from_tree(odb, commit.tree, &mut seen_trees)?;
918 for oid in blobs {
919 let blob = odb.read(&oid)?;
920 if blob.kind != ObjectKind::Blob {
921 continue;
922 }
923 if let Some(msg) = validate_gitmodules_blob_line(&blob.data) {
924 return Ok(Some(format!("{}: {}", oid.to_hex(), msg)));
925 }
926 }
927 Ok(None)
928}
929
930pub fn oids_from_copied_object_paths(copied: &[PathBuf]) -> Result<HashSet<ObjectId>> {
932 let mut out = HashSet::new();
933 for p in copied {
934 let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
935 continue;
936 };
937 if name.ends_with(".idx") {
938 out.extend(PackedObjects::object_ids_from_index_path(p)?);
939 continue;
940 }
941 if let Some(oid) = object_id_from_loose_object_path(p) {
942 out.insert(oid);
943 }
944 }
945 Ok(out)
946}
947
948fn object_id_from_loose_object_path(path: &Path) -> Option<ObjectId> {
949 let file_name = path.file_name()?.to_str()?;
950 if file_name.len() != 38 {
951 return None;
952 }
953 let parent = path.parent()?.file_name()?.to_str()?;
954 if parent.len() != 2 {
955 return None;
956 }
957 let hex = format!("{parent}{file_name}");
958 ObjectId::from_hex(&hex).ok()
959}