Skip to main content

grit_lib/
tree_sha256.rs

1//! SHA-256 tree digest over repository file contents ([tree-sha256] spec).
2//!
3//! [tree-sha256]: https://github.com/schacon/tree-sha256
4//!
5//! ```rust
6//! use grit_lib::tree_sha256::TreeSha256Options;
7//! # fn main() {
8//! let _ = TreeSha256Options::default();
9//! # }
10//! ```
11
12/// Tag header name for the tree-sha256 digest (reference tool: [`HEADER`](https://github.com/schacon/tree-sha256)).
13pub const TREE_SHA256_HEADER: &str = "tree-sha256";
14
15/// Tag header naming a submodule path excluded from the digest.
16pub const TREE_SHA256_EXCLUDED_HEADER: &str = "tree-sha256-excluded";
17
18mod tag_verify;
19
20pub(crate) use tag_verify::verify_tag_data;
21pub use tag_verify::{
22    parse_tree_sha256_tag_headers, verify_tag, SignatureVerdict, TagVerification, TagVerifyOptions,
23    TreeSha256TagHeaders, TreeSha256Verdict,
24};
25
26use std::fmt;
27use std::path::{Path, PathBuf};
28use std::str::FromStr;
29use std::time::Duration;
30
31use crate::config::ConfigSet;
32use crate::diff::submodule_embedded_git_dir;
33use crate::environment::RepositoryOptions;
34use crate::error::{Error, Result};
35use crate::hash::{try_par_hash_with, try_par_hash_with_force, PAR_HASH_MIN_ITEMS};
36use crate::objects::{
37    iter_extra_headers, parse_commit, parse_tree, remove_header_lines, HashAlgo, ObjectId,
38    ObjectKind, TagData, TreeEntry,
39};
40use crate::parallel::ParallelHashError;
41use crate::parallel::ParallelOp;
42use crate::repo::Repository;
43use crate::submodule_gitdir::{submodule_modules_git_dir, submodule_path_config_enabled};
44
45/// 32-byte tree-sha256 digest.
46#[derive(Clone, Copy, PartialEq, Eq, Hash)]
47pub struct TreeSha256(pub [u8; 32]);
48
49impl TreeSha256 {
50    /// Digest bytes.
51    #[must_use]
52    pub const fn bytes(&self) -> &[u8; 32] {
53        &self.0
54    }
55
56    /// Lowercase hex encoding (64 characters).
57    #[must_use]
58    pub fn to_hex(&self) -> String {
59        let mut s = String::with_capacity(64);
60        for b in self.0 {
61            use std::fmt::Write as _;
62            let _ = write!(s, "{b:02x}");
63        }
64        s
65    }
66}
67
68impl fmt::Display for TreeSha256 {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(&self.to_hex())
71    }
72}
73
74impl fmt::Debug for TreeSha256 {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.debug_tuple("TreeSha256").field(&self.to_hex()).finish()
77    }
78}
79
80impl FromStr for TreeSha256 {
81    type Err = Error;
82
83    fn from_str(s: &str) -> Result<Self> {
84        let s = s.trim();
85        if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
86            return Err(Error::TreeSha256InvalidHex);
87        }
88        let mut out = [0_u8; 32];
89        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
90            let hi = hex_nibble(chunk[0])?;
91            let lo = hex_nibble(chunk[1])?;
92            out[i] = (hi << 4) | lo;
93        }
94        Ok(Self(out))
95    }
96}
97
98fn hex_nibble(b: u8) -> Result<u8> {
99    match b {
100        b'0'..=b'9' => Ok(b - b'0'),
101        b'a'..=b'f' => Ok(b - b'a' + 10),
102        b'A'..=b'F' => Ok(b - b'A' + 10),
103        _ => Err(Error::TreeSha256InvalidHex),
104    }
105}
106
107/// Submodule exclusion and glob options for [`Repository::tree_sha256`].
108#[derive(Debug, Clone, Default, PartialEq, Eq)]
109#[non_exhaustive]
110pub struct TreeSha256Options {
111    /// Glob patterns; `*` matches any byte including `/`, `?` one byte. Trailing `/` is trimmed.
112    pub exclude_globs: Vec<String>,
113    /// Submodule paths excluded exactly (full path from repository root).
114    pub exclude_exact: Vec<String>,
115}
116
117/// Outcome of a successful tree-sha256 computation.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct TreeSha256Report {
120    /// Final tree-sha256 digest.
121    pub digest: TreeSha256,
122    /// Regular file, executable, and symlink entries hashed.
123    pub files: usize,
124    /// Submodule entries whose contents were hashed recursively.
125    pub submodules: usize,
126    /// Excluded submodule paths and the gitlink commit recorded.
127    pub excluded: Vec<(String, ObjectId)>,
128    /// Submodule paths where a cycle was detected in the commit chain.
129    pub cycles: Vec<String>,
130}
131
132/// Why a submodule could not be hashed.
133#[derive(Debug, Clone, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum MissingSubmoduleReason {
136    /// Submodule work tree or git directory is not present.
137    NotCheckedOut,
138    /// Gitlink commit is missing from the submodule object database.
139    MissingCommit,
140}
141
142/// One submodule that blocked hashing.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct MissingSubmodule {
145    /// Full path from the superproject root.
146    pub path: String,
147    /// What went wrong.
148    pub reason: MissingSubmoduleReason,
149}
150
151/// Compute tree-sha256 for `repo` at `oid` (peels annotated tags to commits).
152///
153/// # Errors
154///
155/// Returns [`Error::TreeSha256MissingSubmodules`] when any submodule required for
156/// hashing is missing; the error lists every missing path.
157pub fn tree_sha256(
158    repo: &Repository,
159    oid: ObjectId,
160    options: &TreeSha256Options,
161) -> Result<TreeSha256Report> {
162    tree_sha256_with_chain(repo, oid, options, &[], "")
163}
164
165fn tree_sha256_with_chain(
166    repo: &Repository,
167    oid: ObjectId,
168    options: &TreeSha256Options,
169    parent_chain: &[ObjectId],
170    path_prefix_from_root: &str,
171) -> Result<TreeSha256Report> {
172    let tree_oid = peel_to_tree(repo, oid)?;
173    let root_commit = peel_to_commit(repo, oid)?;
174    let mut commit_chain: Vec<ObjectId> = parent_chain.to_vec();
175    if let Some(c) = root_commit {
176        if !commit_chain.contains(&c) {
177            commit_chain.push(c);
178        }
179    }
180    let mut ctx = ComputeCtx {
181        repo,
182        options,
183        commit_chain,
184        path_prefix_from_root: path_prefix_from_root.to_owned(),
185        records: Vec::new(),
186        missing: Vec::new(),
187        files: 0,
188        submodules: 0,
189        excluded: Vec::new(),
190        cycles: Vec::new(),
191        blob_jobs: Vec::new(),
192    };
193    ctx.walk_tree(tree_oid, b"")?;
194    ctx.finish_blob_jobs()?;
195    if !ctx.missing.is_empty() {
196        return Err(Error::TreeSha256MissingSubmodules(ctx.missing));
197    }
198    ctx.records
199        .sort_by(|a, b| record_sort_key(a).cmp(record_sort_key(b)));
200    let mut concat = Vec::new();
201    for rec in &ctx.records {
202        concat.extend_from_slice(rec);
203    }
204    let digest_bytes = HashAlgo::Sha256.checksum_digest(&concat);
205    let digest_arr: [u8; 32] = digest_bytes
206        .as_slice()
207        .try_into()
208        .map_err(|_| Error::Message("sha256 digest length".into()))?;
209    let digest = TreeSha256(digest_arr);
210    Ok(TreeSha256Report {
211        digest,
212        files: ctx.files,
213        submodules: ctx.submodules,
214        excluded: ctx.excluded,
215        cycles: ctx.cycles,
216    })
217}
218
219fn peel_to_tree(repo: &Repository, oid: ObjectId) -> Result<ObjectId> {
220    let (peeled, kind) = repo.odb().peel_tags(oid)?;
221    match kind {
222        ObjectKind::Tree => Ok(peeled),
223        ObjectKind::Commit => {
224            let obj = repo.odb().read(&peeled)?;
225            Ok(parse_commit(&obj.data)?.tree)
226        }
227        other => Err(Error::TreeSha256InvalidRevision { found: other }),
228    }
229}
230
231fn peel_to_commit(repo: &Repository, oid: ObjectId) -> Result<Option<ObjectId>> {
232    repo.odb().peel_to_commit(oid)
233}
234
235fn record_sort_key(record: &[u8]) -> &[u8] {
236    let Some(sp) = record.iter().position(|&b| b == b' ') else {
237        return record;
238    };
239    let start = sp + 1;
240    let end = record.iter().rposition(|&b| b == 0).unwrap_or(record.len());
241    &record[start..end]
242}
243
244struct BlobJob {
245    path: Vec<u8>,
246    oid: ObjectId,
247}
248
249struct ComputeCtx<'a> {
250    repo: &'a Repository,
251    options: &'a TreeSha256Options,
252    commit_chain: Vec<ObjectId>,
253    /// Path from the top-level repository to this walk's root (empty at the superproject).
254    path_prefix_from_root: String,
255    records: Vec<Vec<u8>>,
256    missing: Vec<MissingSubmodule>,
257    files: usize,
258    submodules: usize,
259    excluded: Vec<(String, ObjectId)>,
260    cycles: Vec<String>,
261    blob_jobs: Vec<BlobJob>,
262}
263
264impl ComputeCtx<'_> {
265    fn walk_tree(&mut self, tree_oid: ObjectId, prefix: &[u8]) -> Result<()> {
266        let obj = self.repo.odb().read(&tree_oid)?;
267        if obj.kind != ObjectKind::Tree {
268            return Err(Error::CorruptObject(format!("expected tree {tree_oid}")));
269        }
270        let entries = parse_tree(&obj.data)?;
271        for TreeEntry { mode, name, oid } in entries {
272            if name.contains(&b'/') || name.contains(&0) {
273                return Err(Error::CorruptObject("invalid tree entry name".into()));
274            }
275            let path = join_path_bytes(prefix, &name);
276            const S_IFMT: u32 = 0o170000;
277            const S_IFDIR: u32 = 0o040000;
278            const S_IFREG: u32 = 0o100000;
279            const S_IFLNK: u32 = 0o120000;
280            const S_IFGITLINK: u32 = 0o160000;
281            match mode & S_IFMT {
282                S_IFDIR => {
283                    let mut child_prefix = path;
284                    child_prefix.push(b'/');
285                    self.walk_tree(oid, &child_prefix)?;
286                }
287                S_IFREG | S_IFLNK => {
288                    self.blob_jobs.push(BlobJob { path, oid });
289                }
290                S_IFGITLINK => self.handle_gitlink(&path, oid)?,
291                _ => {}
292            }
293        }
294        Ok(())
295    }
296
297    fn handle_gitlink(&mut self, path: &[u8], gitlink: ObjectId) -> Result<()> {
298        let global_path = self.global_path_from_root(path);
299        if self.is_excluded(path) {
300            self.push_excluded_record(path, gitlink);
301            self.excluded.push((global_path.clone(), gitlink));
302            return Ok(());
303        }
304        if gitlink_in_commit_chain(&self.commit_chain, gitlink) {
305            self.push_cycle_record(path, gitlink);
306            self.cycles.push(global_path.clone());
307            return Ok(());
308        }
309        let Some(sub_repo) = self.open_submodule_repo(path)? else {
310            self.missing.push(MissingSubmodule {
311                path: global_path,
312                reason: MissingSubmoduleReason::NotCheckedOut,
313            });
314            return Ok(());
315        };
316        if sub_repo.odb().read(&gitlink).is_err() {
317            self.missing.push(MissingSubmodule {
318                path: global_path,
319                reason: MissingSubmoduleReason::MissingCommit,
320            });
321            return Ok(());
322        }
323        self.commit_chain.push(gitlink);
324        let sub_opts = TreeSha256Options {
325            exclude_globs: self.options.exclude_globs.clone(),
326            exclude_exact: self.options.exclude_exact.clone(),
327        };
328        match tree_sha256_with_chain(
329            &sub_repo,
330            gitlink,
331            &sub_opts,
332            &self.commit_chain,
333            &global_path,
334        ) {
335            Ok(sub_report) => {
336                self.commit_chain.pop();
337                self.push_submodule_record(path, &sub_report.digest);
338                self.files += sub_report.files;
339                self.submodules += 1 + sub_report.submodules;
340                // Submodule reports already record excluded/cycle paths from the top repo.
341                self.excluded.extend(sub_report.excluded);
342                self.cycles.extend(sub_report.cycles);
343            }
344            Err(Error::TreeSha256MissingSubmodules(mut miss)) => {
345                self.commit_chain.pop();
346                // Child missing paths are already qualified to the top repository.
347                self.missing.append(&mut miss);
348            }
349            Err(e) => {
350                self.commit_chain.pop();
351                return Err(e);
352            }
353        }
354        Ok(())
355    }
356
357    fn global_path_from_root(&self, local_path: &[u8]) -> String {
358        let local = path_bytes_to_string(local_path);
359        if self.path_prefix_from_root.is_empty() {
360            return local;
361        }
362        qualify_submodule_path(&self.path_prefix_from_root, &local)
363    }
364
365    fn is_excluded(&self, path: &[u8]) -> bool {
366        let global = self.global_path_from_root(path);
367        if self
368            .options
369            .exclude_exact
370            .iter()
371            .any(|p| p.as_bytes() == path || p == &global)
372        {
373            return true;
374        }
375        self.options
376            .exclude_globs
377            .iter()
378            .any(|g| path_matches_glob_bytes(path, g.as_bytes()) || path_matches_glob(&global, g))
379    }
380
381    fn open_submodule_repo(&self, rel_path: &[u8]) -> Result<Option<Repository>> {
382        let wt = match self.repo.work_tree() {
383            Some(w) => w,
384            None => return Ok(None),
385        };
386        let sub_wt = wt.join(path_bytes_to_os(rel_path));
387        if !sub_wt.is_dir() {
388            return Ok(None);
389        }
390        if let Some(git_dir) = submodule_embedded_git_dir(&sub_wt) {
391            return Repository::open(&git_dir, &RepositoryOptions::empty().with_work_tree(sub_wt))
392                .map(Some);
393        }
394        let cfg = self.repo.config()?;
395        let rel_str = path_bytes_to_string(rel_path);
396        let name = submodule_name_for_path(&cfg, &rel_str).unwrap_or(rel_str);
397        let git_dir = resolve_submodule_git_dir(wt, self.repo.common_git_dir(), &cfg, &name)?;
398        if !git_dir.join("HEAD").exists() {
399            return Ok(None);
400        }
401        Repository::open(&git_dir, &RepositoryOptions::empty().with_work_tree(sub_wt)).map(Some)
402    }
403
404    fn finish_blob_jobs(&mut self) -> Result<()> {
405        if self.blob_jobs.is_empty() {
406            return Ok(());
407        }
408        let cfg = self.repo.config()?;
409        // Blob SHA-256 is CPU-bound; use `pack.threads` (defaults to all CPUs), not
410        // `DiffContent` (serial unless `core.preloadIndex` is enabled).
411        let threads = self
412            .repo
413            .thread_policy()
414            .try_resolve(ParallelOp::PackWrite, &cfg)
415            .map(|opts| opts.parallelism().threads())
416            .unwrap_or_else(|_| {
417                self.repo
418                    .thread_policy()
419                    .resolve(ParallelOp::DiffContent, &cfg)
420                    .parallelism()
421                    .threads()
422            });
423        let odb = self.repo.odb();
424        let hash_one = |job: &BlobJob| {
425            let obj = odb.read(&job.oid)?;
426            if !matches!(obj.kind, ObjectKind::Blob) {
427                return Err(Error::CorruptObject(format!("expected blob {}", job.oid)));
428            }
429            Ok(hex_sha256_content(&obj.data))
430        };
431        let hashed: Vec<String> = if self.blob_jobs.len() >= PAR_HASH_MIN_ITEMS {
432            try_par_hash_with_force(&self.blob_jobs, threads, hash_one)
433        } else {
434            let mut total_bytes = 0_usize;
435            for job in &self.blob_jobs {
436                total_bytes += odb.read_info(&job.oid)?.size as usize;
437            }
438            try_par_hash_with(&self.blob_jobs, threads, total_bytes.max(1), hash_one)
439        }
440        .map_err(|e| match e {
441            ParallelHashError::Task(err) => err,
442        })?;
443        for (job, hex) in self.blob_jobs.iter().zip(hashed) {
444            self.records.push(file_record(&hex, &job.path));
445            self.files += 1;
446        }
447        Ok(())
448    }
449
450    fn push_submodule_record(&mut self, path: &[u8], digest: &TreeSha256) {
451        let mut rec = digest.to_hex().into_bytes();
452        rec.push(b' ');
453        rec.extend_from_slice(path);
454        if !path.ends_with(b"/") {
455            rec.push(b'/');
456        }
457        rec.push(0);
458        self.records.push(rec);
459    }
460
461    fn push_cycle_record(&mut self, path: &[u8], commit: ObjectId) {
462        let mut rec = b"cycle:".to_vec();
463        rec.extend_from_slice(commit.to_hex().as_bytes());
464        rec.push(b' ');
465        rec.extend_from_slice(path);
466        if !path.ends_with(b"/") {
467            rec.push(b'/');
468        }
469        rec.push(0);
470        self.records.push(rec);
471    }
472
473    fn push_excluded_record(&mut self, path: &[u8], commit: ObjectId) {
474        let mut rec = b"excluded:".to_vec();
475        rec.extend_from_slice(commit.to_hex().as_bytes());
476        rec.push(b' ');
477        rec.extend_from_slice(path);
478        if !path.ends_with(b"/") {
479            rec.push(b'/');
480        }
481        rec.push(0);
482        self.records.push(rec);
483    }
484}
485
486fn join_path_bytes(prefix: &[u8], name: &[u8]) -> Vec<u8> {
487    let mut path = Vec::with_capacity(prefix.len().saturating_add(name.len()));
488    path.extend_from_slice(prefix);
489    path.extend_from_slice(name);
490    path
491}
492
493fn path_bytes_to_string(path: &[u8]) -> String {
494    String::from_utf8_lossy(path).into_owned()
495}
496
497fn path_bytes_to_os(path: &[u8]) -> std::ffi::OsString {
498    #[cfg(unix)]
499    {
500        use std::os::unix::ffi::OsStrExt;
501        std::ffi::OsStr::from_bytes(path).to_os_string()
502    }
503    #[cfg(not(unix))]
504    {
505        path_bytes_to_string(path).into()
506    }
507}
508
509fn qualify_submodule_path(gitlink_path: &str, sub_path: &str) -> String {
510    if sub_path.is_empty() {
511        return gitlink_path.to_owned();
512    }
513    let base = gitlink_path.trim_end_matches('/');
514    format!("{base}/{sub_path}")
515}
516
517fn file_record(content_hex: &str, path: &[u8]) -> Vec<u8> {
518    let mut rec = Vec::with_capacity(content_hex.len() + path.len() + 2);
519    rec.extend_from_slice(content_hex.as_bytes());
520    rec.push(b' ');
521    rec.extend_from_slice(path);
522    rec.push(0);
523    rec
524}
525
526fn hex_sha256_content(data: &[u8]) -> String {
527    let digest = HashAlgo::Sha256.checksum_digest(data);
528    let mut s = String::with_capacity(64);
529    for b in digest {
530        use std::fmt::Write as _;
531        let _ = write!(s, "{b:02x}");
532    }
533    s
534}
535
536fn resolve_submodule_git_dir(
537    work_tree: &Path,
538    super_git_dir: &Path,
539    cfg: &ConfigSet,
540    submodule_name: &str,
541) -> Result<PathBuf> {
542    if submodule_path_config_enabled(super_git_dir) {
543        let key = format!("submodule.{submodule_name}.gitdir");
544        let value = cfg.get(&key).ok_or_else(|| {
545            Error::Config(format!("missing {key} (submodulePathConfig enabled)").into())
546        })?;
547        Ok(resolve_gitdir_value(work_tree, &value))
548    } else {
549        Ok(submodule_modules_git_dir(super_git_dir, submodule_name))
550    }
551}
552
553fn resolve_gitdir_value(work_tree: &Path, gitdir_cfg: &str) -> PathBuf {
554    let p = Path::new(gitdir_cfg.trim());
555    if p.is_absolute() {
556        p.to_path_buf()
557    } else {
558        work_tree.join(p)
559    }
560}
561
562fn submodule_name_for_path(cfg: &ConfigSet, path: &str) -> Option<String> {
563    for e in cfg.entries() {
564        let key = &e.key;
565        if let Some(rest) = key.strip_prefix("submodule.") {
566            if let Some(name) = rest.strip_suffix(".path") {
567                if e.value.as_deref() == Some(path) {
568                    return Some(name.to_owned());
569                }
570            }
571        }
572    }
573    None
574}
575
576#[must_use]
577pub(crate) fn gitlink_in_commit_chain(chain: &[ObjectId], gitlink: ObjectId) -> bool {
578    chain.contains(&gitlink)
579}
580
581/// Tree-sha256 digest, counts, and timing from explicit tag creation (`CreateTagRequest::tree_sha256`).
582#[derive(Debug, Clone, PartialEq)]
583pub struct TagTreeSha256Outcome {
584    /// Headers written on the tag object.
585    pub claim: TreeSha256Claim,
586    /// Full hash report (files, submodules, excluded gitlinks).
587    pub report: TreeSha256Report,
588    /// Wall time spent computing the digest.
589    pub hash_elapsed: Duration,
590}
591
592/// Digest and excluded submodule paths recorded on an annotated tag.
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct TreeSha256Claim {
595    /// Recorded tree-sha256 digest.
596    pub digest: TreeSha256,
597    /// Submodule paths left out of the digest (sorted when written with [`Self::apply_to_tag`]).
598    ///
599    /// Values must be non-empty and must not contain NUL or newline when passed to
600    /// [`Self::apply_to_tag`].
601    pub excluded: Vec<String>,
602}
603
604/// Failure modes when reading or validating a [`TreeSha256Claim`] from tag headers.
605#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
606pub enum TreeSha256ClaimError {
607    /// More than one `tree-sha256` header appeared after `tagger`.
608    #[error("duplicate tree-sha256 header in tag")]
609    DuplicateDigestHeader,
610    /// A `tree-sha256-excluded` path was empty or contained NUL or newline.
611    #[error("invalid tree-sha256-excluded path")]
612    InvalidExcludedPath,
613}
614
615impl TreeSha256Claim {
616    /// Read a claim from post-`tagger` extra headers on `tag`.
617    ///
618    /// Returns `Ok(None)` when no `tree-sha256` header is present. Commit objects are not
619    /// supported.
620    ///
621    /// # Errors
622    ///
623    /// Returns [`TreeSha256ClaimError::DuplicateDigestHeader`] or
624    /// [`TreeSha256ClaimError::InvalidExcludedPath`], or [`Error::TreeSha256InvalidHex`] when the
625    /// digest value is not exactly 64 ASCII hex digits (no trimming; surrounding or trailing
626    /// whitespace is rejected).
627    pub fn from_tag(tag: &TagData) -> Result<Option<Self>> {
628        let header = TREE_SHA256_HEADER.as_bytes();
629        let excluded_key = TREE_SHA256_EXCLUDED_HEADER.as_bytes();
630        let mut digest: Option<TreeSha256> = None;
631        let mut excluded = Vec::new();
632        for (key, value) in iter_extra_headers(&tag.extra_headers) {
633            if key == header {
634                if digest.is_some() {
635                    return Err(TreeSha256ClaimError::DuplicateDigestHeader.into());
636                }
637                digest = Some(tree_sha256_digest_from_tag_header_value(value)?);
638            } else if key == excluded_key {
639                let path = std::str::from_utf8(value)
640                    .map_err(|_| TreeSha256ClaimError::InvalidExcludedPath)?;
641                validate_excluded_path(path)?;
642                excluded.push(path.to_owned());
643            }
644        }
645        Ok(digest.map(|d| Self {
646            digest: d,
647            excluded,
648        }))
649    }
650
651    /// Build a claim from a freshly computed [`TreeSha256Report`].
652    #[must_use]
653    pub fn from_report(report: &TreeSha256Report) -> Self {
654        let mut excluded: Vec<String> = report.excluded.iter().map(|(p, _)| p.clone()).collect();
655        excluded.sort();
656        Self {
657            digest: report.digest,
658            excluded,
659        }
660    }
661
662    /// Replace any existing tree-sha256 headers on `tag` with this claim.
663    ///
664    /// Excluded paths are written in sorted order, then the digest line, immediately after
665    /// `tagger` and before any other extra headers, matching the reference `tree-sha256-tag`
666    /// layout (inside the signed payload, before the blank line).
667    ///
668    /// Each entry in [`Self::excluded`] must be non-empty and must not contain NUL or newline;
669    /// invalid paths are not checked here — callers must only supply paths that would round-trip
670    /// through [`Self::from_tag`].
671    pub fn apply_to_tag(&self, tag: &mut TagData) {
672        let header = TREE_SHA256_HEADER.as_bytes();
673        let excluded_key = TREE_SHA256_EXCLUDED_HEADER.as_bytes();
674        let unrelated = remove_header_lines(&tag.extra_headers, |key| {
675            key == header || key == excluded_key
676        });
677        let mut claim = Vec::new();
678        let mut paths = self.excluded.clone();
679        paths.sort();
680        for path in paths {
681            claim.extend_from_slice(TREE_SHA256_EXCLUDED_HEADER.as_bytes());
682            claim.push(b' ');
683            claim.extend_from_slice(path.as_bytes());
684            claim.push(b'\n');
685        }
686        claim.extend_from_slice(TREE_SHA256_HEADER.as_bytes());
687        claim.push(b' ');
688        claim.extend_from_slice(self.digest.to_hex().as_bytes());
689        claim.push(b'\n');
690        tag.extra_headers = claim;
691        tag.extra_headers.extend_from_slice(&unrelated);
692    }
693}
694
695/// Parse a `tree-sha256` tag header value: exactly 64 ASCII hex digits, no surrounding whitespace.
696fn tree_sha256_digest_from_tag_header_value(value: &[u8]) -> Result<TreeSha256> {
697    if value.len() != 64 || !value.iter().all(|b| b.is_ascii_hexdigit()) {
698        return Err(Error::TreeSha256InvalidHex);
699    }
700    let text = std::str::from_utf8(value).map_err(|_| Error::TreeSha256InvalidHex)?;
701    TreeSha256::from_str(text)
702}
703
704fn validate_excluded_path(path: &str) -> Result<()> {
705    if path.is_empty() || path.bytes().any(|b| b == 0 || b == b'\n') {
706        return Err(TreeSha256ClaimError::InvalidExcludedPath.into());
707    }
708    Ok(())
709}
710
711/// Returns true when `path` matches `pattern` (Git tree-sha256 glob rules).
712#[must_use]
713pub fn path_matches_glob(path: &str, pattern: &str) -> bool {
714    let pattern = pattern.trim_end_matches('/');
715    path_matches_glob_bytes(path.as_bytes(), pattern.as_bytes())
716}
717
718/// Like [`path_matches_glob`], but `path` and `pattern` are raw bytes (`pattern` trailing `/` trimmed).
719#[must_use]
720pub fn path_matches_glob_bytes(path: &[u8], pattern: &[u8]) -> bool {
721    glob_match(path, trim_trailing_slash_bytes(pattern))
722}
723
724fn trim_trailing_slash_bytes(mut pat: &[u8]) -> &[u8] {
725    while pat.last() == Some(&b'/') {
726        pat = &pat[..pat.len() - 1];
727    }
728    pat
729}
730
731fn glob_match(path: &[u8], pat: &[u8]) -> bool {
732    glob_match_at(path, pat, 0, 0)
733}
734
735fn glob_match_at(path: &[u8], pat: &[u8], mut pi: usize, mut si: usize) -> bool {
736    loop {
737        if pi == pat.len() {
738            return si == path.len();
739        }
740        match pat[pi] {
741            b'*' => {
742                pi += 1;
743                if pi == pat.len() {
744                    return true;
745                }
746                while si <= path.len() {
747                    if glob_match_at(path, pat, pi, si) {
748                        return true;
749                    }
750                    si += 1;
751                }
752                return false;
753            }
754            b'?' => {
755                if si >= path.len() {
756                    return false;
757                }
758                pi += 1;
759                si += 1;
760            }
761            c => {
762                if si >= path.len() || path[si] != c {
763                    return false;
764                }
765                pi += 1;
766                si += 1;
767            }
768        }
769    }
770}
771
772#[cfg(test)]
773mod glob_tests {
774    use super::{gitlink_in_commit_chain, path_matches_glob};
775    use crate::objects::ObjectId;
776
777    #[test]
778    fn gitlink_cycle_helper() {
779        let a: ObjectId = "0000000000000000000000000000000000000001".parse().unwrap();
780        let b: ObjectId = "0000000000000000000000000000000000000002".parse().unwrap();
781        assert!(gitlink_in_commit_chain(&[a, b, a], a));
782        assert!(!gitlink_in_commit_chain(&[b], a));
783    }
784
785    #[test]
786    fn star_matches_slash() {
787        assert!(path_matches_glob("a/b/c", "a/*"));
788        assert!(path_matches_glob("nested/sub/file", "nested/*"));
789    }
790
791    #[test]
792    fn question_mark_one_byte() {
793        assert!(path_matches_glob("abc", "a?c"));
794        assert!(!path_matches_glob("ac", "a?c"));
795    }
796
797    #[test]
798    fn trailing_slash_on_pattern_trimmed() {
799        assert!(path_matches_glob("vendor", "vendor/"));
800    }
801}
802
803#[cfg(test)]
804mod claim_tests {
805    use super::{
806        TreeSha256, TreeSha256Claim, TreeSha256ClaimError, TREE_SHA256_EXCLUDED_HEADER,
807        TREE_SHA256_HEADER,
808    };
809    use crate::error::Error;
810    use crate::objects::{parse_tag, serialize_tag, TagData};
811    use std::str::FromStr;
812
813    fn sample_tag(extra: &[u8]) -> TagData {
814        TagData {
815            object: "15c242b3b6ace73d1884e431f0d814d7a0e827a1".parse().unwrap(),
816            object_type: "commit".into(),
817            tag: "t".into(),
818            tagger: Some("T <t@example.com> 1 +0000".into()),
819            extra_headers: extra.to_vec(),
820            message: "msg\n".into(),
821        }
822    }
823
824    #[test]
825    fn claim_roundtrip_tag() {
826        let digest = TreeSha256::from_str(
827            "c29d81cdc535c4804153b7026e6b276b2dbab25d636341f6cab4179e2b1fd47a",
828        )
829        .unwrap();
830        let claim = TreeSha256Claim {
831            digest,
832            excluded: vec!["z/mod".into(), "a/mod".into()],
833        };
834        let mut tag = sample_tag(b"other x\n".as_slice());
835        claim.apply_to_tag(&mut tag);
836        assert!(
837            String::from_utf8_lossy(&tag.extra_headers).starts_with("tree-sha256-excluded a/mod"),
838            "claim must follow tagger, not unrelated headers: {:?}",
839            String::from_utf8_lossy(&tag.extra_headers)
840        );
841        assert!(
842            String::from_utf8_lossy(&tag.extra_headers).contains("other x\n"),
843            "unrelated headers must remain after the claim block"
844        );
845        let parsed = TreeSha256Claim::from_tag(&tag).unwrap().unwrap();
846        assert_eq!(parsed.digest, digest);
847        let mut want = claim.excluded.clone();
848        want.sort();
849        assert_eq!(parsed.excluded, want);
850        let bytes = serialize_tag(&tag);
851        let round = parse_tag(&bytes).unwrap();
852        assert_eq!(TreeSha256Claim::from_tag(&round).unwrap(), Some(parsed));
853    }
854
855    #[test]
856    fn claim_layout_matches_reference_tool_bytes() {
857        let fixture = include_bytes!("../tests/fixtures/tree_sha256/reference_tool_tag.txt");
858        let parsed = parse_tag(fixture).expect("parse fixture");
859        let claim = TreeSha256Claim::from_tag(&parsed)
860            .expect("from_tag")
861            .expect("claim");
862        let mut rebuilt = TagData {
863            extra_headers: Vec::new(),
864            ..parsed.clone()
865        };
866        claim.apply_to_tag(&mut rebuilt);
867        assert_eq!(serialize_tag(&rebuilt), fixture);
868    }
869
870    #[test]
871    fn apply_replaces_existing_claim() {
872        let digest = TreeSha256::from_str(
873            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
874        )
875        .unwrap();
876        let old = TreeSha256Claim {
877            digest,
878            excluded: vec!["old/path".into()],
879        };
880        let new = TreeSha256Claim {
881            digest: TreeSha256::from_str(
882                "c29d81cdc535c4804153b7026e6b276b2dbab25d636341f6cab4179e2b1fd47a",
883            )
884            .unwrap(),
885            excluded: vec![],
886        };
887        let mut tag = sample_tag(b"");
888        old.apply_to_tag(&mut tag);
889        new.apply_to_tag(&mut tag);
890        let claim = TreeSha256Claim::from_tag(&tag).unwrap().unwrap();
891        assert_eq!(claim, new);
892        assert!(
893            !String::from_utf8_lossy(&tag.extra_headers).contains("old/path"),
894            "previous excluded path must be gone"
895        );
896    }
897
898    #[test]
899    fn duplicate_header_rejected() {
900        let extra =
901            b"tree-sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
902tree-sha256 c29d81cdc535c4804153b7026e6b276b2dbab25d636341f6cab4179e2b1fd47a\n";
903        let tag = sample_tag(extra);
904        assert!(matches!(
905            TreeSha256Claim::from_tag(&tag),
906            Err(Error::TreeSha256Claim(
907                TreeSha256ClaimError::DuplicateDigestHeader
908            ))
909        ));
910    }
911
912    #[test]
913    fn bad_hex_rejected() {
914        let extra = b"tree-sha256 not_valid_hex\n";
915        let tag = sample_tag(extra);
916        assert!(matches!(
917            TreeSha256Claim::from_tag(&tag),
918            Err(Error::TreeSha256InvalidHex)
919        ));
920    }
921
922    #[test]
923    fn digest_header_whitespace_rejected() {
924        let hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
925        for extra in [
926            format!("tree-sha256 {hex} \n"),
927            format!("tree-sha256  {hex}\n"),
928            format!("tree-sha256 {hex}x\n"),
929        ] {
930            let tag = sample_tag(extra.as_bytes());
931            assert!(
932                matches!(
933                    TreeSha256Claim::from_tag(&tag),
934                    Err(Error::TreeSha256InvalidHex)
935                ),
936                "must reject {extra:?}"
937            );
938        }
939    }
940
941    #[test]
942    fn apply_places_claim_immediately_after_tagger_byte_order() {
943        let digest = TreeSha256::from_str(
944            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
945        )
946        .unwrap();
947        let claim = TreeSha256Claim {
948            digest,
949            excluded: vec!["sub/mod".into()],
950        };
951        let mut tag = sample_tag(b"other existing\n");
952        claim.apply_to_tag(&mut tag);
953        let want_prefix = "tree-sha256-excluded sub/mod\n\
954tree-sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
955other existing\n";
956        assert_eq!(
957            String::from_utf8_lossy(&tag.extra_headers),
958            want_prefix,
959            "serialized extra headers must not place unrelated lines before the claim"
960        );
961        let bytes = serialize_tag(&tag);
962        let tagger_end = b"tagger T <t@example.com> 1 +0000\n";
963        let pos = bytes
964            .windows(tagger_end.len())
965            .position(|w| w == tagger_end)
966            .expect("tagger line");
967        let after_tagger = &bytes[pos + tagger_end.len()..];
968        assert!(
969            after_tagger.starts_with(want_prefix.as_bytes()),
970            "full tag bytes must place claim immediately after tagger"
971        );
972    }
973
974    #[test]
975    fn no_claim_returns_none() {
976        let tag = sample_tag(b"change-id abc\n");
977        assert_eq!(TreeSha256Claim::from_tag(&tag).unwrap(), None);
978    }
979
980    #[test]
981    fn invalid_excluded_path_rejected() {
982        let digest_line =
983            b"tree-sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n";
984        let cases: Vec<Vec<u8>> = vec![
985            b"tree-sha256-excluded \n".to_vec(),
986            b"tree-sha256-excluded bad\n more\n".to_vec(),
987            b"tree-sha256-excluded nul\0here\n".to_vec(),
988        ];
989        for extra in &cases {
990            let mut blob = extra.clone();
991            blob.extend_from_slice(digest_line);
992            let tag = sample_tag(&blob);
993            assert!(matches!(
994                TreeSha256Claim::from_tag(&tag),
995                Err(Error::TreeSha256Claim(
996                    TreeSha256ClaimError::InvalidExcludedPath
997                ))
998            ));
999        }
1000    }
1001
1002    #[test]
1003    fn header_constants_match_reference_tool() {
1004        assert_eq!(TREE_SHA256_HEADER, "tree-sha256");
1005        assert_eq!(TREE_SHA256_EXCLUDED_HEADER, "tree-sha256-excluded");
1006    }
1007}