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, SubmoduleError};
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::Submodule(SubmoduleError::GitDirNotConfigured {
546 name: submodule_name.to_owned(),
547 })
548 })?;
549 Ok(resolve_gitdir_value(work_tree, &value))
550 } else {
551 Ok(submodule_modules_git_dir(super_git_dir, submodule_name))
552 }
553}
554
555fn resolve_gitdir_value(work_tree: &Path, gitdir_cfg: &str) -> PathBuf {
556 let p = Path::new(gitdir_cfg.trim());
557 if p.is_absolute() {
558 p.to_path_buf()
559 } else {
560 work_tree.join(p)
561 }
562}
563
564fn submodule_name_for_path(cfg: &ConfigSet, path: &str) -> Option<String> {
565 for e in cfg.entries() {
566 let key = &e.key;
567 if let Some(rest) = key.strip_prefix("submodule.") {
568 if let Some(name) = rest.strip_suffix(".path") {
569 if e.value.as_deref() == Some(path) {
570 return Some(name.to_owned());
571 }
572 }
573 }
574 }
575 None
576}
577
578#[must_use]
579pub(crate) fn gitlink_in_commit_chain(chain: &[ObjectId], gitlink: ObjectId) -> bool {
580 chain.contains(&gitlink)
581}
582
583#[derive(Debug, Clone, PartialEq)]
585pub struct TagTreeSha256Outcome {
586 pub claim: TreeSha256Claim,
588 pub report: TreeSha256Report,
590 pub hash_elapsed: Duration,
592}
593
594#[derive(Debug, Clone, PartialEq, Eq)]
596pub struct TreeSha256Claim {
597 pub digest: TreeSha256,
599 pub excluded: Vec<String>,
604}
605
606#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
608pub enum TreeSha256ClaimError {
609 #[error("duplicate tree-sha256 header in tag")]
611 DuplicateDigestHeader,
612 #[error("invalid tree-sha256-excluded path")]
614 InvalidExcludedPath,
615}
616
617impl TreeSha256Claim {
618 pub fn from_tag(tag: &TagData) -> Result<Option<Self>> {
630 let header = TREE_SHA256_HEADER.as_bytes();
631 let excluded_key = TREE_SHA256_EXCLUDED_HEADER.as_bytes();
632 let mut digest: Option<TreeSha256> = None;
633 let mut excluded = Vec::new();
634 for (key, value) in iter_extra_headers(&tag.extra_headers) {
635 if key == header {
636 if digest.is_some() {
637 return Err(TreeSha256ClaimError::DuplicateDigestHeader.into());
638 }
639 digest = Some(tree_sha256_digest_from_tag_header_value(value)?);
640 } else if key == excluded_key {
641 let path = std::str::from_utf8(value)
642 .map_err(|_| TreeSha256ClaimError::InvalidExcludedPath)?;
643 validate_excluded_path(path)?;
644 excluded.push(path.to_owned());
645 }
646 }
647 Ok(digest.map(|d| Self {
648 digest: d,
649 excluded,
650 }))
651 }
652
653 #[must_use]
655 pub fn from_report(report: &TreeSha256Report) -> Self {
656 let mut excluded: Vec<String> = report.excluded.iter().map(|(p, _)| p.clone()).collect();
657 excluded.sort();
658 Self {
659 digest: report.digest,
660 excluded,
661 }
662 }
663
664 pub fn apply_to_tag(&self, tag: &mut TagData) {
674 let header = TREE_SHA256_HEADER.as_bytes();
675 let excluded_key = TREE_SHA256_EXCLUDED_HEADER.as_bytes();
676 let unrelated = remove_header_lines(&tag.extra_headers, |key| {
677 key == header || key == excluded_key
678 });
679 let mut claim = Vec::new();
680 let mut paths = self.excluded.clone();
681 paths.sort();
682 for path in paths {
683 claim.extend_from_slice(TREE_SHA256_EXCLUDED_HEADER.as_bytes());
684 claim.push(b' ');
685 claim.extend_from_slice(path.as_bytes());
686 claim.push(b'\n');
687 }
688 claim.extend_from_slice(TREE_SHA256_HEADER.as_bytes());
689 claim.push(b' ');
690 claim.extend_from_slice(self.digest.to_hex().as_bytes());
691 claim.push(b'\n');
692 tag.extra_headers = claim;
693 tag.extra_headers.extend_from_slice(&unrelated);
694 }
695}
696
697fn tree_sha256_digest_from_tag_header_value(value: &[u8]) -> Result<TreeSha256> {
699 if value.len() != 64 || !value.iter().all(|b| b.is_ascii_hexdigit()) {
700 return Err(Error::TreeSha256InvalidHex);
701 }
702 let text = std::str::from_utf8(value).map_err(|_| Error::TreeSha256InvalidHex)?;
703 TreeSha256::from_str(text)
704}
705
706fn validate_excluded_path(path: &str) -> Result<()> {
707 if path.is_empty() || path.bytes().any(|b| b == 0 || b == b'\n') {
708 return Err(TreeSha256ClaimError::InvalidExcludedPath.into());
709 }
710 Ok(())
711}
712
713#[must_use]
715pub fn path_matches_glob(path: &str, pattern: &str) -> bool {
716 let pattern = pattern.trim_end_matches('/');
717 path_matches_glob_bytes(path.as_bytes(), pattern.as_bytes())
718}
719
720#[must_use]
722pub fn path_matches_glob_bytes(path: &[u8], pattern: &[u8]) -> bool {
723 glob_match(path, trim_trailing_slash_bytes(pattern))
724}
725
726fn trim_trailing_slash_bytes(mut pat: &[u8]) -> &[u8] {
727 while pat.last() == Some(&b'/') {
728 pat = &pat[..pat.len() - 1];
729 }
730 pat
731}
732
733fn glob_match(path: &[u8], pat: &[u8]) -> bool {
734 glob_match_at(path, pat, 0, 0)
735}
736
737fn glob_match_at(path: &[u8], pat: &[u8], mut pi: usize, mut si: usize) -> bool {
738 loop {
739 if pi == pat.len() {
740 return si == path.len();
741 }
742 match pat[pi] {
743 b'*' => {
744 pi += 1;
745 if pi == pat.len() {
746 return true;
747 }
748 while si <= path.len() {
749 if glob_match_at(path, pat, pi, si) {
750 return true;
751 }
752 si += 1;
753 }
754 return false;
755 }
756 b'?' => {
757 if si >= path.len() {
758 return false;
759 }
760 pi += 1;
761 si += 1;
762 }
763 c => {
764 if si >= path.len() || path[si] != c {
765 return false;
766 }
767 pi += 1;
768 si += 1;
769 }
770 }
771 }
772}
773
774#[cfg(test)]
775mod glob_tests {
776 use super::{gitlink_in_commit_chain, path_matches_glob};
777 use crate::objects::ObjectId;
778
779 #[test]
780 fn gitlink_cycle_helper() {
781 let a: ObjectId = "0000000000000000000000000000000000000001".parse().unwrap();
782 let b: ObjectId = "0000000000000000000000000000000000000002".parse().unwrap();
783 assert!(gitlink_in_commit_chain(&[a, b, a], a));
784 assert!(!gitlink_in_commit_chain(&[b], a));
785 }
786
787 #[test]
788 fn star_matches_slash() {
789 assert!(path_matches_glob("a/b/c", "a/*"));
790 assert!(path_matches_glob("nested/sub/file", "nested/*"));
791 }
792
793 #[test]
794 fn question_mark_one_byte() {
795 assert!(path_matches_glob("abc", "a?c"));
796 assert!(!path_matches_glob("ac", "a?c"));
797 }
798
799 #[test]
800 fn trailing_slash_on_pattern_trimmed() {
801 assert!(path_matches_glob("vendor", "vendor/"));
802 }
803}
804
805#[cfg(test)]
806mod claim_tests {
807 use super::{
808 TreeSha256, TreeSha256Claim, TreeSha256ClaimError, TREE_SHA256_EXCLUDED_HEADER,
809 TREE_SHA256_HEADER,
810 };
811 use crate::error::Error;
812 use crate::objects::{parse_tag, serialize_tag, TagData};
813 use std::str::FromStr;
814
815 fn sample_tag(extra: &[u8]) -> TagData {
816 TagData {
817 object: "15c242b3b6ace73d1884e431f0d814d7a0e827a1".parse().unwrap(),
818 object_type: "commit".into(),
819 tag: "t".into(),
820 tagger: Some("T <t@example.com> 1 +0000".into()),
821 extra_headers: extra.to_vec(),
822 message: "msg\n".into(),
823 raw_message: None,
824 }
825 }
826
827 #[test]
828 fn claim_roundtrip_tag() {
829 let digest = TreeSha256::from_str(
830 "c29d81cdc535c4804153b7026e6b276b2dbab25d636341f6cab4179e2b1fd47a",
831 )
832 .unwrap();
833 let claim = TreeSha256Claim {
834 digest,
835 excluded: vec!["z/mod".into(), "a/mod".into()],
836 };
837 let mut tag = sample_tag(b"other x\n".as_slice());
838 claim.apply_to_tag(&mut tag);
839 assert!(
840 String::from_utf8_lossy(&tag.extra_headers).starts_with("tree-sha256-excluded a/mod"),
841 "claim must follow tagger, not unrelated headers: {:?}",
842 String::from_utf8_lossy(&tag.extra_headers)
843 );
844 assert!(
845 String::from_utf8_lossy(&tag.extra_headers).contains("other x\n"),
846 "unrelated headers must remain after the claim block"
847 );
848 let parsed = TreeSha256Claim::from_tag(&tag).unwrap().unwrap();
849 assert_eq!(parsed.digest, digest);
850 let mut want = claim.excluded.clone();
851 want.sort();
852 assert_eq!(parsed.excluded, want);
853 let bytes = serialize_tag(&tag);
854 let round = parse_tag(&bytes).unwrap();
855 assert_eq!(TreeSha256Claim::from_tag(&round).unwrap(), Some(parsed));
856 }
857
858 #[test]
859 fn claim_layout_matches_reference_tool_bytes() {
860 let fixture = include_bytes!("../tests/fixtures/tree_sha256/reference_tool_tag.txt");
861 let parsed = parse_tag(fixture).expect("parse fixture");
862 let claim = TreeSha256Claim::from_tag(&parsed)
863 .expect("from_tag")
864 .expect("claim");
865 let mut rebuilt = TagData {
866 extra_headers: Vec::new(),
867 ..parsed.clone()
868 };
869 claim.apply_to_tag(&mut rebuilt);
870 assert_eq!(serialize_tag(&rebuilt), fixture);
871 }
872
873 #[test]
874 fn apply_replaces_existing_claim() {
875 let digest = TreeSha256::from_str(
876 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
877 )
878 .unwrap();
879 let old = TreeSha256Claim {
880 digest,
881 excluded: vec!["old/path".into()],
882 };
883 let new = TreeSha256Claim {
884 digest: TreeSha256::from_str(
885 "c29d81cdc535c4804153b7026e6b276b2dbab25d636341f6cab4179e2b1fd47a",
886 )
887 .unwrap(),
888 excluded: vec![],
889 };
890 let mut tag = sample_tag(b"");
891 old.apply_to_tag(&mut tag);
892 new.apply_to_tag(&mut tag);
893 let claim = TreeSha256Claim::from_tag(&tag).unwrap().unwrap();
894 assert_eq!(claim, new);
895 assert!(
896 !String::from_utf8_lossy(&tag.extra_headers).contains("old/path"),
897 "previous excluded path must be gone"
898 );
899 }
900
901 #[test]
902 fn duplicate_header_rejected() {
903 let extra =
904 b"tree-sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
905tree-sha256 c29d81cdc535c4804153b7026e6b276b2dbab25d636341f6cab4179e2b1fd47a\n";
906 let tag = sample_tag(extra);
907 assert!(matches!(
908 TreeSha256Claim::from_tag(&tag),
909 Err(Error::TreeSha256Claim(
910 TreeSha256ClaimError::DuplicateDigestHeader
911 ))
912 ));
913 }
914
915 #[test]
916 fn bad_hex_rejected() {
917 let extra = b"tree-sha256 not_valid_hex\n";
918 let tag = sample_tag(extra);
919 assert!(matches!(
920 TreeSha256Claim::from_tag(&tag),
921 Err(Error::TreeSha256InvalidHex)
922 ));
923 }
924
925 #[test]
926 fn digest_header_whitespace_rejected() {
927 let hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
928 for extra in [
929 format!("tree-sha256 {hex} \n"),
930 format!("tree-sha256 {hex}\n"),
931 format!("tree-sha256 {hex}x\n"),
932 ] {
933 let tag = sample_tag(extra.as_bytes());
934 assert!(
935 matches!(
936 TreeSha256Claim::from_tag(&tag),
937 Err(Error::TreeSha256InvalidHex)
938 ),
939 "must reject {extra:?}"
940 );
941 }
942 }
943
944 #[test]
945 fn apply_places_claim_immediately_after_tagger_byte_order() {
946 let digest = TreeSha256::from_str(
947 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
948 )
949 .unwrap();
950 let claim = TreeSha256Claim {
951 digest,
952 excluded: vec!["sub/mod".into()],
953 };
954 let mut tag = sample_tag(b"other existing\n");
955 claim.apply_to_tag(&mut tag);
956 let want_prefix = "tree-sha256-excluded sub/mod\n\
957tree-sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
958other existing\n";
959 assert_eq!(
960 String::from_utf8_lossy(&tag.extra_headers),
961 want_prefix,
962 "serialized extra headers must not place unrelated lines before the claim"
963 );
964 let bytes = serialize_tag(&tag);
965 let tagger_end = b"tagger T <t@example.com> 1 +0000\n";
966 let pos = bytes
967 .windows(tagger_end.len())
968 .position(|w| w == tagger_end)
969 .expect("tagger line");
970 let after_tagger = &bytes[pos + tagger_end.len()..];
971 assert!(
972 after_tagger.starts_with(want_prefix.as_bytes()),
973 "full tag bytes must place claim immediately after tagger"
974 );
975 }
976
977 #[test]
978 fn no_claim_returns_none() {
979 let tag = sample_tag(b"change-id abc\n");
980 assert_eq!(TreeSha256Claim::from_tag(&tag).unwrap(), None);
981 }
982
983 #[test]
984 fn invalid_excluded_path_rejected() {
985 let digest_line =
986 b"tree-sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n";
987 let cases: Vec<Vec<u8>> = vec![
988 b"tree-sha256-excluded \n".to_vec(),
989 b"tree-sha256-excluded bad\n more\n".to_vec(),
990 b"tree-sha256-excluded nul\0here\n".to_vec(),
991 ];
992 for extra in &cases {
993 let mut blob = extra.clone();
994 blob.extend_from_slice(digest_line);
995 let tag = sample_tag(&blob);
996 assert!(matches!(
997 TreeSha256Claim::from_tag(&tag),
998 Err(Error::TreeSha256Claim(
999 TreeSha256ClaimError::InvalidExcludedPath
1000 ))
1001 ));
1002 }
1003 }
1004
1005 #[test]
1006 fn header_constants_match_reference_tool() {
1007 assert_eq!(TREE_SHA256_HEADER, "tree-sha256");
1008 assert_eq!(TREE_SHA256_EXCLUDED_HEADER, "tree-sha256-excluded");
1009 }
1010}