Skip to main content

grit_lib/
gitmodules.rs

1//! `.gitmodules` validation (Git `fsck` / `submodule-config` parity).
2//!
3//! Submodule `path` and `url` values must not look like command-line options
4//! (non-empty and starting with `-`). See Git's `looks_like_command_line_option` in `path.c`.
5//!
6//! Submodule name and URL validation rules matching Git's (`check_submodule_name`,
7//! `check_submodule_url`).
8
9use 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/// Returns `true` when `s` is non-empty and starts with `-` (Git `looks_like_command_line_option`).
26#[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/// True when `name` names a `.gitmodules` file (HFS / NTFS spellings), not a symlink.
32#[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/// True for a tree entry name that should be treated as `.gitattributes` for fsck (blob only).
44#[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/// True when `name` is an HFS/NTFS `.gitmodules` spelling, including `foo\\.gitmodules`.
64#[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
72/// After each `\`, test the remainder with `pred` (Git `fsck_tree` NTFS segment rules).
73fn 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/// When `mode` is `120000`, the well-known metadata file name the entry must not symlink to.
86///
87/// Covers HFS and NTFS spellings of `.gitmodules`, `.gitattributes`, `.gitignore`, and
88/// `.mailmap`, matching [`fsck_dot_special_tree_pass`].
89#[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
112/// Symlink and registration for one tree (Git `fsck_tree` entry loop).
113pub 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/// Problems reported while walking trees / blobs for `.gitmodules` / `.gitattributes` fsck.
172#[derive(Debug, Clone)]
173pub enum DotFsckIssue {
174    /// Returned when tree symlink.
175    TreeSymlink {
176        /// Tree oid for this error or warning.
177        tree_oid: ObjectId,
178        /// Id for this error or warning.
179        id: &'static str,
180        /// Additional human-readable detail for the failure.
181        detail: &'static str,
182    },
183    /// Returned when non blob dot file.
184    NonBlobDotFile {
185        /// Object id (hex) related to the error.
186        oid: ObjectId,
187        /// Object type name when known (`commit`, `tree`, etc.).
188        kind: ObjectKind,
189        /// Id for this error or warning.
190        id: &'static str,
191        /// Additional human-readable detail for the failure.
192        detail: &'static str,
193    },
194    /// Returned when blob gitmodules.
195    BlobGitmodules {
196        /// Blob oid for this error or warning.
197        blob_oid: ObjectId,
198        /// Id for this error or warning.
199        id: &'static str,
200        /// Additional human-readable detail for the failure.
201        detail: String,
202    },
203    /// Returned when blob gitattributes.
204    BlobGitattributes {
205        /// Blob oid for this error or warning.
206        blob_oid: ObjectId,
207        /// Id for this error or warning.
208        id: &'static str,
209        /// Additional human-readable detail for the failure.
210        detail: &'static str,
211    },
212}
213
214impl DotFsckIssue {
215    /// Single-line diagnostic matching `git fsck` (`error in tree` / `warning in blob`, etc.).
216    #[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    /// `true` when this fsck message is fatal by default (Git treats `gitmodulesParse` as INFO).
264    #[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
279/// True when raw `.gitmodules` bytes cannot be parsed as Git config (Git `git_config_from_mem` failure).
280fn 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
311/// Content checks for OIDs registered as `.gitmodules` / `.gitattributes` targets (Git `fsck_blob` / `fsck_blobs`).
312pub 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/// Tracks `.gitmodules` / `.gitattributes` blob OIDs discovered in trees (Git `fsck_options` oidsets).
390#[derive(Debug, Default)]
391pub struct DotFsckTracker {
392    /// Gitmodules found for this item.
393    pub gitmodules_found: HashSet<ObjectId>,
394    /// Gitmodules done for this item.
395    pub gitmodules_done: HashSet<ObjectId>,
396    /// Gitattributes found for this item.
397    pub gitattributes_found: HashSet<ObjectId>,
398    /// Gitattributes done for this item.
399    pub gitattributes_done: HashSet<ObjectId>,
400}
401
402impl DotFsckTracker {
403    /// Run per-tree registration and symlink checks (`fsck_tree` entry loop).
404    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    /// Run per-object blob checks when an OID is validated (`fsck_blob`).
414    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    /// Validate any registered blobs that were not reached during the main walk (`fsck_finish` / `fsck_blobs`).
442    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    /// Like [`Self::finish_pending`], but resolves object bytes via `resolve` (e.g. in-memory pack map).
447    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
509/// Write Git-style warnings for submodule path/url values that look like CLI options.
510pub 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    // Fallback: raw scan (handles minimal `.gitmodules` that the strict parser rejects).
548    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/// Returns `true` when `name` is allowed as a submodule logical name (Git `check_submodule_name`).
597#[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    // Git `check_submodule_name`: `goto in_component` before the loop — first component.
604    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
656/// Percent-decode `%XX` sequences (Git `url_decode` subset for submodule URL safety checks).
657fn 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
678/// Git `count_leading_dotdots` / leading `./` stripping (`submodule-config.c`).
679fn 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/// Returns `true` when `url` is safe for `.gitmodules` (Git `check_submodule_url`).
716#[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
775/// Max `.gitattributes` line length checked by Git `fsck` (`attr.h`).
776pub const ATTR_MAX_LINE_LENGTH: usize = 2048;
777
778/// Max `.gitattributes` blob size for fsck (`attr.h`).
779pub const ATTR_MAX_FILE_SIZE: usize = 100 * 1024 * 1024;
780
781/// `true` when `value` is a command-style submodule update (`!…`), matching Git fsck.
782fn 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
812/// Validate a `.gitmodules` blob (Git `fsck_gitmodules_fn`). Returns `object hex: msg` or `None`.
813pub 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    // Submodule subsection names can contain `..` and still parse as config lines, but our
868    // canonical key builder rejects those keys — so entries for malicious names are dropped
869    // silently. Always cross-check raw `[submodule "..."]` headers (Git fsck does this via the
870    // real config parser + `check_submodule_name`).
871    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
909/// Validate every `.gitmodules` blob reachable from `commit_oid`. Returns `Some(hex: msg)` on error.
910pub 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
930/// Parse `objects/ab/cdef…` loose paths into OIDs; for `.idx` files load all contained OIDs.
931pub 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}