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_with_principal(
901 name: &str,
902 email: &str,
903 ) -> (LocalStateReview, Arc<Repository>, TempDir) {
904 let temp = TempDir::new().expect("create tempdir");
905 let repo = Repository::init_default(temp.path()).expect("init repo");
906 let mut config = repo.config().clone();
907 config.set_principal(name, email);
908 config
909 .save(&repo.heddle_dir().join("config.toml"))
910 .expect("save test principal");
911 let repo = Repository::open(temp.path()).expect("reopen configured repo");
912 let dedup = OperationDedupStore::open(repo.heddle_dir()).expect("open dedup");
913 let repo = Arc::new(repo);
914 let review =
915 LocalStateReview::new(LocalReviewContext::new(Arc::clone(&repo), Arc::new(dedup)));
916 (review, repo, temp)
917 }
918
919 fn fresh_review() -> (LocalStateReview, Arc<Repository>, TempDir) {
920 fresh_review_with_principal("Alice Tester", "alice@example.com")
921 }
922
923 fn capture_state(repo: &Repository, content: &[u8]) -> StateId {
924 std::fs::write(repo.root().join("hello.txt"), content).expect("write file");
925 repo.snapshot(Some("seed".to_string()), None)
926 .expect("snapshot")
927 .state_id
928 }
929
930 fn sign_request(state_id: StateId, operation_id: impl Into<String>) -> SignReviewRequest {
931 let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
932 let scope = ReviewScope::WholeChange;
933 let signed_at = chrono::Utc::now().timestamp();
934 let payload = signing_payload(state_id, ReviewKind::Read, &scope, signed_at, None);
935 SignReviewRequest {
936 state_id,
937 kind: ReviewKind::Read,
938 scope,
939 justification: None,
940 algorithm: "ed25519".to_string(),
941 public_key: signer.public_key().to_vec(),
942 signature: signer.sign(&payload).expect("sign payload"),
943 signed_at,
944 client_operation_id: operation_id.into(),
945 }
946 }
947
948 #[tokio::test]
949 async fn local_interface_signs_lists_and_replays_without_protocol_types() {
950 let (review, repo, _temp) = fresh_review();
951 let state_id = capture_state(&repo, b"hello\n");
952 let operation_id = objects::object::OperationId::new().to_string();
953 let request = sign_request(state_id, operation_id);
954
955 let first = review.sign_state(request.clone()).await.expect("sign");
956 let replay = review.sign_state(request).await.expect("replay");
957
958 assert_eq!(first, replay);
959 assert_eq!(first.state_id, state_id);
960 let signatures = review.list_signatures(state_id).expect("signatures");
961 assert_eq!(signatures.len(), 1, "replay must not append");
962 assert_eq!(signatures[0].id, "rs-0");
963 assert_eq!(signatures[0].signature.kind, ReviewKind::Read);
964 assert_eq!(signatures[0].signature.scope, ReviewScope::WholeChange);
965 assert_eq!(signatures[0].signature.actor.name, b"Alice Tester");
966 assert_eq!(signatures[0].signature.actor.email, b"alice@example.com");
967 }
968
969 #[tokio::test]
970 async fn legacy_prost_replay_is_a_controlled_operation_id_conflict() {
971 use repo::operation_dedup::hash_request_body;
972
973 let (review, repo, _temp) = fresh_review();
974 let state_id = capture_state(&repo, b"hello\n");
975 let operation_id = objects::object::OperationId::new();
976 let request = sign_request(state_id, operation_id.to_string());
977 let request_bytes = serde_json::to_vec(&request).expect("encode request");
978
979 review
982 .inner
983 .dedup
984 .record(
985 operation_id,
986 "state_review.sign_state",
987 hash_request_body(&request_bytes),
988 vec![0x0a, 0x03, b'o', b'l', b'd'],
989 )
990 .expect("record legacy replay");
991
992 let error = review
993 .sign_state(request)
994 .await
995 .expect_err("legacy replay must not be decoded as local JSON");
996
997 assert_eq!(
998 error.code(),
999 crate::review::LocalReviewCode::FailedPrecondition
1000 );
1001 assert!(
1002 error
1003 .message()
1004 .contains("different operation or replay encoding")
1005 );
1006 assert!(
1007 review
1008 .list_signatures(state_id)
1009 .expect("signatures")
1010 .is_empty()
1011 );
1012 }
1013
1014 #[tokio::test]
1015 async fn local_interface_rejects_a_forged_signature() {
1016 let (review, repo, _temp) = fresh_review();
1017 let state_id = capture_state(&repo, b"hello\n");
1018 let mut request = sign_request(state_id, "");
1019 let last = request.signature.len() - 1;
1020 request.signature[last] ^= 0xff;
1021
1022 let error = review
1023 .sign_state(request)
1024 .await
1025 .expect_err("forgery must fail");
1026
1027 assert_eq!(
1028 error.code(),
1029 crate::review::LocalReviewCode::InvalidArgument
1030 );
1031 assert!(error.message().contains("failed verification"));
1032 assert!(
1033 review
1034 .list_signatures(state_id)
1035 .expect("signatures")
1036 .is_empty()
1037 );
1038 }
1039
1040 #[tokio::test]
1041 async fn local_interface_rejects_the_reserved_verdict_envelope_prefix() {
1042 let (review, repo, _temp) = fresh_review();
1043 let state_id = capture_state(&repo, b"hello\n");
1044 let mut request = sign_request(state_id, "");
1045 request.justification = Some(format!("{VERDICT_ENVELOPE_TAG}{{\"verdict\":\"hold\"}}"));
1046
1047 let error = review
1048 .sign_state(request)
1049 .await
1050 .expect_err("reserved verdict envelope must fail");
1051
1052 assert_eq!(
1053 error.code(),
1054 crate::review::LocalReviewCode::InvalidArgument
1055 );
1056 assert!(error.message().contains("reserved verdict-envelope prefix"));
1057 assert!(
1058 review
1059 .list_signatures(state_id)
1060 .expect("signatures")
1061 .is_empty()
1062 );
1063 }
1064
1065 #[tokio::test]
1066 async fn local_interface_rejects_a_skewed_timestamp() {
1067 let (review, repo, _temp) = fresh_review();
1068 let state_id = capture_state(&repo, b"hello\n");
1069 let mut request = sign_request(state_id, "");
1070 request.signed_at += 60 * 60;
1071
1072 let error = review
1073 .sign_state(request)
1074 .await
1075 .expect_err("skewed timestamp must fail");
1076
1077 assert_eq!(
1078 error.code(),
1079 crate::review::LocalReviewCode::InvalidArgument
1080 );
1081 assert!(error.message().contains("too far from server time"));
1082 assert!(
1083 review
1084 .list_signatures(state_id)
1085 .expect("signatures")
1086 .is_empty()
1087 );
1088 }
1089
1090 #[tokio::test]
1091 async fn local_interface_attributes_the_signature_to_the_current_principal() {
1092 let (review, repo, _temp) = fresh_review_with_principal("Bob Signer", "bob@example.com");
1093 std::fs::write(repo.root().join("hello.txt"), b"hello\n").expect("write file");
1094 let state_id = repo
1095 .snapshot_with_attribution(
1096 Some("seed".to_string()),
1097 None,
1098 objects::object::Attribution::human(objects::object::Principal::new(
1099 "Alice Author",
1100 "alice@example.com",
1101 )),
1102 )
1103 .expect("snapshot")
1104 .state_id;
1105
1106 review
1107 .sign_state(sign_request(state_id, ""))
1108 .await
1109 .expect("sign as Bob");
1110
1111 let signature = review
1112 .list_signatures(state_id)
1113 .expect("signatures")
1114 .pop()
1115 .expect("signature")
1116 .signature;
1117 assert_eq!(signature.actor.name, b"Bob Signer");
1118 assert_eq!(signature.actor.email, b"bob@example.com");
1119 }
1120
1121 #[tokio::test]
1122 async fn local_interface_serializes_concurrent_signature_appends() {
1123 let (review, repo, _temp) = fresh_review();
1124 let state_id = capture_state(&repo, b"hello\n");
1125 let request_a = sign_request(state_id, objects::object::OperationId::new().to_string());
1126 let request_b = sign_request(state_id, objects::object::OperationId::new().to_string());
1127
1128 let first_review = review.clone();
1129 let second_review = review.clone();
1130 let (a, b) = tokio::join!(
1131 first_review.sign_state(request_a),
1132 second_review.sign_state(request_b)
1133 );
1134 a.expect("first sign");
1135 b.expect("second sign");
1136
1137 assert_eq!(
1138 review.list_signatures(state_id).expect("signatures").len(),
1139 2
1140 );
1141 }
1142
1143 #[test]
1144 fn local_payload_exposes_domain_summary_signals_and_reading_order() {
1145 let (review, repo, _temp) = fresh_review();
1146 let state_id = capture_state(&repo, b"first\nsecond\nthird\n");
1147
1148 let payload = review.get_review_payload(state_id, false).expect("payload");
1149
1150 assert_eq!(payload.state_id, state_id);
1151 assert!(payload.summary.files_changed >= 1);
1152 assert!(payload.summary.added_lines >= 3);
1153 assert_eq!(
1154 payload.summary.in_budget_signal_count,
1155 payload.in_budget_signals.len() as u32
1156 );
1157 let signal = payload.in_budget_signals.first().expect("diff signal");
1158 assert_eq!(signal.kind, ReviewSignalKind::DiffSummary);
1159 assert_eq!(signal.producer.module, "review_show.diff_summary");
1160 assert_eq!(signal.visibility, ReviewSignalVisibility::Visible);
1161 assert_eq!(signal.anchor.file, "hello.txt");
1162 let surfaced = payload
1163 .partition
1164 .structural
1165 .iter()
1166 .chain(payload.partition.consequence.iter())
1167 .chain(payload.partition.tests_and_docs.iter())
1168 .any(|symbol| symbol.file == "hello.txt");
1169 assert!(surfaced, "changed path must appear in reading order");
1170
1171 std::fs::write(
1172 repo.root().join("hello.txt"),
1173 b"first\nsecond changed\nthird\nfourth\n",
1174 )
1175 .expect("modify file");
1176 let modified_state = repo
1177 .snapshot(Some("modify".to_string()), None)
1178 .expect("snapshot modification")
1179 .state_id;
1180 let modified = review
1181 .get_review_payload(modified_state, false)
1182 .expect("payload");
1183 assert_eq!(modified.summary.files_changed, 1);
1184 assert!(modified.summary.added_lines >= 1);
1185 assert!(modified.summary.removed_lines >= 1);
1186 assert_eq!(
1187 modified.in_budget_signals[0].anchor.file, "hello.txt",
1188 "the aggregate signal must anchor on the changed file"
1189 );
1190 assert!(
1191 modified.in_budget_signals[0]
1192 .reason
1193 .contains("files changed")
1194 );
1195 }
1196
1197 #[test]
1198 fn local_payload_surfaces_gitlink_target_changes() {
1199 let (review, repo, _temp) = fresh_review();
1200 let old_target = "0303030303030303030303030303030303030303"
1201 .parse()
1202 .expect("old git oid");
1203 let new_target = "0404040404040404040404040404040404040404"
1204 .parse()
1205 .expect("new git oid");
1206 let old_tree = objects::object::Tree::from_entries(vec![
1207 objects::object::TreeEntry::gitlink("vendor", old_target).expect("old gitlink"),
1208 ]);
1209 let new_tree = objects::object::Tree::from_entries(vec![
1210 objects::object::TreeEntry::gitlink("vendor", new_target).expect("new gitlink"),
1211 ]);
1212 let old_tree_hash = repo.store().put_tree(&old_tree).expect("put old tree");
1213 let new_tree_hash = repo.store().put_tree(&new_tree).expect("put new tree");
1214 let attribution = objects::object::Attribution::human(objects::object::Principal::new(
1215 "Gitlink Reviewer",
1216 "gitlink@example.test",
1217 ));
1218 let base = State::new_snapshot(old_tree_hash, Vec::new(), attribution.clone());
1219 repo.store().put_state(&base).expect("put base state");
1220 let changed = State::new_snapshot(new_tree_hash, vec![base.state_id], attribution);
1221 repo.store().put_state(&changed).expect("put changed state");
1222
1223 let payload = review
1224 .get_review_payload(changed.state_id, false)
1225 .expect("payload");
1226
1227 assert_eq!(payload.summary.files_changed, 1);
1228 assert_eq!(payload.summary.added_lines, 0);
1229 assert_eq!(payload.summary.removed_lines, 0);
1230 assert_eq!(payload.in_budget_signals[0].anchor.file, "vendor");
1231 let surfaced = payload
1232 .partition
1233 .structural
1234 .iter()
1235 .chain(payload.partition.consequence.iter())
1236 .chain(payload.partition.tests_and_docs.iter())
1237 .any(|symbol| symbol.file == "vendor" && symbol.symbol == "vendor");
1238 assert!(surfaced, "gitlink change must remain path-visible");
1239 }
1240
1241 #[test]
1242 fn local_payload_tolerates_a_missing_tree() {
1243 let (review, repo, _temp) = fresh_review();
1244 let state_id = capture_state(&repo, b"hello\n");
1245 let mut state = repo
1246 .store()
1247 .get_state(&state_id)
1248 .expect("get state")
1249 .expect("state");
1250 state.tree = objects::object::ContentHash::compute(b"missing-tree");
1251 let missing_tree_state_id = state.id();
1252 repo.store().put_state(&state).expect("put mutated state");
1253
1254 let payload = review
1255 .get_review_payload(missing_tree_state_id, false)
1256 .expect("missing tree must not block the review payload");
1257
1258 assert_eq!(payload.summary.files_changed, 0);
1259 assert_eq!(payload.in_budget_signals.len(), 1);
1260 assert_eq!(
1261 payload.in_budget_signals[0].kind,
1262 ReviewSignalKind::DiffSummary
1263 );
1264 assert_eq!(
1265 payload.in_budget_signals[0].producer.module,
1266 "review_show.diff_summary"
1267 );
1268 }
1269
1270 #[test]
1271 fn local_payload_surfaces_persisted_risk_signals_without_all_signals_flag() {
1272 let (review, repo, _temp) = fresh_review();
1273 let state_id = capture_state(&repo, b"hello\n");
1274 let signal = objects::object::RiskSignal {
1275 kind: objects::object::RiskSignalKind::Novelty,
1276 anchor: objects::object::SignalAnchor::symbol("changed.rs", "delta"),
1277 reason: "function shape unique in repo".to_string(),
1278 producer: objects::object::ProducerId::new("novelty.tree_sitter", 1),
1279 computed_at: 1,
1280 computed_against: Some(state_id),
1281 };
1282 let bytes = objects::object::RiskSignalBlob::new(vec![signal])
1283 .encode()
1284 .expect("encode risk signals");
1285 let hash = repo
1286 .store()
1287 .put_blob(&objects::object::Blob::new(bytes))
1288 .expect("put risk blob");
1289 repo.put_state_attachment(&objects::object::StateAttachment {
1290 state_id,
1291 body: objects::object::StateAttachmentBody::RiskSignals(hash),
1292 attribution: repo.get_attribution().expect("attribution"),
1293 created_at: chrono::Utc::now(),
1294 supersedes: None,
1295 })
1296 .expect("attach risk signals");
1297
1298 let payload = review.get_review_payload(state_id, false).expect("payload");
1299 assert!(
1300 payload.in_budget_signals.iter().any(|signal| {
1301 signal.kind == ReviewSignalKind::Risk(objects::object::RiskSignalKind::Novelty)
1302 && signal.producer.module == "novelty.tree_sitter"
1303 }),
1304 "review show must emit persisted risk signals, got {:?}",
1305 payload.in_budget_signals
1306 );
1307 assert!(
1308 payload.all_signals.is_empty(),
1309 "all_signals stays empty unless --all-signals"
1310 );
1311
1312 let health = crate::review::get_repo_signal_health(&repo, 10).expect("health");
1313 assert!(
1314 health
1315 .entries
1316 .iter()
1317 .any(|entry| entry.module_id == "novelty.tree_sitter"),
1318 "review health must see persisted signals: {:?}",
1319 health.entries
1320 );
1321 }
1322
1323 #[test]
1324 fn line_count_matches_git_semantics() {
1325 assert_eq!(line_count(b""), 0);
1326 assert_eq!(line_count(b"\n"), 1);
1327 assert_eq!(line_count(b"hello"), 1);
1328 assert_eq!(line_count(b"hello\n"), 1);
1329 assert_eq!(line_count(b"hello\nworld"), 2);
1330 assert_eq!(line_count(b"hello\nworld\n"), 2);
1331 assert_eq!(line_count(&[0xff, 0xfe, 0xfd]), 0);
1332 }
1333}