Skip to main content

prikk_store/sync_negotiation/
have_list.rs

1//! RFC 116 stage 2 (design-v1.md N1, §1.3; handoff §1, §4): the `PSYNCHV1` have-list -- one ref,
2//! its declared [`PatchSetDigest`], and the full patch-id list the digest is over. Sent receiver ->
3//! sender (design §1.2 step 2), so the sender can compute the delta ([`super::compute_sync_delta`]).
4//!
5//! **Representational, not frozen** (RFC 114 §3): carries no identity of its own.
6//!
7//! **Self-consistency (§1.3): the digest is recomputed over the decoded list and checked, never
8//! trusted from the wire.** [`compute_patch_set_digest`] already refuses a list that is not
9//! sorted-and-deduplicated, so a truncated or reordered list either fails there or fails the digest
10//! comparison below it -- no separate sortedness check is needed here.
11//!
12//! **Constructs no `RecognitionClaimPayload`** (handoff §6) -- only patch ids.
13
14use prikk_error::{PrikkError, Result};
15use prikk_object::{ObjectId, ObjectType, RefKind, RefStatePayload};
16
17use crate::byte_cursor::ByteCursor;
18use crate::file_codec::{push_string_u16, push_u64};
19use crate::fsutil::len_to_u64;
20use crate::layout::RepositoryLayout;
21use crate::object_store::{ObjectReadSnapshot, ObjectReader};
22use crate::patch_set_digest::{
23    PatchSetDigest, compute_patch_set_digest, patch_ids_reachable_from_block,
24};
25use crate::refs::{RefStore, validate_local_branch_ref};
26
27const HAVE_LIST_MAGIC: &[u8; 8] = b"PSYNCHV1";
28
29/// DC-86 bound on the have-list's declared patch count, checked before the list is allocated.
30pub const DEFAULT_HAVE_LIST_MAX_PATCH_COUNT: usize = 100_000;
31
32/// DC-86 bound on the have-list's total encoded byte length, checked before decoding starts.
33pub const DEFAULT_HAVE_LIST_MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
34
35/// A decoded, self-consistency-checked `PSYNCHV1` have-list.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct HaveList {
38    /// The one ref this have-list is about.
39    pub ref_name: String,
40    /// The declared digest -- verified to match `patch_ids` at decode time.
41    pub digest: PatchSetDigest,
42    /// Every patch id the sender of this message already holds for `ref_name`, sorted and
43    /// deduplicated (the shape [`compute_patch_set_digest`]'s input requires).
44    pub patch_ids: Vec<ObjectId>,
45}
46
47/// Build a `PSYNCHV1` have-list for `ref_name`, from this repository's own current state.
48///
49/// **A ref this repository does not hold locally encodes an empty list, not a refusal** (design §5
50/// item 6 / N5 item 6): the receiver of a summary entry it has no local counterpart for still needs
51/// to say "I have nothing under this name" so the sender can compute a delta that is everything.
52pub fn build_have_list(layout: &RepositoryLayout, ref_name: &str) -> Result<Vec<u8>> {
53    let canonical_ref = validate_local_branch_ref(ref_name)?;
54    let ref_store = RefStore::new(layout.clone());
55    let object_store = ObjectReadSnapshot::open(layout)?;
56    let patch_ids = match ref_store.read_current_ref_state_id(&canonical_ref)? {
57        Some(ref_state_id) => {
58            let envelope = object_store
59                .read_typed(ref_state_id, ObjectType::RefState)?
60                .ok_or_else(|| {
61                    PrikkError::Integrity(format!(
62                        "ref {canonical_ref} names missing RefState {ref_state_id}"
63                    ))
64                })?;
65            let payload = RefStatePayload::decode_canonical(
66                &envelope.canonical_payload,
67                envelope.schema_version,
68            )?;
69            if payload.kind != RefKind::Branch {
70                return Err(PrikkError::Integrity(format!(
71                    "ref {canonical_ref} is under heads/ but its RefState kind is not Branch"
72                )));
73            }
74            patch_ids_reachable_from_block(&object_store, payload.target_object_id)?
75        }
76        None => Vec::new(),
77    };
78    let digest = compute_patch_set_digest(&patch_ids)?;
79
80    let mut out = Vec::new();
81    out.extend_from_slice(HAVE_LIST_MAGIC);
82    push_string_u16(&mut out, &canonical_ref)?;
83    out.extend_from_slice(&digest.0);
84    push_u64(&mut out, len_to_u64(patch_ids.len())?);
85    for patch_id in &patch_ids {
86        out.extend_from_slice(patch_id.as_bytes());
87    }
88    Ok(out)
89}
90
91/// Decode and self-consistency-check a `PSYNCHV1` have-list (§1.3). Bounds the total byte length
92/// before touching the bytes at all, then the declared patch count before allocating the list --
93/// the same DC-86 shape `decode_exchange_artifact` follows.
94pub fn decode_have_list(
95    bytes: &[u8],
96    max_total_bytes: usize,
97    max_patch_count: usize,
98) -> Result<HaveList> {
99    if bytes.len() > max_total_bytes {
100        return Err(PrikkError::MalformedData(format!(
101            "have-list is {} bytes, over the configured limit of {max_total_bytes} bytes",
102            bytes.len()
103        )));
104    }
105    let mut cursor = ByteCursor::new(bytes);
106    let magic = cursor.read_array::<8>()?;
107    if &magic != HAVE_LIST_MAGIC {
108        return Err(PrikkError::MalformedData(
109            "invalid have-list magic".to_string(),
110        ));
111    }
112    let ref_name = cursor.read_string_u16()?;
113    let declared_digest = PatchSetDigest(cursor.read_array::<32>()?);
114    let patch_count = cursor.read_u64()?;
115    if patch_count > len_to_u64(max_patch_count)? {
116        return Err(PrikkError::MalformedData(format!(
117            "have-list declares {patch_count} patch ids, over the configured limit of \
118             {max_patch_count}"
119        )));
120    }
121    let mut patch_ids = Vec::new();
122    for _ in 0..patch_count {
123        patch_ids.push(ObjectId::from_bytes(cursor.read_array::<32>()?));
124    }
125    if !cursor.is_finished() {
126        return Err(PrikkError::MalformedData(
127            "trailing bytes in have-list".to_string(),
128        ));
129    }
130
131    // §1.3: recompute the digest over the list actually received and refuse on mismatch --
132    // `compute_patch_set_digest` already refuses an unsorted or duplicate-bearing list, so a
133    // truncated or reordered list fails there before the comparison below even runs.
134    let recomputed_digest = compute_patch_set_digest(&patch_ids)?;
135    if recomputed_digest != declared_digest {
136        return Err(PrikkError::Integrity(
137            "have-list's declared patch-set digest does not match its own carried list".to_string(),
138        ));
139    }
140
141    Ok(HaveList {
142        ref_name,
143        digest: declared_digest,
144        patch_ids,
145    })
146}
147
148#[cfg(test)]
149mod tests;