1use std::collections::BTreeMap;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use super::{Attribution, ChangeId, ContentHash, Principal, StateId};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
16pub enum Status {
17 #[default]
18 Draft,
19 Published,
20}
21
22impl Status {
23 pub fn to_byte(&self) -> u8 {
24 match self {
25 Status::Draft => 0,
26 Status::Published => 1,
27 }
28 }
29
30 pub fn from_byte(b: u8) -> Option<Self> {
31 match b {
32 0 => Some(Status::Draft),
33 1 => Some(Status::Published),
34 _ => None,
35 }
36 }
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub enum ChangeLineageKind {
41 CherryPick,
42 Collapse,
43 Revert,
44 GitProjection,
45}
46
47impl ChangeLineageKind {
48 fn to_byte(self) -> u8 {
49 match self {
50 Self::CherryPick => 1,
51 Self::Collapse => 2,
52 Self::Revert => 3,
53 Self::GitProjection => 4,
54 }
55 }
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ChangeLineage {
60 pub kind: ChangeLineageKind,
61 pub source_change: ChangeId,
62 pub source_state: StateId,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct StateSignature {
70 pub algorithm: String,
71 pub public_key: String,
72 pub signature: String,
73}
74
75impl StateSignature {
76 pub fn algorithm(&self) -> &str {
77 &self.algorithm
78 }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum SignatureStatus {
84 Valid,
85 Invalid,
86 Unsigned,
87}
88
89impl SignatureStatus {
90 pub fn is_valid(self) -> bool {
91 self == SignatureStatus::Valid
92 }
93
94 pub fn is_unsigned(self) -> bool {
95 self == SignatureStatus::Unsigned
96 }
97}
98
99#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
103pub struct Verification {
104 pub tests_passed: Option<bool>,
105 pub tests_failed: Option<u32>,
106 pub coverage_pct: Option<f32>,
107 pub coverage_delta: Option<f32>,
108 pub lint_warnings: Option<u32>,
109 #[serde(default)]
110 pub custom: BTreeMap<String, serde_json::Value>,
111}
112
113impl Verification {
114 pub fn new() -> Self {
115 Self::default()
116 }
117
118 pub fn with_tests_passed(mut self, passed: bool) -> Self {
119 self.tests_passed = Some(passed);
120 self
121 }
122
123 pub fn with_tests_failed(mut self, failed: u32) -> Self {
124 self.tests_failed = Some(failed);
125 self
126 }
127
128 pub fn is_empty(&self) -> bool {
129 self.tests_passed.is_none()
130 && self.tests_failed.is_none()
131 && self.coverage_pct.is_none()
132 && self.coverage_delta.is_none()
133 && self.lint_warnings.is_none()
134 && self.custom.is_empty()
135 }
136
137 pub(crate) fn hash_len(&self) -> usize {
138 let mut len = 0;
139 len += 1 + self.tests_passed.map(|_| 1).unwrap_or(0);
140 len += 1 + self.tests_failed.map(|_| 4).unwrap_or(0);
141 len += 1 + self.coverage_pct.map(|_| 4).unwrap_or(0);
142 len += 1 + self.coverage_delta.map(|_| 4).unwrap_or(0);
143 len += 1 + self.lint_warnings.map(|_| 4).unwrap_or(0);
144 len += 4;
145 for (key, value) in &self.custom {
146 let value_bytes = serde_json::to_vec(value).unwrap_or_default();
147 len += 4 + key.len();
148 len += 4 + value_bytes.len();
149 }
150 len
151 }
152
153 pub(crate) fn update_hasher(&self, hasher: &mut blake3::Hasher) {
154 let tests_passed = self.tests_passed.map(u8::from);
155 write_optional_u8(hasher, tests_passed);
156 write_optional_u32(hasher, self.tests_failed);
157 write_optional_f32(hasher, self.coverage_pct);
158 write_optional_f32(hasher, self.coverage_delta);
159 write_optional_u32(hasher, self.lint_warnings);
160 let custom_len = self.custom.len() as u32;
161 hasher.update(&custom_len.to_le_bytes());
162 for (key, value) in &self.custom {
163 let key_bytes = key.as_bytes();
164 let value_bytes = serde_json::to_vec(value).unwrap_or_default();
165 hasher.update(&(key_bytes.len() as u32).to_le_bytes());
166 hasher.update(key_bytes);
167 hasher.update(&(value_bytes.len() as u32).to_le_bytes());
168 hasher.update(&value_bytes);
169 }
170 }
171}
172
173fn write_optional_u8(hasher: &mut blake3::Hasher, value: Option<u8>) {
174 match value {
175 Some(v) => {
176 hasher.update(&[1]);
177 hasher.update(&[v]);
178 }
179 None => {
180 hasher.update(&[0]);
181 }
182 }
183}
184
185fn write_optional_u32(hasher: &mut blake3::Hasher, value: Option<u32>) {
186 match value {
187 Some(v) => {
188 hasher.update(&[1]);
189 hasher.update(&v.to_le_bytes());
190 }
191 None => {
192 hasher.update(&[0]);
193 }
194 }
195}
196
197fn write_optional_f32(hasher: &mut blake3::Hasher, value: Option<f32>) {
198 match value {
199 Some(v) => {
200 hasher.update(&[1]);
201 hasher.update(&v.to_le_bytes());
202 }
203 None => {
204 hasher.update(&[0]);
205 }
206 }
207}
208
209#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
214pub struct State {
215 #[serde(skip)]
216 pub state_id: StateId,
217 pub change_id: ChangeId,
218 pub tree: ContentHash,
219 pub parents: Vec<StateId>,
220 pub attribution: Attribution,
221 pub intent: Option<String>,
222 pub confidence: Option<f32>,
223 pub created_at: DateTime<Utc>,
224 pub verification: Option<Verification>,
225 pub status: Status,
226 #[serde(default)]
228 pub provenance: Option<ContentHash>,
229 #[serde(default)]
247 pub authored_at: Option<DateTime<Utc>>,
248 #[serde(default)]
265 pub committer: Option<Principal>,
266 #[serde(default)]
271 pub authored_tz_offset: i32,
272 #[serde(default)]
275 pub committer_tz_offset: i32,
276 #[serde(default)]
288 pub raw_message: Option<Vec<u8>>,
289 #[serde(default)]
304 pub git_lossy: bool,
305 #[serde(default)]
324 pub extra_headers: Vec<(Vec<u8>, Vec<u8>)>,
325 pub lineage: Vec<ChangeLineage>,
326}
327
328impl State {
329 pub fn new(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
330 Self::new_snapshot(tree, parents, attribution)
331 }
332
333 pub fn new_snapshot(
334 tree: ContentHash,
335 parents: Vec<StateId>,
336 attribution: Attribution,
337 ) -> Self {
338 Self::new_with_change_id(tree, parents, attribution, ChangeId::generate())
339 }
340
341 pub fn new_merge(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
342 Self::new_snapshot(tree, parents, attribution)
343 }
344
345 pub fn new_refresh_of(
346 tree: ContentHash,
347 parents: Vec<StateId>,
348 attribution: Attribution,
349 change_id: ChangeId,
350 ) -> Self {
351 Self::new_with_change_id(tree, parents, attribution, change_id)
352 }
353
354 pub fn new_fork_of(tree: ContentHash, parents: Vec<StateId>, attribution: Attribution) -> Self {
355 Self::new_snapshot(tree, parents, attribution)
356 }
357
358 pub fn new_collapse_of(
359 tree: ContentHash,
360 parents: Vec<StateId>,
361 attribution: Attribution,
362 ) -> Self {
363 Self::new_snapshot(tree, parents, attribution)
364 }
365
366 fn new_with_change_id(
367 tree: ContentHash,
368 parents: Vec<StateId>,
369 attribution: Attribution,
370 change_id: ChangeId,
371 ) -> Self {
372 let mut state = Self {
373 state_id: StateId::default(),
374 change_id,
375 tree,
376 parents,
377 attribution,
378 intent: None,
379 confidence: None,
380 created_at: Utc::now(),
381 verification: None,
382 provenance: None,
383 authored_at: None,
384 committer: None,
385 authored_tz_offset: 0,
386 committer_tz_offset: 0,
387 raw_message: None,
388 git_lossy: false,
389 extra_headers: Vec::new(),
390 lineage: Vec::new(),
391 status: Status::Draft,
392 };
393 state.refresh_state_id();
394 state
395 }
396
397 pub fn with_intent(mut self, intent: impl Into<String>) -> Self {
398 self.intent = Some(intent.into());
399 self.refresh_state_id();
400 self
401 }
402
403 pub fn with_confidence(mut self, confidence: f32) -> Self {
404 self.confidence = Some(confidence.clamp(0.0, 1.0));
405 self.refresh_state_id();
406 self
407 }
408
409 pub fn with_verification(mut self, verification: Verification) -> Self {
410 self.verification = Some(verification);
411 self.refresh_state_id();
412 self
413 }
414
415 pub fn with_provenance(mut self, provenance: ContentHash) -> Self {
416 self.provenance = Some(provenance);
417 self.refresh_state_id();
418 self
419 }
420
421 pub fn with_authored_at(mut self, timestamp: DateTime<Utc>) -> Self {
432 self.authored_at = Some(timestamp);
433 self.refresh_state_id();
434 self
435 }
436
437 pub fn with_committer(mut self, committer: Principal) -> Self {
442 self.committer = Some(committer);
443 self.refresh_state_id();
444 self
445 }
446
447 pub fn with_tz_offsets(mut self, authored: i32, committer: i32) -> Self {
450 self.authored_tz_offset = authored;
451 self.committer_tz_offset = committer;
452 self.refresh_state_id();
453 self
454 }
455
456 pub fn with_raw_message(mut self, raw_message: impl AsRef<[u8]>) -> Self {
460 self.raw_message = Some(raw_message.as_ref().to_vec());
461 self.refresh_state_id();
462 self
463 }
464
465 pub fn with_git_lossy(mut self, git_lossy: bool) -> Self {
471 self.git_lossy = git_lossy;
472 self.refresh_state_id();
473 self
474 }
475
476 pub fn with_extra_headers(mut self, extra_headers: Vec<(Vec<u8>, Vec<u8>)>) -> Self {
480 self.extra_headers = extra_headers;
481 self.refresh_state_id();
482 self
483 }
484
485 pub fn with_lineage(mut self, lineage: Vec<ChangeLineage>) -> Self {
486 self.lineage = lineage;
487 self.refresh_state_id();
488 self
489 }
490
491 pub fn with_status(mut self, status: Status) -> Self {
492 self.status = status;
493 self.refresh_state_id();
494 self
495 }
496
497 pub fn with_change_id(mut self, change_id: ChangeId) -> Self {
498 self.change_id = change_id;
499 self.refresh_state_id();
500 self
501 }
502
503 pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
504 self.created_at = timestamp;
505 self.refresh_state_id();
506 self
507 }
508
509 pub fn compute_hash(&self) -> ContentHash {
510 let content_len = self.hash_len();
511 ContentHash::compute_typed_with_len("state", content_len, |hasher| {
512 self.update_hash(hasher);
513 })
514 }
515
516 pub fn hash(&mut self) -> ContentHash {
517 self.refresh_state_id();
518 self.state_id.as_content_hash()
519 }
520
521 pub fn id(&self) -> StateId {
522 StateId::from_content_hash(self.compute_hash())
523 }
524
525 pub fn is_root(&self) -> bool {
526 self.parents.is_empty()
527 }
528
529 pub fn is_merge(&self) -> bool {
530 self.parents.len() > 1
531 }
532
533 pub fn is_agent_authored(&self) -> bool {
534 self.attribution.agent.is_some()
535 }
536
537 pub fn first_parent(&self) -> Option<&StateId> {
538 self.parents.first()
539 }
540
541 fn hash_len(&self) -> u64 {
542 self.hash_len_core() + self.hash_len_fidelity()
543 }
544
545 fn hash_len_core(&self) -> u64 {
547 let principal = &self.attribution.principal;
548 let mut len = 0u64;
549
550 len += 16;
551
552 len += self.tree.as_bytes().len() as u64;
553 len += 4;
554 len += (self.parents.len() * 32) as u64;
555
556 len += principal.name.len() as u64 + 1;
557 len += principal.email.len() as u64 + 1;
558
559 len += 1;
560 if let Some(agent) = &self.attribution.agent {
561 len += agent.provider.len() as u64 + 1;
562 len += agent.model.len() as u64 + 1;
563
564 len += 1;
565 if let Some(session_id) = &agent.session_id {
566 len += session_id.len() as u64 + 1;
567 }
568
569 len += 1;
570 if let Some(policy_id) = &agent.policy_id {
571 len += policy_id.len() as u64 + 1;
572 }
573 }
574
575 len += 1;
576 if let Some(intent) = &self.intent {
577 len += intent.len() as u64 + 1;
578 }
579
580 len += 1;
581 if self.confidence.is_some() {
582 len += 4;
583 }
584
585 len += 8;
586
587 len += 1;
588 if let Some(verification) = &self.verification {
589 len += verification.hash_len() as u64;
590 }
591
592 len += 1;
593 if self.provenance.is_some() {
594 len += 32;
595 }
596
597 len += 1;
598
599 len
600 }
601
602 fn hash_len_fidelity(&self) -> u64 {
607 let mut len = 0u64;
608
609 len += 1;
612 if let Some(committer) = &self.committer {
613 len += committer.name.len() as u64 + 1;
614 len += committer.email.len() as u64 + 1;
615 }
616 len += 4;
618 len += 4;
619 len += 1;
621 if self.authored_at.is_some() {
622 len += 8;
623 }
624 len += 1;
628 if let Some(raw_message) = &self.raw_message {
629 len += 4 + raw_message.len() as u64;
630 }
631 len += 4;
634 for (key, value) in &self.extra_headers {
635 len += 4 + key.len() as u64;
636 len += 4 + value.len() as u64;
637 }
638 len += 4 + (self.lineage.len() as u64 * 49);
639
640 len
641 }
642
643 fn update_hash(&self, hasher: &mut blake3::Hasher) {
644 self.update_hash_core(hasher);
645 self.update_hash_fidelity(hasher);
646 }
647
648 fn update_hash_core(&self, hasher: &mut blake3::Hasher) {
652 let principal = &self.attribution.principal;
653
654 hasher.update(self.change_id.as_bytes());
655
656 hasher.update(self.tree.as_bytes());
657 hasher.update(&(self.parents.len() as u32).to_le_bytes());
658 for parent in &self.parents {
659 hasher.update(parent.as_bytes());
660 }
661
662 hasher.update(principal.name.as_bytes());
663 hasher.update(&[0]);
664 hasher.update(principal.email.as_bytes());
665 hasher.update(&[0]);
666
667 if let Some(agent) = &self.attribution.agent {
668 hasher.update(&[1]);
669 hasher.update(agent.provider.as_bytes());
670 hasher.update(&[0]);
671 hasher.update(agent.model.as_bytes());
672 hasher.update(&[0]);
673 write_optional_string(hasher, &agent.session_id);
674 write_optional_string(hasher, &agent.segment_id);
675 write_optional_string(hasher, &agent.policy_id);
676 } else {
677 hasher.update(&[0]);
678 }
679
680 write_optional_string(hasher, &self.intent);
681
682 if let Some(confidence) = self.confidence {
683 hasher.update(&[1]);
684 hasher.update(&confidence.to_le_bytes());
685 } else {
686 hasher.update(&[0]);
687 }
688
689 hasher.update(&self.created_at.timestamp().to_le_bytes());
690
691 if let Some(verification) = &self.verification {
692 hasher.update(&[1]);
693 verification.update_hasher(hasher);
694 } else {
695 hasher.update(&[0]);
696 }
697
698 if let Some(provenance) = self.provenance {
699 hasher.update(&[1]);
700 hasher.update(provenance.as_bytes());
701 } else {
702 hasher.update(&[0]);
703 }
704
705 hasher.update(&[self.status.to_byte()]);
706 }
707
708 fn update_hash_fidelity(&self, hasher: &mut blake3::Hasher) {
722 if let Some(committer) = &self.committer {
723 hasher.update(&[1]);
724 hasher.update(committer.name.as_bytes());
725 hasher.update(&[0]);
726 hasher.update(committer.email.as_bytes());
727 hasher.update(&[0]);
728 } else {
729 hasher.update(&[0]);
730 }
731
732 hasher.update(&self.authored_tz_offset.to_le_bytes());
733 hasher.update(&self.committer_tz_offset.to_le_bytes());
734
735 if let Some(authored_at) = self.authored_at {
738 hasher.update(&[1]);
739 hasher.update(&authored_at.timestamp().to_le_bytes());
740 } else {
741 hasher.update(&[0]);
742 }
743
744 write_optional_bytes(hasher, &self.raw_message);
745
746 hasher.update(&(self.extra_headers.len() as u32).to_le_bytes());
748 for (key, value) in &self.extra_headers {
749 hasher.update(&(key.len() as u32).to_le_bytes());
750 hasher.update(key);
751 hasher.update(&(value.len() as u32).to_le_bytes());
752 hasher.update(value);
753 }
754 hasher.update(&(self.lineage.len() as u32).to_le_bytes());
755 for lineage in &self.lineage {
756 hasher.update(&[lineage.kind.to_byte()]);
757 hasher.update(lineage.source_change.as_bytes());
758 hasher.update(lineage.source_state.as_bytes());
759 }
760 }
761
762 fn refresh_state_id(&mut self) {
763 self.state_id = StateId::from_content_hash(self.compute_hash());
764 }
765}
766
767fn write_optional_bytes(hasher: &mut blake3::Hasher, value: &Option<Vec<u8>>) {
773 match value {
774 Some(bytes) => {
775 hasher.update(&[1]);
776 hasher.update(&(bytes.len() as u32).to_le_bytes());
777 hasher.update(bytes);
778 }
779 None => {
780 hasher.update(&[0]);
781 }
782 }
783}
784
785fn write_optional_string(hasher: &mut blake3::Hasher, value: &Option<String>) {
786 match value {
787 Some(value) => {
788 hasher.update(&[1]);
789 hasher.update(value.as_bytes());
790 hasher.update(&[0]);
791 }
792 None => {
793 hasher.update(&[0]);
794 }
795 }
796}
797
798pub fn parse_commit_extension_headers(commit_content: &[u8]) -> Vec<(Vec<u8>, Vec<u8>)> {
827 let header_block = match find_subslice(commit_content, b"\n\n") {
831 Some(idx) => &commit_content[..idx],
832 None => commit_content,
834 };
835
836 let mut headers: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
839 for line in header_block.split(|&b| b == b'\n') {
840 if line.first() == Some(&b' ') {
841 if let Some((_, value)) = headers.last_mut() {
844 value.push(b'\n');
845 value.extend_from_slice(&line[1..]);
846 }
847 continue;
850 }
851 let (name, value) = match line.iter().position(|&b| b == b' ') {
855 Some(sp) => (line[..sp].to_vec(), line[sp + 1..].to_vec()),
856 None => (line.to_vec(), Vec::new()),
857 };
858 headers.push((name, value));
859 }
860
861 match headers.iter().position(|(name, _)| name == b"committer") {
866 Some(idx) => headers.split_off(idx + 1),
867 None => headers
868 .into_iter()
869 .filter(|(name, _)| {
870 !matches!(
871 name.as_slice(),
872 b"tree" | b"parent" | b"author" | b"committer"
873 )
874 })
875 .collect(),
876 }
877}
878
879fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
881 if needle.is_empty() || needle.len() > haystack.len() {
882 return None;
883 }
884 haystack.windows(needle.len()).position(|w| w == needle)
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890 use crate::object::Principal;
891
892 fn sample_attribution() -> Attribution {
893 Attribution::human(Principal::new("Alice", "alice@example.com"))
894 }
895
896 #[test]
897 fn new_snapshot_sets_fresh_logical_identity() {
898 let state =
899 State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution());
900 assert!(!state.change_id.is_zero());
901 assert_eq!(state.state_id, state.id());
902 }
903
904 #[test]
905 fn new_refresh_preserves_explicit_logical_identity() {
906 let logical_change_id = ChangeId::from_bytes([7; 16]);
907 let state = State::new_refresh_of(
908 ContentHash::compute(b"tree"),
909 vec![],
910 sample_attribution(),
911 logical_change_id,
912 );
913 assert_eq!(state.change_id, logical_change_id);
914 }
915
916 #[test]
917 fn new_merge_uses_fresh_logical_identity() {
918 let state = State::new_merge(
919 ContentHash::compute(b"tree"),
920 vec![StateId::from_bytes([1; 32]), StateId::from_bytes([2; 32])],
921 sample_attribution(),
922 );
923 assert!(!state.change_id.is_zero());
924 assert!(state.is_merge());
925 }
926
927 #[test]
928 fn with_change_id_invalidates_cached_hash_when_logical_identity_changes() {
929 let mut state =
930 State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution());
931 let original_hash = state.hash();
932 let replacement = ChangeId::from_bytes([9; 16]);
933
934 let mut updated = state.with_change_id(replacement);
935
936 assert_eq!(updated.change_id, replacement);
937 assert_ne!(updated.hash(), original_hash);
938 assert_eq!(updated.hash(), updated.compute_hash());
939 }
940
941 #[test]
942 fn agent_segment_is_part_of_state_hash() {
943 let principal = Principal::new("Alice", "alice@example.com");
944 let attribution_a = Attribution::with_agent(
945 principal.clone(),
946 crate::object::Agent::new("openai", "gpt-5").with_session("sess-1", "seg-1"),
947 );
948 let attribution_b = Attribution::with_agent(
949 principal,
950 crate::object::Agent::new("openai", "gpt-5").with_session("sess-1", "seg-2"),
951 );
952 let tree = ContentHash::compute(b"tree");
953 let timestamp = Utc::now();
954 let logical_change_id = ChangeId::from_bytes([3; 16]);
955 let state_a = State::new_snapshot(tree, vec![], attribution_a)
956 .with_change_id(logical_change_id)
957 .with_timestamp(timestamp);
958 let state_b = State::new_snapshot(tree, vec![], attribution_b)
959 .with_change_id(logical_change_id)
960 .with_timestamp(timestamp);
961
962 assert_ne!(state_a.compute_hash(), state_b.compute_hash());
963 }
964
965 fn sample_state() -> State {
966 State::new_snapshot(ContentHash::compute(b"tree"), vec![], sample_attribution())
967 }
968
969 fn assert_mutator_invalidates_cached_hash(
970 mut state: State,
971 mutate: impl FnOnce(State) -> State,
972 ) {
973 let original_hash = state.hash();
974 let mut updated = mutate(state);
975 assert_ne!(updated.hash(), original_hash);
976 assert_eq!(updated.hash(), updated.compute_hash());
977 }
978
979 #[test]
980 fn with_intent_invalidates_cached_hash() {
981 assert_mutator_invalidates_cached_hash(sample_state(), |state| {
982 state.with_intent("capture intent")
983 });
984 }
985
986 #[test]
987 fn with_confidence_invalidates_cached_hash() {
988 assert_mutator_invalidates_cached_hash(sample_state(), |state| state.with_confidence(0.9));
989 }
990
991 #[test]
992 fn with_verification_invalidates_cached_hash() {
993 assert_mutator_invalidates_cached_hash(sample_state(), |state| {
994 state.with_verification(Verification::new().with_tests_passed(true))
995 });
996 }
997
998 #[test]
999 fn with_status_invalidates_cached_hash() {
1000 assert_mutator_invalidates_cached_hash(sample_state(), |state| {
1001 state.with_status(Status::Published)
1002 });
1003 }
1004
1005 #[test]
1006 fn with_timestamp_invalidates_cached_hash() {
1007 assert_mutator_invalidates_cached_hash(sample_state(), |state| {
1008 state.with_timestamp(Utc::now() + chrono::Duration::seconds(1))
1009 });
1010 }
1011
1012 #[test]
1016 fn fidelity_fields_are_part_of_state_hash() {
1017 let base = sample_state();
1018 let base_hash = base.compute_hash();
1019
1020 let with_committer = sample_state().with_change_id(base.change_id);
1021 let mut with_committer =
1022 with_committer.with_committer(Principal::new("Carol", "carol@example.com"));
1023 with_committer.created_at = base.created_at;
1024 assert_ne!(
1025 with_committer.hash(),
1026 base_hash,
1027 "committer must affect the state hash"
1028 );
1029
1030 for mutate in [
1031 |s: State| s.with_tz_offsets(3600, -7200),
1032 |s: State| s.with_authored_at(Utc::now() + chrono::Duration::seconds(1)),
1033 |s: State| s.with_raw_message("verbatim body\n"),
1034 |s: State| {
1036 s.with_extra_headers(vec![(
1037 b"gpgsig".to_vec(),
1038 b"-----BEGIN PGP SIGNATURE-----\n".to_vec(),
1039 )])
1040 },
1041 |s: State| s.with_extra_headers(vec![(b"mergetag".to_vec(), b"x".to_vec())]),
1042 ] {
1043 let seeded = sample_state().with_change_id(base.change_id);
1044 let mut decorated = mutate(seeded);
1045 decorated.created_at = base.created_at;
1046 assert_ne!(
1047 decorated.hash(),
1048 base_hash,
1049 "fidelity field must affect the state hash"
1050 );
1051 }
1052 }
1053
1054 #[test]
1057 fn extra_headers_order_affects_hash() {
1058 let base = sample_state();
1059 let one = sample_state().with_change_id(base.change_id);
1060 let mut one = one.with_extra_headers(vec![
1061 (b"a".to_vec(), b"1".to_vec()),
1062 (b"b".to_vec(), b"2".to_vec()),
1063 ]);
1064 one.created_at = base.created_at;
1065
1066 let two = sample_state().with_change_id(base.change_id);
1067 let mut two = two.with_extra_headers(vec![
1068 (b"b".to_vec(), b"2".to_vec()),
1069 (b"a".to_vec(), b"1".to_vec()),
1070 ]);
1071 two.created_at = base.created_at;
1072
1073 assert_ne!(one.hash(), two.hash());
1074 }
1075
1076 #[test]
1080 fn fidelity_fields_hash_is_stable() {
1081 let mut state = sample_state()
1082 .with_committer(Principal::new("Dave", "dave@example.com"))
1083 .with_tz_offsets(3600, 0)
1084 .with_authored_at(Utc::now())
1085 .with_raw_message("body\n")
1086 .with_extra_headers(vec![
1087 (b"gpgsig".to_vec(), b"sig".to_vec()),
1088 (b"k".to_vec(), b"v".to_vec()),
1089 ]);
1090 assert_eq!(state.hash(), state.compute_hash());
1091 }
1092
1093 #[test]
1098 fn non_utf8_raw_message_is_byte_preserved() {
1099 let raw = b"caf\xe9\n".to_vec();
1100 assert!(
1101 String::from_utf8(raw.clone()).is_err(),
1102 "test fixture must be invalid UTF-8 to be meaningful"
1103 );
1104 let mut state = sample_state().with_raw_message(&raw);
1105 assert_eq!(
1106 state.raw_message.as_deref(),
1107 Some(raw.as_slice()),
1108 "raw bytes preserved verbatim"
1109 );
1110 let bytes = rmp_serde::to_vec(&state).expect("serialize state");
1113 let back: State = rmp_serde::from_slice(&bytes).expect("deserialize state");
1114 assert_eq!(back.raw_message.as_deref(), Some(raw.as_slice()));
1115 let mut back = back;
1116 assert_eq!(state.hash(), back.hash());
1117 assert_eq!(back.hash(), back.compute_hash());
1118 }
1119
1120 #[test]
1124 fn raw_message_with_nul_byte_changes_hash() {
1125 let base = sample_state();
1126 let with_nul = sample_state().with_change_id(base.change_id);
1127 let mut a = with_nul.with_raw_message(b"a\x00b");
1128 a.created_at = base.created_at;
1129
1130 let other = sample_state().with_change_id(base.change_id);
1131 let mut b = other.with_raw_message(b"a\x00c");
1132 b.created_at = base.created_at;
1133
1134 assert_ne!(a.hash(), b.hash());
1135 }
1136
1137 #[test]
1145 fn parse_extension_headers_preserves_noncanonical_wire_order() {
1146 let lines: &[&[u8]] = &[
1153 b"tree 1111111111111111111111111111111111111111",
1154 b"parent 2222222222222222222222222222222222222222",
1155 b"author Alice <alice@example.com> 1700000000 +0000",
1156 b"committer Bob <bob@example.com> 1700000100 +0000",
1157 b"x-custom custom value",
1158 b"gpgsig -----BEGIN PGP SIGNATURE-----",
1159 b" sig-line-1",
1160 b" -----END PGP SIGNATURE-----",
1161 b"encoding ISO-8859-1",
1162 b"mergetag object 3333333333333333333333333333333333333333",
1163 b" type commit",
1164 b" tag sidetag",
1165 b" tagger Carol <carol@example.com> 1700000050 +0000",
1166 b" ", b" signed side tag",
1168 b"", b"the commit message",
1170 b"",
1171 ];
1172 let content = lines.join(&b'\n');
1173
1174 let headers = parse_commit_extension_headers(&content);
1175
1176 let expected: Vec<(Vec<u8>, Vec<u8>)> = vec![
1177 (b"x-custom".to_vec(), b"custom value".to_vec()),
1178 (
1179 b"gpgsig".to_vec(),
1180 b"-----BEGIN PGP SIGNATURE-----\nsig-line-1\n-----END PGP SIGNATURE-----"
1183 .to_vec(),
1184 ),
1185 (b"encoding".to_vec(), b"ISO-8859-1".to_vec()),
1186 (
1187 b"mergetag".to_vec(),
1188 b"object 3333333333333333333333333333333333333333\ntype commit\ntag sidetag\ntagger Carol <carol@example.com> 1700000050 +0000\n\nsigned side tag".to_vec(),
1191 ),
1192 ];
1193
1194 assert_eq!(headers, expected);
1195 }
1196
1197 #[test]
1201 fn parse_extension_headers_empty_when_only_core_headers() {
1202 let content: &[u8] = b"\
1203tree 1111111111111111111111111111111111111111\n\
1204author Alice <alice@example.com> 1700000000 +0000\n\
1205committer Bob <bob@example.com> 1700000100 +0000\n\
1206\n\
1207just a message\n";
1208 assert!(parse_commit_extension_headers(content).is_empty());
1209 }
1210}