Skip to main content

prikk_store/sync_negotiation/
summary.rs

1//! RFC 116 stage 2 (design-v1.md N1; handoff §1, §2): the `PSYNCSU1` sync summary -- one message,
2//! every local `heads/*` ref, each with its own [`PatchSetDigest`] and patch count. Answers "are we
3//! the same?" without moving a single patch id (design §1.1): the steady-state case, two
4//! repositories already in sync, costs a few hundred bytes rather than 32 bytes per patch.
5//!
6//! **Branches only.** `remotes/*` never appears: those pointers live in the received-index, not the
7//! ordinary ref-pointer index [`RefStore::list_ref_pointers`] enumerates. `tags/*` **is** filtered
8//! out explicitly and deliberately, not by oversight -- see the parent module doc.
9//!
10//! **Representational, not frozen** (RFC 114 §3): carries no identity of its own.
11//!
12//! **Constructs no `RecognitionClaimPayload`** (handoff §6) -- only ref names, counts, and digests.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use prikk_error::{PrikkError, Result};
17use prikk_object::{ObjectType, RefKind, RefStatePayload};
18
19use crate::byte_cursor::ByteCursor;
20use crate::file_codec::{push_string_u16, push_u64};
21use crate::fsutil::len_to_u64;
22use crate::layout::RepositoryLayout;
23use crate::object_store::{ObjectReadSnapshot, ObjectReader};
24use crate::patch_set_digest::{
25    PatchSetDigest, compute_patch_set_digest, compute_patch_set_digest_for_ref,
26    patch_ids_reachable_from_block,
27};
28use crate::refs::RefStore;
29
30const SYNC_SUMMARY_MAGIC: &[u8; 8] = b"PSYNCSU1";
31
32/// DC-86 bound on the summary's declared ref count, checked before the section is allocated.
33pub const DEFAULT_SYNC_SUMMARY_MAX_REF_COUNT: usize = 100_000;
34
35/// DC-86 bound on the summary's total encoded byte length, checked before decoding starts.
36pub const DEFAULT_SYNC_SUMMARY_MAX_TOTAL_BYTES: usize = 16 * 1024 * 1024;
37
38/// One ref's own entry in a decoded sync summary.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct SyncSummaryRefEntry {
41    /// The branch ref's name, e.g. `heads/main`.
42    pub ref_name: String,
43    /// That ref's own patch-set digest, over its full reachable closure.
44    pub digest: PatchSetDigest,
45    /// That ref's own reachable patch count -- informational only; the digest already commits to
46    /// the exact set, not merely its size, so this is never re-verified against it.
47    pub patch_count: u64,
48}
49
50/// Build a `PSYNCSU1` sync summary covering every local `heads/*` ref, in
51/// [`RefStore::list_ref_pointers`]'s own sorted-by-name order. `remotes/*` and `tags/*` are
52/// excluded -- see the module doc. A repository with no `heads/*` ref at all still encodes validly,
53/// as a summary declaring zero refs.
54pub fn build_sync_summary(layout: &RepositoryLayout) -> Result<Vec<u8>> {
55    let ref_store = RefStore::new(layout.clone());
56    let object_store = ObjectReadSnapshot::open(layout)?;
57    let mut entries: Vec<(String, PatchSetDigest, u64)> = Vec::new();
58    for pointer in ref_store.list_ref_pointers()? {
59        if !pointer.ref_name.starts_with("heads/") {
60            continue;
61        }
62        let envelope = object_store
63            .read_typed(pointer.ref_state_id, ObjectType::RefState)?
64            .ok_or_else(|| {
65                PrikkError::Integrity(format!(
66                    "ref {} names missing RefState {}",
67                    pointer.ref_name, pointer.ref_state_id
68                ))
69            })?;
70        let payload = RefStatePayload::decode_canonical(
71            &envelope.canonical_payload,
72            envelope.schema_version,
73        )?;
74        if payload.kind != RefKind::Branch {
75            return Err(PrikkError::Integrity(format!(
76                "ref {} is under heads/ but its RefState kind is not Branch",
77                pointer.ref_name
78            )));
79        }
80        let patch_ids = patch_ids_reachable_from_block(&object_store, payload.target_object_id)?;
81        let digest = compute_patch_set_digest(&patch_ids)?;
82        let patch_count = len_to_u64(patch_ids.len())?;
83        entries.push((pointer.ref_name, digest, patch_count));
84    }
85
86    let mut out = Vec::new();
87    out.extend_from_slice(SYNC_SUMMARY_MAGIC);
88    push_u64(&mut out, len_to_u64(entries.len())?);
89    for (ref_name, digest, patch_count) in &entries {
90        push_string_u16(&mut out, ref_name)?;
91        out.extend_from_slice(&digest.0);
92        push_u64(&mut out, *patch_count);
93    }
94    Ok(out)
95}
96
97/// Decode a `PSYNCSU1` sync summary structurally. Bounds the total byte length before touching the
98/// bytes at all, then the declared ref count before allocating, the same DC-86 shape
99/// `decode_exchange_artifact` follows. Performs no cross-entry checks and no comparison against
100/// this repository's own refs -- see [`compare_sync_summary`] for that.
101pub fn decode_sync_summary(
102    bytes: &[u8],
103    max_total_bytes: usize,
104    max_ref_count: usize,
105) -> Result<Vec<SyncSummaryRefEntry>> {
106    if bytes.len() > max_total_bytes {
107        return Err(PrikkError::MalformedData(format!(
108            "sync summary is {} bytes, over the configured limit of {max_total_bytes} bytes",
109            bytes.len()
110        )));
111    }
112    let mut cursor = ByteCursor::new(bytes);
113    let magic = cursor.read_array::<8>()?;
114    if &magic != SYNC_SUMMARY_MAGIC {
115        return Err(PrikkError::MalformedData(
116            "invalid sync summary magic".to_string(),
117        ));
118    }
119    let ref_count = cursor.read_u64()?;
120    if ref_count > len_to_u64(max_ref_count)? {
121        return Err(PrikkError::MalformedData(format!(
122            "sync summary declares {ref_count} refs, over the configured limit of {max_ref_count}"
123        )));
124    }
125    let mut entries = Vec::new();
126    for _ in 0..ref_count {
127        let ref_name = cursor.read_string_u16()?;
128        let digest = PatchSetDigest(cursor.read_array::<32>()?);
129        let patch_count = cursor.read_u64()?;
130        entries.push(SyncSummaryRefEntry {
131            ref_name,
132            digest,
133            patch_count,
134        });
135    }
136    if !cursor.is_finished() {
137        return Err(PrikkError::MalformedData(
138            "trailing bytes in sync summary".to_string(),
139        ));
140    }
141    Ok(entries)
142}
143
144/// One ref's own comparison state, from [`compare_sync_summary`].
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum SyncRefComparisonState {
147    /// Both sides have this ref, and their digests agree.
148    InSync,
149    /// Both sides have this ref, but their digests disagree.
150    Differs,
151    /// Only the remote summary names this ref -- this repository does not hold it.
152    RemoteOnly,
153    /// Only this repository holds this ref -- the remote summary does not name it.
154    LocalOnly,
155}
156
157impl SyncRefComparisonState {
158    /// Stable, lowercase, hyphenated name -- used for CLI output and test assertions alike, so
159    /// there is exactly one spelling to keep in sync.
160    #[must_use]
161    pub const fn as_str(self) -> &'static str {
162        match self {
163            Self::InSync => "in-sync",
164            Self::Differs => "differs",
165            Self::RemoteOnly => "remote-only",
166            Self::LocalOnly => "local-only",
167        }
168    }
169}
170
171/// One ref's own comparison result.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct SyncRefComparison {
174    /// The branch ref's name.
175    pub ref_name: String,
176    /// How this repository's own state compares to `remote`'s.
177    pub state: SyncRefComparisonState,
178}
179
180/// Compare this repository's own `heads/*` refs against a remote summary's entries (handoff §2).
181/// **None of the four states is a refusal** -- an asymmetric ref set (one side names a ref the
182/// other does not hold) is ordinary, ruled by design §5 item 6 / N5 item 6 and carried forward
183/// through every stage since: a receiver-absent have-list is empty, not a refusal (stage 2/3); a
184/// sender-absent ref reports `AlreadyInSync`, not a refusal (stage 3). This function is the same
185/// principle applied to the summary's own comparison, which stage 2's review flagged as pinned by
186/// a passing test and by no control.
187///
188/// Local digests are computed directly per ref via [`compute_patch_set_digest_for_ref`] -- not by
189/// building and decoding this repository's own summary and diffing the two decoded lists, which
190/// would compute a digest for every local ref whether or not it is even being compared. Branches
191/// only, matching this module's own scope.
192pub fn compare_sync_summary(
193    layout: &RepositoryLayout,
194    remote: &[SyncSummaryRefEntry],
195) -> Result<Vec<SyncRefComparison>> {
196    let ref_store = RefStore::new(layout.clone());
197    let mut local_ref_names: BTreeSet<String> = BTreeSet::new();
198    for pointer in ref_store.list_ref_pointers()? {
199        if pointer.ref_name.starts_with("heads/") {
200            local_ref_names.insert(pointer.ref_name);
201        }
202    }
203    let remote_digests: BTreeMap<&str, PatchSetDigest> = remote
204        .iter()
205        .map(|entry| (entry.ref_name.as_str(), entry.digest))
206        .collect();
207
208    let mut all_ref_names: BTreeSet<&str> = local_ref_names.iter().map(String::as_str).collect();
209    all_ref_names.extend(remote_digests.keys().copied());
210
211    let mut comparisons = Vec::with_capacity(all_ref_names.len());
212    for ref_name in all_ref_names {
213        let is_local = local_ref_names.contains(ref_name);
214        let state = match (is_local, remote_digests.get(ref_name)) {
215            (true, Some(remote_digest)) => {
216                let local_digest = compute_patch_set_digest_for_ref(layout, ref_name)?;
217                if &local_digest == remote_digest {
218                    SyncRefComparisonState::InSync
219                } else {
220                    SyncRefComparisonState::Differs
221                }
222            }
223            (true, None) => SyncRefComparisonState::LocalOnly,
224            (false, Some(_)) => SyncRefComparisonState::RemoteOnly,
225            (false, None) => unreachable!(
226                "ref_name is drawn from local_ref_names or remote_digests, so at least one holds it"
227            ),
228        };
229        comparisons.push(SyncRefComparison {
230            ref_name: ref_name.to_string(),
231            state,
232        });
233    }
234    Ok(comparisons)
235}
236
237#[cfg(test)]
238mod tests;