prikk_store/patch_exchange/
accept.rs1use std::collections::{BTreeMap, BTreeSet};
8
9use prikk_error::{PrikkError, Result};
10use prikk_object::{ObjectEnvelope, ObjectId, ObjectType, RecognitionClaimPayload, SignerRole};
11
12use crate::author_key_index::{
13 check_author_key_conflict, lookup_author_key_entries, record_author_key_material,
14 verify_author_signature_against_material,
15};
16use crate::layout::RepositoryLayout;
17use crate::lock::ActiveLock;
18use crate::object_store::{ObjectReadSnapshot, ObjectWriteSession, ObjectWriter};
19use crate::patch_replay::decode::{
20 DecodedDeletePreimage, DecodedOperationKind, decode_patch_operations, decode_patch_parent_ids,
21};
22use crate::patch_set_digest::compute_patch_set_digest;
23use crate::recognition_claim::{
24 check_recognition_claim_consistency, maintainer_trust_policy_or_empty, verify_claim_signature,
25};
26use crate::tag_travel::verify_tag_signature;
27use crate::verify::AuthorSignatureVerification;
28
29pub use crate::recognition_claim::ClaimSignatureVerification;
30pub use crate::tag_travel::TagSignatureVerification;
31
32use super::artifact::{
33 DEFAULT_EXCHANGE_ARTIFACT_MAX_OBJECT_COUNT, DEFAULT_EXCHANGE_ARTIFACT_MAX_TOTAL_BYTES,
34 decode_exchange_artifact,
35};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct AcceptOptions {
41 pub max_object_count: usize,
44 pub max_total_bytes: usize,
46}
47
48impl AcceptOptions {
49 #[must_use]
52 pub const fn default_limits() -> Self {
53 Self {
54 max_object_count: DEFAULT_EXCHANGE_ARTIFACT_MAX_OBJECT_COUNT,
55 max_total_bytes: DEFAULT_EXCHANGE_ARTIFACT_MAX_TOTAL_BYTES,
56 }
57 }
58
59 #[must_use]
61 pub const fn with_max_object_count(mut self, max_object_count: usize) -> Self {
62 self.max_object_count = max_object_count;
63 self
64 }
65
66 #[must_use]
68 pub const fn with_max_total_bytes(mut self, max_total_bytes: usize) -> Self {
69 self.max_total_bytes = max_total_bytes;
70 self
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct AcceptReport {
77 pub patch_count: usize,
79 pub blob_count: usize,
81 pub claim_count: usize,
83 pub tag_count: usize,
85 pub written_object_count: usize,
88 pub recorded_author_key_count: usize,
92 pub author_signature_outcomes: Vec<(ObjectId, AuthorSignatureVerification)>,
96 pub claim_signature_outcomes: Vec<(ObjectId, ClaimSignatureVerification)>,
98 pub tag_signature_outcomes: Vec<(ObjectId, TagSignatureVerification)>,
101}
102
103pub fn accept_exchange_artifact(
111 layout: &RepositoryLayout,
112 bytes: &[u8],
113 options: &AcceptOptions,
114) -> Result<AcceptReport> {
115 if bytes.len() > options.max_total_bytes {
117 return Err(PrikkError::MalformedData(format!(
118 "patch-exchange artifact is {} bytes, over the configured limit of {} bytes",
119 bytes.len(),
120 options.max_total_bytes
121 )));
122 }
123
124 let decoded = decode_exchange_artifact(bytes, options.max_object_count)?;
128
129 let mut decoded_patch_ids: Vec<ObjectId> = decoded
131 .patches
132 .iter()
133 .map(ObjectEnvelope::object_id)
134 .collect();
135 decoded_patch_ids.sort_unstable();
136 decoded_patch_ids.dedup();
137 let recomputed_digest = compute_patch_set_digest(&decoded_patch_ids)?;
138 if recomputed_digest != decoded.declared_digest {
139 return Err(PrikkError::Integrity(
140 "patch-exchange artifact's declared patch-set digest does not match its own decoded \
141 patches -- refusing before any signature work"
142 .to_string(),
143 ));
144 }
145
146 let mut artifact_key_ids: BTreeMap<&str, [u8; 32]> = BTreeMap::new();
150 for entry in &decoded.author_keys {
151 match artifact_key_ids.get(entry.key_id.as_str()) {
152 Some(existing) if *existing != entry.public_key => {
153 return Err(PrikkError::MalformedData(format!(
154 "patch-exchange artifact's author-key section carries two different public \
155 keys for key_id {} -- refusing the whole exchange",
156 entry.key_id
157 )));
158 }
159 Some(_) => {}
160 None => {
161 artifact_key_ids.insert(&entry.key_id, entry.public_key);
162 }
163 }
164 }
165
166 for (&key_id, &public_key) in &artifact_key_ids {
170 check_author_key_conflict(layout, key_id, public_key)?;
171 }
172
173 let read_snapshot = ObjectReadSnapshot::open(layout)?;
185 let artifact_blob_ids: BTreeSet<ObjectId> = decoded
186 .blobs
187 .iter()
188 .map(ObjectEnvelope::object_id)
189 .collect();
190 for envelope in &decoded.patches {
191 let parent_patch_ids = decode_patch_parent_ids(&envelope.canonical_payload)?;
192 if !parent_patch_ids.is_empty() {
193 return Err(PrikkError::Integrity(format!(
194 "patch {} carries a non-empty parent_patch_ids -- this field is always empty \
195 today and there is nothing defined to walk there yet; refusing rather than \
196 silently ignoring it",
197 envelope.object_id()
198 )));
199 }
200 for operation in
201 decode_patch_operations(&envelope.canonical_payload, envelope.schema_version)?
202 {
203 for blob_id in referenced_blob_ids(&operation.kind) {
204 if !artifact_blob_ids.contains(&blob_id)
205 && !read_snapshot.contains_object(ObjectType::Blob, blob_id)
206 {
207 return Err(PrikkError::Integrity(format!(
208 "patch {} references blob {blob_id}, which is neither carried by this \
209 artifact nor already present in this repository -- refusing the whole \
210 exchange, no partial apply",
211 envelope.object_id()
212 )));
213 }
214 }
215 }
216 }
217
218 let mut author_signature_outcomes = Vec::with_capacity(decoded.patches.len());
222 for envelope in &decoded.patches {
223 let Some(signature) = envelope
224 .signatures
225 .iter()
226 .find(|signature| signature.signer_role == SignerRole::Author)
227 else {
228 continue;
229 };
230 let mut candidates = lookup_author_key_entries(layout, &signature.key_id)?;
231 candidates.extend(
232 decoded
233 .author_keys
234 .iter()
235 .filter(|entry| entry.key_id == signature.key_id)
236 .cloned(),
237 );
238 let Some((key_id, verifies)) =
239 verify_author_signature_against_material(envelope, &candidates)?
240 else {
241 continue;
242 };
243 let outcome = if verifies {
244 AuthorSignatureVerification::Sound { key_id }
245 } else {
246 AuthorSignatureVerification::Unverifiable { key_id }
247 };
248 author_signature_outcomes.push((envelope.object_id(), outcome));
249 }
250
251 let trust_policy = maintainer_trust_policy_or_empty(layout)?;
254 let mut claim_signature_outcomes = Vec::with_capacity(decoded.claims.len());
255 for envelope in &decoded.claims {
256 let claim_id = envelope.object_id();
257 let outcome = verify_claim_signature(envelope, &trust_policy)?;
258 claim_signature_outcomes.push((claim_id, outcome));
259
260 let payload = RecognitionClaimPayload::decode_canonical(&envelope.canonical_payload)?;
261 match check_recognition_claim_consistency(&read_snapshot, &payload)? {
262 crate::recognition_claim::RecognitionClaimConsistency::Contradicted { .. } => {
263 return Err(PrikkError::Integrity(format!(
264 "recognition claim {claim_id} contradicts a block this repository already \
265 holds -- refusing the whole exchange"
266 )));
267 }
268 crate::recognition_claim::RecognitionClaimConsistency::Consistent
269 | crate::recognition_claim::RecognitionClaimConsistency::BlockAbsent => {}
270 }
271 }
272
273 let mut tag_signature_outcomes = Vec::with_capacity(decoded.tags.len());
279 for envelope in &decoded.tags {
280 let tag_id = envelope.object_id();
281 let outcome = verify_tag_signature(envelope, &trust_policy)?;
282 tag_signature_outcomes.push((tag_id, outcome));
283 }
284
285 let mut object_store = ObjectWriteSession::open(layout)?;
289 let mut written_object_count = 0_usize;
290 for envelope in decoded.patches.iter().chain(decoded.blobs.iter()) {
291 let id = envelope.object_id();
292 if !object_store.contains_object(envelope.object_type, id)? {
293 written_object_count = written_object_count.checked_add(1).ok_or_else(|| {
294 PrikkError::Integrity("exchange accept written-object count overflow".to_string())
295 })?;
296 }
297 object_store.write_object(envelope)?;
298 }
299
300 let mut recorded_author_key_count = 0_usize;
305 {
306 let active_lock = ActiveLock::acquire(layout)?;
307 for (&key_id, &public_key) in &artifact_key_ids {
308 check_author_key_conflict(layout, key_id, public_key)?;
309 }
310 for entry in &decoded.author_keys {
311 record_author_key_material(layout, &entry.key_id, entry.public_key, &active_lock)?;
312 recorded_author_key_count =
313 recorded_author_key_count.checked_add(1).ok_or_else(|| {
314 PrikkError::Integrity(
315 "exchange accept recorded-author-key count overflow".to_string(),
316 )
317 })?;
318 }
319 }
320
321 for envelope in decoded.claims.iter().chain(decoded.tags.iter()) {
331 let id = envelope.object_id();
332 if !object_store.contains_object(envelope.object_type, id)? {
333 written_object_count = written_object_count.checked_add(1).ok_or_else(|| {
334 PrikkError::Integrity("exchange accept written-object count overflow".to_string())
335 })?;
336 }
337 object_store.write_object(envelope)?;
338 }
339
340 Ok(AcceptReport {
341 patch_count: decoded.patches.len(),
342 blob_count: decoded.blobs.len(),
343 claim_count: decoded.claims.len(),
344 tag_count: decoded.tags.len(),
345 written_object_count,
346 recorded_author_key_count,
347 author_signature_outcomes,
348 claim_signature_outcomes,
349 tag_signature_outcomes,
350 })
351}
352
353fn referenced_blob_ids(kind: &DecodedOperationKind) -> Vec<ObjectId> {
357 match kind {
358 DecodedOperationKind::CreateFile { blob_id, .. } => vec![*blob_id],
359 DecodedOperationKind::ReplaceBinary {
360 old_blob_id,
361 new_blob_id,
362 ..
363 } => vec![*old_blob_id, *new_blob_id],
364 DecodedOperationKind::DeleteNode {
365 preimage: DecodedDeletePreimage::File { old_blob_id, .. },
366 ..
367 } => vec![*old_blob_id],
368 _ => Vec::new(),
369 }
370}
371
372#[cfg(test)]
373mod tests;