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