Skip to main content

cli/client/
review_sync.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Hosted review-signature sync bridge.
3//!
4//! `heddle review sign` records a `ReviewSignatures` state-attachment LOCALLY.
5//! weft#549 rejects a client-pushed attachment in the pack, so a signature only
6//! reaches the hosted server through the caller-authenticated, PoP-signed
7//! `StateReviewService::SignState` RPC (which binds the signing key to the
8//! authenticated caller). This module replays our local signatures over that
9//! RPC after a successful `heddle push`, mirroring [`crate::client::discussion_sync`]:
10//!
11//! * **Push (write path):** for the pushed state(s), forward each review
12//!   signature WE authored to the hosted `SignState`. The signature bytes were
13//!   computed over the deterministic [`objects::object::state_review::signing_payload`],
14//!   byte-identical to the server's reconstruction, so the exact signature the
15//!   local `review sign` wrote verifies unchanged server-side. weft relaxes the
16//!   `signed_at` skew gate for this authenticated install path, so a signature
17//!   minted long before the push still lands.
18//! * **Pull (read path):** none needed — the server-minted `ReviewSignatures`
19//!   attachment rides the pull pack like any server-owned attachment, so a clone
20//!   / pull materializes it and the local `review show` reads it directly.
21//!
22//! ## Fail-closed self filter
23//!
24//! Only signatures whose actor is the local principal are forwarded. The actor
25//! is resolved from [`Repository::get_principal`] (env → config → git) — the SAME
26//! source `review sign` stamped the `actor` with — NOT `config().principal`
27//! alone, which is empty in git-overlay / env-identity repos and would silently
28//! drop our own signatures. When the principal is unresolvable we warn and skip
29//! (we cannot tell which signatures are ours).
30//!
31//! ## Retry discipline
32//!
33//! The mirror (`.heddle/collaboration/hosted-review-mirror.json`) records both
34//! `synced` and permanently-`rejected` `(state, signature)` pairs. A transient
35//! failure (network / server unavailable / state not yet on the server) is left
36//! for the next push to retry; a permanent rejection (bad signature, key not
37//! owned by the caller) is recorded so it stops retrying and warning every push.
38
39#![cfg(feature = "client")]
40
41use std::{
42    collections::{BTreeMap, HashSet},
43    fs,
44    path::{Path, PathBuf},
45};
46
47use anyhow::{Context, Result};
48use api::heddle::api::v1alpha1::{
49    PathSymbolRef, ReviewKind as ProtoReviewKind, ReviewScope as ProtoReviewScope, review_scope,
50};
51use objects::fs_atomic::write_file_atomic;
52use objects::object::{
53    ReviewKind, ReviewScope, ReviewSignature, ReviewSignaturesBlob, StateAttachmentBody, StateId,
54};
55use objects::store::ObjectStore;
56use repo::{HistoryQuery, Repository, StateAttachmentKind};
57use serde::{Deserialize, Serialize};
58use wire::ProtocolError;
59
60use crate::client::HostedGrpcClient;
61
62/// How far back from HEAD to scan for locally-recorded review signatures.
63const REVIEW_SCAN_LIMIT: usize = 50;
64
65#[derive(Debug, Default, Serialize, Deserialize)]
66struct HostedReviewMirror {
67    #[serde(default)]
68    repos: BTreeMap<String, RepoReviewMirror>,
69}
70
71#[derive(Debug, Default, Serialize, Deserialize)]
72struct RepoReviewMirror {
73    /// `(state_id, signature-hex)` pairs successfully installed on the server.
74    #[serde(default)]
75    synced: Vec<String>,
76    /// Pairs the server permanently rejected — do not retry (avoids warning
77    /// every push over a signature that will never install).
78    #[serde(default)]
79    rejected: Vec<String>,
80}
81
82fn synced_key(state_id: &StateId, signature_hex: &str) -> String {
83    format!("{}#{signature_hex}", state_id.to_string_full())
84}
85
86fn mirror_path(heddle_dir: &Path) -> PathBuf {
87    heddle_dir
88        .join("collaboration")
89        .join("hosted-review-mirror.json")
90}
91
92fn load_mirror(heddle_dir: &Path) -> Result<HostedReviewMirror> {
93    match fs::read(mirror_path(heddle_dir)) {
94        Ok(bytes) => serde_json::from_slice(&bytes).context("decode hosted review mirror map"),
95        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
96            Ok(HostedReviewMirror::default())
97        }
98        Err(error) => Err(error).context("read hosted review mirror map"),
99    }
100}
101
102fn save_mirror(heddle_dir: &Path, mirror: &HostedReviewMirror) -> Result<()> {
103    let path = mirror_path(heddle_dir);
104    if let Some(parent) = path.parent() {
105        fs::create_dir_all(parent).context("create collaboration dir")?;
106    }
107    let bytes = serde_json::to_vec_pretty(mirror).context("encode hosted review mirror map")?;
108    write_file_atomic(&path, &bytes).context("write hosted review mirror map")?;
109    Ok(())
110}
111
112fn kind_to_proto(kind: ReviewKind) -> ProtoReviewKind {
113    match kind {
114        ReviewKind::Read => ProtoReviewKind::Read,
115        ReviewKind::AgentPreview => ProtoReviewKind::AgentPreview,
116        ReviewKind::AgentCoReview => ProtoReviewKind::AgentCoReview,
117    }
118}
119
120fn scope_to_proto(scope: &ReviewScope) -> ProtoReviewScope {
121    let inner = match scope {
122        ReviewScope::WholeChange => review_scope::Scope::WholeChange(review_scope::WholeChange {}),
123        ReviewScope::Symbols(symbols) => review_scope::Scope::Symbols(review_scope::SymbolList {
124            symbols: symbols
125                .iter()
126                .map(|anchor| PathSymbolRef {
127                    file: anchor.file.clone(),
128                    symbol: anchor.symbol.clone(),
129                })
130                .collect(),
131        }),
132    };
133    ProtoReviewScope { scope: Some(inner) }
134}
135
136/// Whether a hosted rejection is permanent (won't succeed on retry) vs transient
137/// (retry next push). A malformed/invalid signature or a key the caller does not
138/// own will never install; a network error or a state not yet on the server may.
139fn is_permanent(error: &ProtocolError) -> bool {
140    matches!(
141        error,
142        ProtocolError::InvalidState(_) | ProtocolError::AuthorizationFailed(_)
143    )
144}
145
146enum ForwardOutcome {
147    Installed,
148    Permanent(String),
149    Transient(String),
150}
151
152/// Read the current `ReviewSignatures` blob for a state, if any.
153fn read_signatures(repo: &Repository, state_id: &StateId) -> Result<Vec<ReviewSignature>> {
154    let Some(attachment) =
155        repo.latest_state_attachment(state_id, StateAttachmentKind::ReviewSignatures)?
156    else {
157        return Ok(Vec::new());
158    };
159    let StateAttachmentBody::ReviewSignatures(hash) = attachment.body else {
160        return Ok(Vec::new());
161    };
162    let Some(blob) = repo.store().get_blob(&hash)? else {
163        return Ok(Vec::new());
164    };
165    let decoded = ReviewSignaturesBlob::decode(blob.content())
166        .map_err(|error| anyhow::anyhow!("decode review signatures blob: {error}"))?;
167    Ok(decoded.signatures)
168}
169
170/// Replay local review signatures we authored to the hosted `StateReviewService`.
171pub async fn push_review_signatures(
172    repo: &Repository,
173    client: &mut HostedGrpcClient,
174    repo_path: &str,
175) -> Result<usize> {
176    let Some(head) = repo.head().context("resolve repository head")? else {
177        return Ok(0);
178    };
179
180    // Resolve our identity from the SAME source `review sign` stamped the actor
181    // with (env → config → git). Warn + skip if unresolvable — we cannot tell
182    // which signatures are ours.
183    let principal = match repo.get_principal() {
184        Ok(principal) => principal,
185        Err(error) => {
186            eprintln!(
187                "{} review sync skipped: could not resolve the local principal ({error}); \
188                 set one with `heddle init --principal-name <name> --principal-email <email>`",
189                crate::cli::style::warn_marker(),
190            );
191            return Ok(0);
192        }
193    };
194
195    let states = repo
196        .query_history(&HistoryQuery::new(Some(head)).with_limit(REVIEW_SCAN_LIMIT))
197        .context("walk history for review signatures")?;
198
199    let heddle_dir = repo.heddle_dir().to_path_buf();
200    let mut mirror = load_mirror(&heddle_dir)?;
201    let skip: HashSet<String> = mirror
202        .repos
203        .get(repo_path)
204        .map(|repo_mirror| {
205            repo_mirror
206                .synced
207                .iter()
208                .chain(repo_mirror.rejected.iter())
209                .cloned()
210                .collect()
211        })
212        .unwrap_or_default();
213
214    let mut synced = 0usize;
215    for state in states {
216        let signatures = match read_signatures(repo, &state.state_id) {
217            Ok(signatures) => signatures,
218            Err(error) => {
219                eprintln!(
220                    "{} hosted review {}: {error:#}",
221                    crate::cli::style::warn_marker(),
222                    state.state_id.short()
223                );
224                continue;
225            }
226        };
227        for signature in signatures {
228            if signature.actor.name != principal.name || signature.actor.email != principal.email {
229                continue;
230            }
231            let key = synced_key(&state.state_id, &signature.signature);
232            if skip.contains(&key) {
233                continue;
234            }
235            match forward_signature(client, repo_path, &state.state_id, &signature).await {
236                ForwardOutcome::Installed => {
237                    mirror
238                        .repos
239                        .entry(repo_path.to_string())
240                        .or_default()
241                        .synced
242                        .push(key);
243                    save_mirror(&heddle_dir, &mirror)?;
244                    synced += 1;
245                }
246                ForwardOutcome::Permanent(message) => {
247                    // Record so we stop retrying + warning on every push.
248                    mirror
249                        .repos
250                        .entry(repo_path.to_string())
251                        .or_default()
252                        .rejected
253                        .push(key);
254                    save_mirror(&heddle_dir, &mirror)?;
255                    eprintln!(
256                        "{} hosted review {}: permanently rejected, will not retry: {message}",
257                        crate::cli::style::warn_marker(),
258                        state.state_id.short()
259                    );
260                }
261                ForwardOutcome::Transient(message) => {
262                    eprintln!(
263                        "{} hosted review {}: {message} (will retry on next push)",
264                        crate::cli::style::warn_marker(),
265                        state.state_id.short()
266                    );
267                }
268            }
269        }
270    }
271    Ok(synced)
272}
273
274async fn forward_signature(
275    client: &mut HostedGrpcClient,
276    repo_path: &str,
277    state_id: &StateId,
278    signature: &ReviewSignature,
279) -> ForwardOutcome {
280    // A malformed stored signature will never install → permanent.
281    let public_key = match hex::decode(&signature.public_key) {
282        Ok(bytes) => bytes,
283        Err(error) => return ForwardOutcome::Permanent(format!("public_key is not hex: {error}")),
284    };
285    let signature_bytes = match hex::decode(&signature.signature) {
286        Ok(bytes) => bytes,
287        Err(error) => return ForwardOutcome::Permanent(format!("signature is not hex: {error}")),
288    };
289    match client
290        .sign_state(
291            repo_path,
292            state_id,
293            kind_to_proto(signature.kind),
294            scope_to_proto(&signature.scope),
295            signature.justification.as_deref().unwrap_or_default(),
296            &signature.algorithm,
297            public_key,
298            signature_bytes,
299            signature.signed_at,
300            sign_op_id(repo_path, state_id, &signature.signature),
301        )
302        .await
303    {
304        // Idempotent success or an already-installed signature both mean "on the
305        // server".
306        Ok(_) | Err(ProtocolError::AlreadyExists(_)) => ForwardOutcome::Installed,
307        Err(error) if is_permanent(&error) => ForwardOutcome::Permanent(error.to_string()),
308        Err(error) => ForwardOutcome::Transient(error.to_string()),
309    }
310}
311
312const OP_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6865_6464_6c65_7276_775f_7379_6e63_0001);
313
314fn sign_op_id(repo_path: &str, state_id: &StateId, signature_hex: &str) -> String {
315    uuid::Uuid::new_v5(
316        &OP_NAMESPACE,
317        format!(
318            "sign:{repo_path}:{}:{signature_hex}",
319            state_id.to_string_full()
320        )
321        .as_bytes(),
322    )
323    .to_string()
324}
325
326#[cfg(test)]
327mod tests {
328    use objects::object::SymbolAnchor;
329
330    use super::*;
331
332    #[test]
333    fn whole_change_scope_maps_to_proto() {
334        let proto = scope_to_proto(&ReviewScope::WholeChange);
335        assert!(matches!(
336            proto.scope,
337            Some(review_scope::Scope::WholeChange(_))
338        ));
339    }
340
341    #[test]
342    fn symbol_scope_maps_to_proto() {
343        let proto = scope_to_proto(&ReviewScope::Symbols(vec![SymbolAnchor::new("a.rs", "foo")]));
344        match proto.scope {
345            Some(review_scope::Scope::Symbols(list)) => {
346                assert_eq!(list.symbols.len(), 1);
347                assert_eq!(list.symbols[0].file, "a.rs");
348                assert_eq!(list.symbols[0].symbol, "foo");
349            }
350            other => panic!("expected symbols scope, got {other:?}"),
351        }
352    }
353
354    #[test]
355    fn synced_key_is_state_scoped() {
356        let a = StateId::from_bytes([1; 32]);
357        let b = StateId::from_bytes([2; 32]);
358        assert_ne!(synced_key(&a, "abad1dea"), synced_key(&b, "abad1dea"));
359        assert_eq!(synced_key(&a, "abad1dea"), synced_key(&a, "abad1dea"));
360    }
361
362    #[test]
363    fn kind_maps_to_proto() {
364        assert_eq!(kind_to_proto(ReviewKind::Read), ProtoReviewKind::Read);
365        assert_eq!(
366            kind_to_proto(ReviewKind::AgentPreview),
367            ProtoReviewKind::AgentPreview
368        );
369        assert_eq!(
370            kind_to_proto(ReviewKind::AgentCoReview),
371            ProtoReviewKind::AgentCoReview
372        );
373    }
374
375    // A bad-signature / key-not-owned rejection is permanent (stops retrying);
376    // a network / not-yet-pushed error is transient (retries next push).
377    #[test]
378    fn permanent_vs_transient_classification() {
379        assert!(is_permanent(&ProtocolError::InvalidState("bad sig".into())));
380        assert!(is_permanent(&ProtocolError::AuthorizationFailed(
381            "key not owned".into()
382        )));
383        assert!(!is_permanent(&ProtocolError::ObjectNotFound(
384            "state not on server yet".into()
385        )));
386        assert!(!is_permanent(&ProtocolError::Remote("unavailable".into())));
387    }
388}