1use crypto::verify_payload_signature;
8use objects::{
9 lock::RepositoryLockExt,
10 object::{
11 Blob, DiffKind, Discussion, DiscussionsBlob, ProducerId, ReviewKind, ReviewScope,
12 ReviewSignature, ReviewSignaturesBlob, RiskSignalBlob, RiskSignalKind, SignalAnchor, State,
13 StateAttachment, StateAttachmentBody, StateId, signing_payload,
14 },
15 store::ObjectStore,
16 worktree::diff_blobs,
17};
18use repo::{Repository, StateAttachmentKind};
19use serde::{Deserialize, Serialize};
20use state_review::{
21 PathSymbol, ReadingOrderPartition, SymbolKind, payload::build_review_payload_partition_owned,
22};
23
24use super::{LocalReviewContext, LocalReviewError, map_repository_error, with_idempotency};
25
26const SIGN_TIMESTAMP_SKEW_SECS: i64 = 5 * 60;
30
31const VERDICT_ENVELOPE_TAG: &str = "\u{1}hd-verdict-v1\u{1}";
34
35const LOCAL_SIGN_REPLAY_VERB: &str = "local.state_review.sign/json-v1";
43
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct ReviewSummary {
46 pub headline: String,
47 pub files_changed: u32,
48 pub added_lines: u32,
49 pub removed_lines: u32,
50 pub in_budget_signal_count: u32,
51 pub hidden_signal_count: u32,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum ReviewSignalKind {
56 DiffSummary,
57 Risk(RiskSignalKind),
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum ReviewSignalVisibility {
62 Visible,
63 Hidden,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct ReviewSignal {
68 pub kind: ReviewSignalKind,
69 pub anchor: SignalAnchor,
70 pub reason: String,
71 pub producer: ProducerId,
72 pub computed_at: Option<i64>,
73 pub visibility: ReviewSignalVisibility,
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct ReviewPayload {
78 pub state_id: StateId,
79 pub summary: ReviewSummary,
80 pub agent_narrative: Option<String>,
81 pub partition: ReadingOrderPartition,
82 pub in_budget_signals: Vec<ReviewSignal>,
83 pub all_signals: Vec<ReviewSignal>,
84 pub tick_budget: u32,
85 pub discussions: Vec<Discussion>,
86 pub signing_kinds: Vec<ReviewKind>,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct StoredReviewSignature {
91 pub id: String,
92 pub signature: ReviewSignature,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct SignReviewRequest {
97 pub state_id: StateId,
98 pub kind: ReviewKind,
99 pub scope: ReviewScope,
100 pub justification: Option<String>,
101 pub algorithm: String,
102 pub public_key: Vec<u8>,
103 pub signature: Vec<u8>,
104 pub signed_at: i64,
105 pub client_operation_id: String,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SignReviewResult {
110 pub signature_id: String,
111 pub state_id: StateId,
112}
113
114#[derive(Clone)]
116pub struct LocalStateReview {
117 inner: LocalReviewContext,
118}
119
120impl LocalStateReview {
121 pub fn new(inner: LocalReviewContext) -> Self {
122 Self { inner }
123 }
124
125 pub fn get_review_payload(
126 &self,
127 state_id: StateId,
128 include_all_signals: bool,
129 ) -> Result<ReviewPayload, LocalReviewError> {
130 self.get_review_payload_from(state_id, include_all_signals, None)
131 }
132
133 pub fn get_review_payload_from(
136 &self,
137 state_id: StateId,
138 include_all_signals: bool,
139 base_state_id: Option<StateId>,
140 ) -> Result<ReviewPayload, LocalReviewError> {
141 let repo = self.inner.repo();
142 let state = repo
143 .store()
144 .get_state(&state_id)
145 .map_err(map_repository_error)?
146 .ok_or_else(|| {
147 LocalReviewError::not_found(format!(
148 "state {} not found",
149 state_id.to_string_full()
150 ))
151 })?;
152
153 let diff_summary = compute_state_diff_summary(repo, &state, base_state_id)
159 .map_err(map_repository_error)?;
160
161 let summary = ReviewSummary {
162 headline: state.intent.clone().unwrap_or_default(),
163 files_changed: diff_summary.files_changed,
164 added_lines: diff_summary.added_lines,
165 removed_lines: diff_summary.removed_lines,
166 in_budget_signal_count: 0,
167 hidden_signal_count: 0,
168 };
169
170 let agent_narrative = if state.attribution.agent.is_some() {
171 state.intent.clone()
172 } else {
173 None
174 };
175
176 let risk_signals = load_persisted_risk_signals(repo, &state.state_id)?;
180 let all_signals = if include_all_signals {
181 risk_signals.clone()
182 } else {
183 Vec::new()
184 };
185
186 let mut in_budget_signals = Vec::new();
195 let summary_reason = format!(
196 "{} files changed (+{}/-{}, {} added, {} modified, {} deleted)",
197 diff_summary.files_changed,
198 diff_summary.added_lines,
199 diff_summary.removed_lines,
200 diff_summary.added_files,
201 diff_summary.modified_files,
202 diff_summary.deleted_files,
203 );
204 const MAX_DIFF_SIGNAL_ANCHORS: usize = 32;
209 if diff_summary.changed_paths.is_empty() {
210 in_budget_signals.push(ReviewSignal {
211 kind: ReviewSignalKind::DiffSummary,
212 anchor: SignalAnchor {
213 file: String::new(),
214 symbol: None,
215 line_range: None,
216 },
217 reason: summary_reason.clone(),
218 producer: ProducerId::new("review_show.diff_summary", 1),
219 computed_at: None,
220 visibility: ReviewSignalVisibility::Visible,
221 });
222 } else {
223 for (idx, path_kind) in diff_summary
224 .changed_paths
225 .iter()
226 .take(MAX_DIFF_SIGNAL_ANCHORS)
227 .enumerate()
228 {
229 let reason = if idx == 0 {
230 summary_reason.clone()
231 } else {
232 format!("{} ({})", path_kind.path, path_kind.kind_str())
233 };
234 in_budget_signals.push(ReviewSignal {
235 kind: ReviewSignalKind::DiffSummary,
236 anchor: SignalAnchor {
237 file: path_kind.path.clone(),
238 symbol: None,
239 line_range: None,
240 },
241 reason,
242 producer: ProducerId::new("review_show.diff_summary", 1),
243 computed_at: None,
244 visibility: ReviewSignalVisibility::Visible,
245 });
246 }
247 }
248 in_budget_signals.extend(risk_signals);
249
250 let symbols = changed_files_as_symbols(repo, &state, &diff_summary.changed_paths)
254 .map_err(map_repository_error)?;
255 let partition = build_review_payload_partition_owned(symbols);
256
257 let discussions =
259 match attachment_hash(repo, &state.state_id, StateAttachmentKind::Discussions)? {
260 Some(hash) => {
261 let blob = repo
262 .store()
263 .get_blob(&hash)
264 .map_err(map_repository_error)?
265 .ok_or_else(|| {
266 LocalReviewError::internal(format!(
267 "discussions blob {} referenced by state {} is missing",
268 hash,
269 state.state_id.to_string_full()
270 ))
271 })?;
272 let decoded = DiscussionsBlob::decode(blob.content()).map_err(|err| {
273 LocalReviewError::internal(format!("decode discussions: {err}"))
274 })?;
275 decoded.discussions
276 }
277 None => Vec::new(),
278 };
279
280 let mut summary = summary;
281 summary.in_budget_signal_count = in_budget_signals.len() as u32;
282 summary.hidden_signal_count =
283 all_signals.len().saturating_sub(in_budget_signals.len()) as u32;
284
285 let payload = ReviewPayload {
286 state_id,
287 summary,
288 agent_narrative,
289 partition,
290 in_budget_signals,
291 all_signals,
292 tick_budget: 3,
293 discussions,
294 signing_kinds: vec![
295 ReviewKind::Read,
296 ReviewKind::AgentPreview,
297 ReviewKind::AgentCoReview,
298 ],
299 };
300
301 Ok(payload)
302 }
303
304 pub async fn sign_state(
305 &self,
306 req: SignReviewRequest,
307 ) -> Result<SignReviewResult, LocalReviewError> {
308 let req_bytes = serde_json::to_vec(&req)
309 .map_err(|error| LocalReviewError::internal(format!("encode sign request: {error}")))?;
310 let client_operation_id = req.client_operation_id.clone();
311 let inner = self.inner.clone();
312
313 let response = with_idempotency(
314 &self.inner,
315 &client_operation_id,
316 LOCAL_SIGN_REPLAY_VERB,
317 &req_bytes,
318 move || {
319 let inner = inner.clone();
320 async move { execute_sign_state(&inner, req).await }
321 },
322 )
323 .await?;
324
325 Ok(response)
326 }
327
328 pub fn list_signatures(
329 &self,
330 state_id: StateId,
331 ) -> Result<Vec<StoredReviewSignature>, LocalReviewError> {
332 let repo = self.inner.repo();
333 let state = repo
334 .store()
335 .get_state(&state_id)
336 .map_err(map_repository_error)?
337 .ok_or_else(|| {
338 LocalReviewError::not_found(format!(
339 "state {} not found",
340 state_id.to_string_full()
341 ))
342 })?;
343
344 let signatures =
345 match attachment_hash(repo, &state.state_id, StateAttachmentKind::ReviewSignatures)? {
346 Some(hash) => {
347 let blob = repo
348 .store()
349 .get_blob(&hash)
350 .map_err(map_repository_error)?
351 .ok_or_else(|| {
352 LocalReviewError::internal(format!(
353 "review signatures blob {} missing from object store",
354 hash
355 ))
356 })?;
357 let decoded = ReviewSignaturesBlob::decode(blob.content()).map_err(|err| {
358 LocalReviewError::internal(format!("decode review signatures: {err}"))
359 })?;
360 decoded
361 .signatures
362 .into_iter()
363 .enumerate()
364 .map(|(idx, sig)| StoredReviewSignature {
365 id: synthetic_signature_id(idx),
366 signature: sig,
367 })
368 .collect()
369 }
370 None => Vec::new(),
371 };
372
373 Ok(signatures)
374 }
375}
376
377async fn execute_sign_state(
380 inner: &LocalReviewContext,
381 req: SignReviewRequest,
382) -> Result<SignReviewResult, LocalReviewError> {
383 let state_id = req.state_id;
384 let repo = inner.repo();
385
386 let actor = repo
394 .get_principal()
395 .map_err(|err| LocalReviewError::internal(format!("resolve caller principal: {err}")))?;
396 if req
397 .justification
398 .as_deref()
399 .is_some_and(|text| text.starts_with(VERDICT_ENVELOPE_TAG))
400 {
401 return Err(LocalReviewError::invalid_argument(
402 "justification must not begin with the reserved verdict-envelope prefix",
403 ));
404 }
405 let justification = req.justification.clone().filter(|text| !text.is_empty());
406
407 let now = chrono::Utc::now().timestamp();
408 let signed_at = req.signed_at;
409 if signed_at == 0 {
410 return Err(LocalReviewError::invalid_argument(
411 "signed_at is required and must match the timestamp the client signed over",
412 ));
413 }
414 if (signed_at - now).abs() > SIGN_TIMESTAMP_SKEW_SECS {
415 return Err(LocalReviewError::invalid_argument(format!(
416 "signed_at={signed_at} is too far from server time={now} (max skew {SIGN_TIMESTAMP_SKEW_SECS}s)"
417 )));
418 }
419
420 let new_sig = ReviewSignature {
421 actor,
422 kind: req.kind,
423 scope: req.scope.clone(),
424 justification: justification.clone(),
425 signed_at,
426 algorithm: req.algorithm.clone(),
427 public_key: hex::encode(&req.public_key),
428 signature: hex::encode(&req.signature),
429 };
430 new_sig.validate().map_err(|err| {
431 LocalReviewError::invalid_argument(format!("invalid review signature: {err}"))
432 })?;
433
434 let public_key_bytes = req.public_key.clone();
435 let signature_bytes = req.signature.clone();
436 let payload = signing_payload(
437 state_id,
438 req.kind,
439 &req.scope,
440 signed_at,
441 justification.as_deref(),
442 );
443 verify_payload_signature(
444 &payload,
445 &req.algorithm,
446 &public_key_bytes,
447 &signature_bytes,
448 )
449 .map_err(|err| {
450 LocalReviewError::invalid_argument(format!(
451 "review signature failed verification ({}): {err}",
452 req.algorithm
453 ))
454 })?;
455
456 let new_index = append_review_signature(repo, state_id, new_sig)?;
457
458 Ok(SignReviewResult {
459 signature_id: synthetic_signature_id(new_index),
460 state_id,
461 })
462}
463
464fn append_review_signature(
466 repo: &Repository,
467 state_id: StateId,
468 signature: ReviewSignature,
469) -> Result<usize, LocalReviewError> {
470 let _lock = repo
471 .locker()
472 .write()
473 .map_err(|err| LocalReviewError::internal(err.to_string()))?;
474 repo.store()
475 .get_state(&state_id)
476 .map_err(map_repository_error)?
477 .ok_or_else(|| {
478 LocalReviewError::not_found(format!("state {} not found", state_id.to_string_full()))
479 })?;
480 let prior = repo
481 .latest_state_attachment(&state_id, StateAttachmentKind::ReviewSignatures)
482 .map_err(map_repository_error)?;
483 let mut blob = match prior.as_ref().map(|attachment| {
484 let StateAttachmentBody::ReviewSignatures(hash) = &attachment.body else {
485 unreachable!()
486 };
487 *hash
488 }) {
489 Some(hash) => {
490 let raw = repo
491 .store()
492 .get_blob(&hash)
493 .map_err(map_repository_error)?
494 .ok_or_else(|| {
495 LocalReviewError::internal(format!(
496 "existing review signatures blob {} missing from object store",
497 hash
498 ))
499 })?;
500 ReviewSignaturesBlob::decode(raw.content()).map_err(|err| {
501 LocalReviewError::internal(format!("decode review signatures: {err}"))
502 })?
503 }
504 None => ReviewSignaturesBlob::new(Vec::new()),
505 };
506 blob.signatures.push(signature);
507 let new_index = blob.signatures.len() - 1;
508
509 let bytes = blob
510 .encode()
511 .map_err(|err| LocalReviewError::internal(format!("encode review signatures: {err}")))?;
512 let content_hash = repo
513 .store()
514 .put_blob(&Blob::new(bytes))
515 .map_err(map_repository_error)?;
516
517 let attachment = StateAttachment {
518 state_id,
519 body: StateAttachmentBody::ReviewSignatures(content_hash),
520 attribution: repo.get_attribution().map_err(map_repository_error)?,
521 created_at: chrono::Utc::now(),
522 supersedes: prior.map(|attachment| attachment.id()),
523 };
524 repo.put_state_attachment(&attachment)
525 .map_err(map_repository_error)?;
526 Ok(new_index)
527}
528
529fn synthetic_signature_id(index: usize) -> String {
533 format!("rs-{index}")
534}
535
536fn load_persisted_risk_signals(
537 repo: &Repository,
538 state_id: &StateId,
539) -> Result<Vec<ReviewSignal>, LocalReviewError> {
540 let Some(hash) = attachment_hash(repo, state_id, StateAttachmentKind::RiskSignals)? else {
541 return Ok(Vec::new());
542 };
543 let Some(blob) = repo.store().get_blob(&hash).map_err(map_repository_error)? else {
544 return Ok(Vec::new());
545 };
546 let decoded = RiskSignalBlob::decode(blob.content())
547 .map_err(|err| LocalReviewError::internal(format!("decode risk signals: {err}")))?;
548 Ok(decoded
549 .signals
550 .into_iter()
551 .map(|signal| review_signal(signal, ReviewSignalVisibility::Visible))
552 .collect())
553}
554
555fn attachment_hash(
556 repo: &Repository,
557 state_id: &StateId,
558 kind: StateAttachmentKind,
559) -> Result<Option<objects::object::ContentHash>, LocalReviewError> {
560 let Some(attachment) = repo
561 .latest_state_attachment(state_id, kind)
562 .map_err(map_repository_error)?
563 else {
564 return Ok(None);
565 };
566 let hash = match attachment.body {
567 StateAttachmentBody::RiskSignals(hash)
568 | StateAttachmentBody::ReviewSignatures(hash)
569 | StateAttachmentBody::Discussions(hash) => hash,
570 _ => unreachable!(),
571 };
572 Ok(Some(hash))
573}
574
575fn review_signal(
576 signal: objects::object::RiskSignal,
577 visibility: ReviewSignalVisibility,
578) -> ReviewSignal {
579 ReviewSignal {
580 kind: ReviewSignalKind::Risk(signal.kind),
581 anchor: signal.anchor,
582 reason: signal.reason,
583 producer: signal.producer,
584 computed_at: Some(signal.computed_at),
585 visibility,
586 }
587}
588
589fn changed_files_as_symbols(
600 repo: &Repository,
601 state: &State,
602 changed_paths: &[ChangedPath],
603) -> objects::error::Result<Vec<PathSymbol>> {
604 let new_tree = match repo.store().get_tree(&state.tree)? {
605 Some(t) => t,
606 None => return Ok(Vec::new()),
607 };
608 let new_files = collect_files(repo, &new_tree, "")?;
609
610 let mut out: Vec<PathSymbol> = Vec::new();
611 for path_kind in changed_paths {
612 let path = &path_kind.path;
613 #[cfg_attr(not(feature = "semantic"), allow(unused_mut))]
614 let mut emitted_any = false;
615 if let Some(hash) = new_files.get(path) {
616 #[cfg(feature = "semantic")]
617 {
618 if let Some(blob) = repo.store().get_blob(hash)? {
619 emitted_any = extract_file_symbols(path, blob.content(), &mut out);
620 }
621 }
622 #[cfg(not(feature = "semantic"))]
623 {
624 let _ = hash;
625 }
626 }
627 if !emitted_any {
628 out.push(PathSymbol {
629 file: path.clone(),
630 symbol: path.clone(),
631 kind: SymbolKind::Other,
632 });
633 }
634 }
635 Ok(out)
636}
637
638#[cfg(feature = "semantic")]
639fn extract_file_symbols(path: &str, source: &[u8], out: &mut Vec<PathSymbol>) -> bool {
640 use ::semantic::symbol_resolver::{Definition, extract_definitions};
641 let definitions: Vec<Definition> = match extract_definitions(source, std::path::Path::new(path))
642 {
643 Ok(defs) => defs,
644 Err(_) => return false,
645 };
646 if definitions.is_empty() {
647 return false;
648 }
649 for d in definitions {
650 let symbol = match d.parent_name.as_deref() {
651 Some(parent) if !parent.is_empty() => format!("{parent}::{}", d.name),
652 _ => d.name,
653 };
654 out.push(PathSymbol {
655 file: path.to_string(),
656 symbol,
657 kind: d.kind,
658 });
659 }
660 true
661}
662
663fn collect_files(
664 repo: &Repository,
665 tree: &objects::object::Tree,
666 prefix: &str,
667) -> objects::error::Result<std::collections::HashMap<String, objects::object::ContentHash>> {
668 let mut out = std::collections::HashMap::new();
669 for entry in tree.entries() {
670 let path = if prefix.is_empty() {
671 entry.name().to_string()
672 } else {
673 format!("{prefix}/{}", entry.name())
674 };
675 if entry.is_tree() {
676 if let Some(hash) = entry.tree_hash()
677 && let Some(subtree) = repo.store().get_tree(&hash)?
678 {
679 let sub = collect_files(repo, &subtree, &path)?;
680 out.extend(sub);
681 }
682 } else if let Some(hash) = entry.content_hash() {
683 out.insert(path, hash);
684 }
685 }
686 Ok(out)
687}
688
689#[derive(Debug, Clone)]
697struct ChangedPath {
698 path: String,
699 kind: DiffKind,
700}
701
702impl ChangedPath {
703 fn kind_str(&self) -> &'static str {
704 match self.kind {
705 DiffKind::Added => "added",
706 DiffKind::Modified => "modified",
707 DiffKind::Deleted => "deleted",
708 DiffKind::Unchanged => "unchanged",
709 }
710 }
711}
712
713struct DiffSummary {
720 files_changed: u32,
721 added_files: u32,
722 modified_files: u32,
723 deleted_files: u32,
724 added_lines: u32,
725 removed_lines: u32,
726 changed_paths: Vec<ChangedPath>,
727}
728
729fn compute_state_diff_summary(
738 repo: &Repository,
739 state: &State,
740 base_state_id: Option<StateId>,
741) -> objects::error::Result<DiffSummary> {
742 use objects::object::Tree;
743 let parent_id = base_state_id.as_ref().or_else(|| state.parents.first());
744 let parent_tree_hash = if let Some(parent_id) = parent_id {
745 match repo.store().get_state(parent_id)? {
746 Some(parent_state) => parent_state.tree,
747 None => Tree::new().hash(),
748 }
749 } else {
750 Tree::new().hash()
751 };
752
753 let parent_tree_obj = repo.store().get_tree(&parent_tree_hash)?;
759 let new_tree_obj = repo.store().get_tree(&state.tree)?;
760
761 let changes = if parent_tree_obj.is_some() && new_tree_obj.is_some() {
767 repo.diff_trees(&parent_tree_hash, &state.tree)?
768 } else {
769 objects::object::FileChangeSet::new()
770 };
771
772 let mut added_lines: u32 = 0;
779 let mut removed_lines: u32 = 0;
780 let mut changed_paths: Vec<ChangedPath> = Vec::with_capacity(changes.len());
781
782 let parent_files = match parent_tree_obj.as_ref() {
783 Some(t) => collect_files(repo, t, "")?,
784 None => std::collections::HashMap::new(),
785 };
786 let new_files = match new_tree_obj.as_ref() {
787 Some(t) => collect_files(repo, t, "")?,
788 None => std::collections::HashMap::new(),
789 };
790
791 let mut added_files: u32 = 0;
792 let mut modified_files: u32 = 0;
793 let mut deleted_files: u32 = 0;
794
795 for change in changes.iter() {
796 match change.kind {
797 DiffKind::Added => {
798 added_files += 1;
799 if let Some(hash) = new_files.get(&change.path)
806 && let Some(blob) = repo.store().get_blob(hash)?
807 {
808 added_lines = added_lines.saturating_add(line_count(blob.content()));
809 }
810 }
811 DiffKind::Deleted => {
812 deleted_files += 1;
813 if let Some(hash) = parent_files.get(&change.path)
814 && let Some(blob) = repo.store().get_blob(hash)?
815 {
816 removed_lines = removed_lines.saturating_add(line_count(blob.content()));
817 }
818 }
819 DiffKind::Modified => {
820 modified_files += 1;
821 let old_blob = match parent_files.get(&change.path) {
828 Some(h) => repo.store().get_blob(h)?,
829 None => None,
830 };
831 let new_blob = match new_files.get(&change.path) {
832 Some(h) => repo.store().get_blob(h)?,
833 None => None,
834 };
835 if let (Some(old), Some(new)) = (old_blob, new_blob) {
836 for line in diff_blobs(&old, &new) {
837 match line {
838 objects::worktree::DiffLine::Added(_) => {
839 added_lines = added_lines.saturating_add(1);
840 }
841 objects::worktree::DiffLine::Removed(_) => {
842 removed_lines = removed_lines.saturating_add(1);
843 }
844 objects::worktree::DiffLine::Context(_) => {}
845 }
846 }
847 }
848 }
849 DiffKind::Unchanged => continue,
850 }
851 changed_paths.push(ChangedPath {
852 path: change.path.clone(),
853 kind: change.kind,
854 });
855 }
856
857 Ok(DiffSummary {
858 files_changed: changed_paths.len() as u32,
859 added_files,
860 modified_files,
861 deleted_files,
862 added_lines,
863 removed_lines,
864 changed_paths,
865 })
866}
867
868fn line_count(content: &[u8]) -> u32 {
873 let Ok(s) = std::str::from_utf8(content) else {
874 return 0;
875 };
876 if s.is_empty() {
877 return 0;
878 }
879 let trimmed = s.strip_suffix('\n').unwrap_or(s);
880 if trimmed.is_empty() {
881 return 1;
882 }
883 (trimmed.matches('\n').count() as u32).saturating_add(1)
884}
885
886#[cfg(test)]
891mod tests {
892 use std::sync::Arc;
893
894 use crypto::Signer as _;
895 use repo::{Repository, operation_dedup::OperationDedupStore};
896 use tempfile::TempDir;
897
898 use super::*;
899
900 fn fresh_review() -> (LocalStateReview, Arc<Repository>, TempDir) {
901 let temp = TempDir::new().expect("create tempdir");
902 unsafe {
904 std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
905 std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
906 }
907 let repo = Repository::init_default(temp.path()).expect("init repo");
908 let dedup = OperationDedupStore::open(repo.heddle_dir()).expect("open dedup");
909 let repo = Arc::new(repo);
910 let review =
911 LocalStateReview::new(LocalReviewContext::new(Arc::clone(&repo), Arc::new(dedup)));
912 (review, repo, temp)
913 }
914
915 fn capture_state(repo: &Repository, content: &[u8]) -> StateId {
916 std::fs::write(repo.root().join("hello.txt"), content).expect("write file");
917 repo.snapshot(Some("seed".to_string()), None)
918 .expect("snapshot")
919 .state_id
920 }
921
922 fn sign_request(state_id: StateId, operation_id: impl Into<String>) -> SignReviewRequest {
923 let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
924 let scope = ReviewScope::WholeChange;
925 let signed_at = chrono::Utc::now().timestamp();
926 let payload = signing_payload(state_id, ReviewKind::Read, &scope, signed_at, None);
927 SignReviewRequest {
928 state_id,
929 kind: ReviewKind::Read,
930 scope,
931 justification: None,
932 algorithm: "ed25519".to_string(),
933 public_key: signer.public_key().to_vec(),
934 signature: signer.sign(&payload).expect("sign payload"),
935 signed_at,
936 client_operation_id: operation_id.into(),
937 }
938 }
939
940 #[tokio::test]
941 #[serial_test::serial(process_global)]
942 async fn local_interface_signs_lists_and_replays_without_protocol_types() {
943 let (review, repo, _temp) = fresh_review();
944 let state_id = capture_state(&repo, b"hello\n");
945 let operation_id = objects::object::OperationId::new().to_string();
946 let request = sign_request(state_id, operation_id);
947
948 let first = review.sign_state(request.clone()).await.expect("sign");
949 let replay = review.sign_state(request).await.expect("replay");
950
951 assert_eq!(first, replay);
952 assert_eq!(first.state_id, state_id);
953 let signatures = review.list_signatures(state_id).expect("signatures");
954 assert_eq!(signatures.len(), 1, "replay must not append");
955 assert_eq!(signatures[0].id, "rs-0");
956 assert_eq!(signatures[0].signature.kind, ReviewKind::Read);
957 assert_eq!(signatures[0].signature.scope, ReviewScope::WholeChange);
958 assert_eq!(signatures[0].signature.actor.name, b"Alice Tester");
959 assert_eq!(signatures[0].signature.actor.email, b"alice@example.com");
960 }
961
962 #[tokio::test]
963 #[serial_test::serial(process_global)]
964 async fn legacy_prost_replay_is_a_controlled_operation_id_conflict() {
965 use repo::operation_dedup::hash_request_body;
966
967 let (review, repo, _temp) = fresh_review();
968 let state_id = capture_state(&repo, b"hello\n");
969 let operation_id = objects::object::OperationId::new();
970 let request = sign_request(state_id, operation_id.to_string());
971 let request_bytes = serde_json::to_vec(&request).expect("encode request");
972
973 review
976 .inner
977 .dedup
978 .record(
979 operation_id,
980 "state_review.sign_state",
981 hash_request_body(&request_bytes),
982 vec![0x0a, 0x03, b'o', b'l', b'd'],
983 )
984 .expect("record legacy replay");
985
986 let error = review
987 .sign_state(request)
988 .await
989 .expect_err("legacy replay must not be decoded as local JSON");
990
991 assert_eq!(
992 error.code(),
993 crate::review::LocalReviewCode::FailedPrecondition
994 );
995 assert!(
996 error
997 .message()
998 .contains("different operation or replay encoding")
999 );
1000 assert!(
1001 review
1002 .list_signatures(state_id)
1003 .expect("signatures")
1004 .is_empty()
1005 );
1006 }
1007
1008 #[tokio::test]
1009 #[serial_test::serial(process_global)]
1010 async fn local_interface_rejects_a_forged_signature() {
1011 let (review, repo, _temp) = fresh_review();
1012 let state_id = capture_state(&repo, b"hello\n");
1013 let mut request = sign_request(state_id, "");
1014 let last = request.signature.len() - 1;
1015 request.signature[last] ^= 0xff;
1016
1017 let error = review
1018 .sign_state(request)
1019 .await
1020 .expect_err("forgery must fail");
1021
1022 assert_eq!(
1023 error.code(),
1024 crate::review::LocalReviewCode::InvalidArgument
1025 );
1026 assert!(error.message().contains("failed verification"));
1027 assert!(
1028 review
1029 .list_signatures(state_id)
1030 .expect("signatures")
1031 .is_empty()
1032 );
1033 }
1034
1035 #[tokio::test]
1036 #[serial_test::serial(process_global)]
1037 async fn local_interface_rejects_the_reserved_verdict_envelope_prefix() {
1038 let (review, repo, _temp) = fresh_review();
1039 let state_id = capture_state(&repo, b"hello\n");
1040 let mut request = sign_request(state_id, "");
1041 request.justification = Some(format!("{VERDICT_ENVELOPE_TAG}{{\"verdict\":\"hold\"}}"));
1042
1043 let error = review
1044 .sign_state(request)
1045 .await
1046 .expect_err("reserved verdict envelope must fail");
1047
1048 assert_eq!(
1049 error.code(),
1050 crate::review::LocalReviewCode::InvalidArgument
1051 );
1052 assert!(error.message().contains("reserved verdict-envelope prefix"));
1053 assert!(
1054 review
1055 .list_signatures(state_id)
1056 .expect("signatures")
1057 .is_empty()
1058 );
1059 }
1060
1061 #[tokio::test]
1062 #[serial_test::serial(process_global)]
1063 async fn local_interface_rejects_a_skewed_timestamp() {
1064 let (review, repo, _temp) = fresh_review();
1065 let state_id = capture_state(&repo, b"hello\n");
1066 let mut request = sign_request(state_id, "");
1067 request.signed_at += 60 * 60;
1068
1069 let error = review
1070 .sign_state(request)
1071 .await
1072 .expect_err("skewed timestamp must fail");
1073
1074 assert_eq!(
1075 error.code(),
1076 crate::review::LocalReviewCode::InvalidArgument
1077 );
1078 assert!(error.message().contains("too far from server time"));
1079 assert!(
1080 review
1081 .list_signatures(state_id)
1082 .expect("signatures")
1083 .is_empty()
1084 );
1085 }
1086
1087 #[tokio::test]
1088 #[serial_test::serial(process_global)]
1089 async fn local_interface_attributes_the_signature_to_the_current_principal() {
1090 let (review, repo, _temp) = fresh_review();
1091 let state_id = capture_state(&repo, b"hello\n");
1092
1093 unsafe {
1095 std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Bob Signer");
1096 std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "bob@example.com");
1097 }
1098 review
1099 .sign_state(sign_request(state_id, ""))
1100 .await
1101 .expect("sign as Bob");
1102
1103 let signature = review
1104 .list_signatures(state_id)
1105 .expect("signatures")
1106 .pop()
1107 .expect("signature")
1108 .signature;
1109 assert_eq!(signature.actor.name, b"Bob Signer");
1110 assert_eq!(signature.actor.email, b"bob@example.com");
1111 }
1112
1113 #[tokio::test]
1114 #[serial_test::serial(process_global)]
1115 async fn local_interface_serializes_concurrent_signature_appends() {
1116 let (review, repo, _temp) = fresh_review();
1117 let state_id = capture_state(&repo, b"hello\n");
1118 let request_a = sign_request(state_id, objects::object::OperationId::new().to_string());
1119 let request_b = sign_request(state_id, objects::object::OperationId::new().to_string());
1120
1121 let first_review = review.clone();
1122 let second_review = review.clone();
1123 let (a, b) = tokio::join!(
1124 first_review.sign_state(request_a),
1125 second_review.sign_state(request_b)
1126 );
1127 a.expect("first sign");
1128 b.expect("second sign");
1129
1130 assert_eq!(
1131 review.list_signatures(state_id).expect("signatures").len(),
1132 2
1133 );
1134 }
1135
1136 #[test]
1137 #[serial_test::serial(process_global)]
1138 fn local_payload_exposes_domain_summary_signals_and_reading_order() {
1139 let (review, repo, _temp) = fresh_review();
1140 let state_id = capture_state(&repo, b"first\nsecond\nthird\n");
1141
1142 let payload = review.get_review_payload(state_id, false).expect("payload");
1143
1144 assert_eq!(payload.state_id, state_id);
1145 assert!(payload.summary.files_changed >= 1);
1146 assert!(payload.summary.added_lines >= 3);
1147 assert_eq!(
1148 payload.summary.in_budget_signal_count,
1149 payload.in_budget_signals.len() as u32
1150 );
1151 let signal = payload.in_budget_signals.first().expect("diff signal");
1152 assert_eq!(signal.kind, ReviewSignalKind::DiffSummary);
1153 assert_eq!(signal.producer.module, "review_show.diff_summary");
1154 assert_eq!(signal.visibility, ReviewSignalVisibility::Visible);
1155 assert_eq!(signal.anchor.file, "hello.txt");
1156 let surfaced = payload
1157 .partition
1158 .structural
1159 .iter()
1160 .chain(payload.partition.consequence.iter())
1161 .chain(payload.partition.tests_and_docs.iter())
1162 .any(|symbol| symbol.file == "hello.txt");
1163 assert!(surfaced, "changed path must appear in reading order");
1164
1165 std::fs::write(
1166 repo.root().join("hello.txt"),
1167 b"first\nsecond changed\nthird\nfourth\n",
1168 )
1169 .expect("modify file");
1170 let modified_state = repo
1171 .snapshot(Some("modify".to_string()), None)
1172 .expect("snapshot modification")
1173 .state_id;
1174 let modified = review
1175 .get_review_payload(modified_state, false)
1176 .expect("payload");
1177 assert_eq!(modified.summary.files_changed, 1);
1178 assert!(modified.summary.added_lines >= 1);
1179 assert!(modified.summary.removed_lines >= 1);
1180 assert_eq!(
1181 modified.in_budget_signals[0].anchor.file, "hello.txt",
1182 "the aggregate signal must anchor on the changed file"
1183 );
1184 assert!(
1185 modified.in_budget_signals[0]
1186 .reason
1187 .contains("files changed")
1188 );
1189 }
1190
1191 #[test]
1192 #[serial_test::serial(process_global)]
1193 fn local_payload_surfaces_gitlink_target_changes() {
1194 let (review, repo, _temp) = fresh_review();
1195 let old_target = "0303030303030303030303030303030303030303"
1196 .parse()
1197 .expect("old git oid");
1198 let new_target = "0404040404040404040404040404040404040404"
1199 .parse()
1200 .expect("new git oid");
1201 let old_tree = objects::object::Tree::from_entries(vec![
1202 objects::object::TreeEntry::gitlink("vendor", old_target).expect("old gitlink"),
1203 ]);
1204 let new_tree = objects::object::Tree::from_entries(vec![
1205 objects::object::TreeEntry::gitlink("vendor", new_target).expect("new gitlink"),
1206 ]);
1207 let old_tree_hash = repo.store().put_tree(&old_tree).expect("put old tree");
1208 let new_tree_hash = repo.store().put_tree(&new_tree).expect("put new tree");
1209 let attribution = objects::object::Attribution::human(objects::object::Principal::new(
1210 "Gitlink Reviewer",
1211 "gitlink@example.test",
1212 ));
1213 let base = State::new_snapshot(old_tree_hash, Vec::new(), attribution.clone());
1214 repo.store().put_state(&base).expect("put base state");
1215 let changed = State::new_snapshot(new_tree_hash, vec![base.state_id], attribution);
1216 repo.store().put_state(&changed).expect("put changed state");
1217
1218 let payload = review
1219 .get_review_payload(changed.state_id, false)
1220 .expect("payload");
1221
1222 assert_eq!(payload.summary.files_changed, 1);
1223 assert_eq!(payload.summary.added_lines, 0);
1224 assert_eq!(payload.summary.removed_lines, 0);
1225 assert_eq!(payload.in_budget_signals[0].anchor.file, "vendor");
1226 let surfaced = payload
1227 .partition
1228 .structural
1229 .iter()
1230 .chain(payload.partition.consequence.iter())
1231 .chain(payload.partition.tests_and_docs.iter())
1232 .any(|symbol| symbol.file == "vendor" && symbol.symbol == "vendor");
1233 assert!(surfaced, "gitlink change must remain path-visible");
1234 }
1235
1236 #[test]
1237 #[serial_test::serial(process_global)]
1238 fn local_payload_tolerates_a_missing_tree() {
1239 let (review, repo, _temp) = fresh_review();
1240 let state_id = capture_state(&repo, b"hello\n");
1241 let mut state = repo
1242 .store()
1243 .get_state(&state_id)
1244 .expect("get state")
1245 .expect("state");
1246 state.tree = objects::object::ContentHash::compute(b"missing-tree");
1247 let missing_tree_state_id = state.id();
1248 repo.store().put_state(&state).expect("put mutated state");
1249
1250 let payload = review
1251 .get_review_payload(missing_tree_state_id, false)
1252 .expect("missing tree must not block the review payload");
1253
1254 assert_eq!(payload.summary.files_changed, 0);
1255 assert_eq!(payload.in_budget_signals.len(), 1);
1256 assert_eq!(
1257 payload.in_budget_signals[0].kind,
1258 ReviewSignalKind::DiffSummary
1259 );
1260 assert_eq!(
1261 payload.in_budget_signals[0].producer.module,
1262 "review_show.diff_summary"
1263 );
1264 }
1265
1266 #[test]
1267 #[serial_test::serial(process_global)]
1268 fn local_payload_surfaces_persisted_risk_signals_without_all_signals_flag() {
1269 let (review, repo, _temp) = fresh_review();
1270 let state_id = capture_state(&repo, b"hello\n");
1271 let signal = objects::object::RiskSignal {
1272 kind: objects::object::RiskSignalKind::Novelty,
1273 anchor: objects::object::SignalAnchor::symbol("changed.rs", "delta"),
1274 reason: "function shape unique in repo".to_string(),
1275 producer: objects::object::ProducerId::new("novelty.tree_sitter", 1),
1276 computed_at: 1,
1277 computed_against: Some(state_id),
1278 };
1279 let bytes = objects::object::RiskSignalBlob::new(vec![signal])
1280 .encode()
1281 .expect("encode risk signals");
1282 let hash = repo
1283 .store()
1284 .put_blob(&objects::object::Blob::new(bytes))
1285 .expect("put risk blob");
1286 repo.put_state_attachment(&objects::object::StateAttachment {
1287 state_id,
1288 body: objects::object::StateAttachmentBody::RiskSignals(hash),
1289 attribution: repo.get_attribution().expect("attribution"),
1290 created_at: chrono::Utc::now(),
1291 supersedes: None,
1292 })
1293 .expect("attach risk signals");
1294
1295 let payload = review.get_review_payload(state_id, false).expect("payload");
1296 assert!(
1297 payload.in_budget_signals.iter().any(|signal| {
1298 signal.kind == ReviewSignalKind::Risk(objects::object::RiskSignalKind::Novelty)
1299 && signal.producer.module == "novelty.tree_sitter"
1300 }),
1301 "review show must emit persisted risk signals, got {:?}",
1302 payload.in_budget_signals
1303 );
1304 assert!(
1305 payload.all_signals.is_empty(),
1306 "all_signals stays empty unless --all-signals"
1307 );
1308
1309 let health = crate::review::get_repo_signal_health(&repo, 10).expect("health");
1310 assert!(
1311 health
1312 .entries
1313 .iter()
1314 .any(|entry| entry.module_id == "novelty.tree_sitter"),
1315 "review health must see persisted signals: {:?}",
1316 health.entries
1317 );
1318 }
1319
1320 #[test]
1321 fn line_count_matches_git_semantics() {
1322 assert_eq!(line_count(b""), 0);
1323 assert_eq!(line_count(b"\n"), 1);
1324 assert_eq!(line_count(b"hello"), 1);
1325 assert_eq!(line_count(b"hello\n"), 1);
1326 assert_eq!(line_count(b"hello\nworld"), 2);
1327 assert_eq!(line_count(b"hello\nworld\n"), 2);
1328 assert_eq!(line_count(&[0xff, 0xfe, 0xfd]), 0);
1329 }
1330}