1pub const TREE_SHA256_HEADER: &str = "tree-sha256";
14
15pub 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#[derive(Clone, Copy, PartialEq, Eq, Hash)]
47pub struct TreeSha256(pub [u8; 32]);
48
49impl TreeSha256 {
50 #[must_use]
52 pub const fn bytes(&self) -> &[u8; 32] {
53 &self.0
54 }
55
56 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
109#[non_exhaustive]
110pub struct TreeSha256Options {
111 pub exclude_globs: Vec<String>,
113 pub exclude_exact: Vec<String>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct TreeSha256Report {
120 pub digest: TreeSha256,
122 pub files: usize,
124 pub submodules: usize,
126 pub excluded: Vec<(String, ObjectId)>,
128 pub cycles: Vec<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum MissingSubmoduleReason {
136 NotCheckedOut,
138 MissingCommit,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct MissingSubmodule {
145 pub path: String,
147 pub reason: MissingSubmoduleReason,
149}
150
151pub 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_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 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 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 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#[derive(Debug, Clone, PartialEq)]
583pub struct TagTreeSha256Outcome {
584 pub claim: TreeSha256Claim,
586 pub report: TreeSha256Report,
588 pub hash_elapsed: Duration,
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct TreeSha256Claim {
595 pub digest: TreeSha256,
597 pub excluded: Vec<String>,
602}
603
604#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
606pub enum TreeSha256ClaimError {
607 #[error("duplicate tree-sha256 header in tag")]
609 DuplicateDigestHeader,
610 #[error("invalid tree-sha256-excluded path")]
612 InvalidExcludedPath,
613}
614
615impl TreeSha256Claim {
616 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 #[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 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
695fn 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#[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#[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}