Skip to main content

hashiverse_lib/protocol/posting/
encoded_post_bundle_feedback.rs

1//! # `EncodedPostBundleFeedbackV1` — server-signed aggregate feedback for a bundle
2//!
3//! Feedback (reactions, upvotes/downvotes, flags) on the posts in a single
4//! `(location, time bucket)` is aggregated into its own bundle, parallel to — but
5//! independent of — the [`crate::protocol::posting::encoded_post_bundle`]. The two are
6//! split because feedback can change long after the post bundle itself has sealed, and
7//! many consumers don't need the feedback layer at all.
8//!
9//! Wire format:
10//!
11//! - **Header** (server-signed) — lists every feedback entry's `(post_id,
12//!   feedback_type, salt, pow)` along with a hash of the body. Signed by the
13//!   publishing [`crate::protocol::peer::Peer`].
14//! - **Body** — a packed array of fixed-size
15//!   [`crate::protocol::posting::encoded_post_feedback::EncodedPostFeedbackV1`] entries,
16//!   exactly `N * ENTRY_SIZE` bytes. Fixed sizing means iteration and verification are
17//!   O(N) with no per-entry header overhead.
18//!
19//! `verify()` checks the signature, confirms body length is a multiple of `ENTRY_SIZE`,
20//! re-hashes the body and compares against the header's hash, and checks the
21//! proof-of-work on every entry against the numeraire — Sybil-resistant voting in one
22//! pass.
23
24use crate::protocol::peer::Peer;
25use crate::protocol::posting::encoded_post_feedback::{EncodedPostFeedbackV1, EncodedPostFeedbackViewV1, ENTRY_SIZE};
26use crate::tools::time::TimeMillis;
27use crate::tools::types::{Hash, Id, Pow, Salt, Signature, SignatureKey, VerificationKey};
28use std::collections::HashMap;
29use crate::tools::{hashing, json, signing};
30use bytes::{Buf, BufMut, Bytes, BytesMut};
31use serde::{Deserialize, Serialize};
32use std::fmt::{Debug, Display};
33use crate::anyhow_assert_eq;
34
35// Contains all the posts for a given location_id in a particular bucket
36#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
37pub struct EncodedPostBundleFeedbackHeaderV1 {
38    pub time_millis: TimeMillis, // When was this bundle last updated
39    pub location_id: Id,
40    pub feedbacks_bytes_hash: Hash, // Hash(feedbacks_bytes from the body)
41    pub peer: Peer,
42
43    pub signature: Signature, // Peer.sign(get_hash_for_signing())
44}
45
46impl EncodedPostBundleFeedbackHeaderV1 {
47    pub fn get_hash_for_signing(&self) -> Hash {
48        let time_millis_be = self.time_millis.encode_be();
49
50        let hash_input: Vec<&[u8]> = vec![
51            time_millis_be.as_ref(),
52            self.location_id.as_ref(),
53            self.feedbacks_bytes_hash.as_ref(),
54            self.peer.signature.as_ref(),
55        ];
56
57        hashing::hash_multiple(&hash_input)
58    }
59
60    pub fn signature_generate(&mut self, signature_key: &SignatureKey) {
61        let hash = self.get_hash_for_signing();
62        self.signature = signing::sign(signature_key, hash.as_ref());
63    }
64
65    pub fn signature_verify(&self) -> anyhow::Result<()> {
66        let hash = self.get_hash_for_signing();
67        let verification_key = VerificationKey::from_bytes(&self.peer.verification_key_bytes)?;
68        signing::verify(&verification_key, &self.signature, hash.as_ref())
69    }
70
71    pub fn verify(&self) -> anyhow::Result<()> {
72        self.signature_verify()?;
73        Ok(())
74    }
75}
76
77impl Display for EncodedPostBundleFeedbackHeaderV1 {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "EncodedPostBundleFeedbackHeaderV1 [ location_id: {}, time_millis: {} ]", self.location_id, self.time_millis)
80    }
81}
82
83#[derive(Debug, PartialEq, Clone)]
84pub struct EncodedPostBundleFeedbackV1 {
85    pub header: EncodedPostBundleFeedbackHeaderV1,
86    pub feedbacks_bytes: Bytes, // A concatenated array of EncodedPostFeedbackV1
87}
88impl Display for EncodedPostBundleFeedbackV1 {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        write!(f, "EncodedPostBundleV1 [ header: {}, length: {} ]", self.header, self.feedbacks_bytes.len())
91    }
92}
93
94impl EncodedPostBundleFeedbackV1 {
95    pub fn to_bytes(&self) -> anyhow::Result<Bytes> {
96        let mut bytes = BytesMut::new();
97
98        let header = json::struct_to_bytes(&self.header)?;
99        bytes.put_u8(1u8); // Version
100        bytes.put_u64(header.len() as u64);
101        bytes.put_u64(self.feedbacks_bytes.len() as u64);
102        bytes.put_slice(header.as_ref());
103        bytes.put_slice(self.feedbacks_bytes.as_ref());
104
105        Ok(bytes.freeze())
106    }
107
108    pub fn from_bytes(mut bytes: Bytes) -> anyhow::Result<Self> {
109        if bytes.remaining() < 1 {
110            anyhow::bail!("Invalid buffer: missing post_bundle version");
111        }
112        let version = bytes.get_u8();
113        if 1 != version {
114            anyhow::bail!("Invalid buffer: unknown post_bundle version");
115        }
116
117        if bytes.remaining() < 16 {
118            anyhow::bail!("Invalid buffer: missing post_bundle lengths");
119        }
120
121        let header_len = bytes.get_u64() as usize;
122        let body_len = bytes.get_u64() as usize;
123        let total_length = header_len.checked_add(body_len).ok_or_else(|| anyhow::anyhow!("total_length overflow"))?;
124        if bytes.remaining() < total_length {
125            anyhow::bail!("Invalid buffer: post_bundle data truncated");
126        }
127        let header_bytes = bytes.copy_to_bytes(header_len);
128        let body_bytes = bytes.copy_to_bytes(body_len);
129
130        let header = json::bytes_to_struct(&header_bytes)?;
131
132        Ok(EncodedPostBundleFeedbackV1 { header, feedbacks_bytes: body_bytes })
133    }
134
135    /// Verifies that this bundle is legitimate enough to cache.
136    ///
137    /// Checks:
138    /// 1. The bundle header is valid and its signature verifies.
139    /// 2. The feedback body is a valid multiple of `ENTRY_SIZE` (no partial entries).
140    /// 3. The header's hash is the same as the body's
141    /// 4. Each feedback entry's proof-of-work is correct.
142    pub fn verify(&self) -> anyhow::Result<()> {
143        self.verify_envelope()?;
144        self.verify_entries_pow()
145    }
146
147    /// Checks (1)-(3) of [`Self::verify`]: the bundle is intact and signed as its header claims, without checking each entry's PoW.
148    fn verify_envelope(&self) -> anyhow::Result<()> {
149        // (1) Header signature
150        self.header.verify()?;
151
152        // (2) Body must be an exact multiple of the fixed entry size
153        if !self.feedbacks_bytes.len().is_multiple_of(ENTRY_SIZE) {
154            anyhow::bail!(
155                "feedbacks_bytes length ({}) is not a multiple of ENTRY_SIZE ({})",
156                self.feedbacks_bytes.len(),
157                ENTRY_SIZE
158            );
159        }
160
161        // (3) Hash
162        let feedbacks_bytes_hash = hashing::hash(self.feedbacks_bytes.as_ref());
163        anyhow_assert_eq!(feedbacks_bytes_hash, self.header.feedbacks_bytes_hash, "feedbacks_bytes_hash mismatch");
164        Ok(())
165    }
166
167    /// Check (4) of [`Self::verify`].
168    fn verify_entries_pow(&self) -> anyhow::Result<()> {
169        // (4) PoW check for every entry
170        let num_entries = self.feedbacks_bytes.len() / ENTRY_SIZE;
171        for i in 0..num_entries {
172            let entry_bytes = &self.feedbacks_bytes[i * ENTRY_SIZE..(i + 1) * ENTRY_SIZE];
173            let feedback = EncodedPostFeedbackV1::decode_from_bytes(&mut &entry_bytes[..])
174                .map_err(|e| anyhow::anyhow!("feedback {}: invalid entry: {}", i, e))?;
175            feedback.pow_verify()
176                .map_err(|e| anyhow::anyhow!("feedback {}: pow verification failed: {}", i, e))?;
177        }
178
179        Ok(())
180    }
181
182    /// Verifies that this bundle is a legitimate feedback bundle for `location_id`, as received from the network.
183    ///
184    /// On top of [`EncodedPostBundleFeedbackV1::verify`]:
185    /// 1. The publishing peer is a genuine server identity (server-id PoW, id derived from its key, self-signed).
186    ///    [`EncodedPostBundleFeedbackHeaderV1::verify`] alone only checks the header against the key the header itself supplies.
187    /// 2. The bundle is for this location_id.
188    pub fn verify_for_location(&self, location_id: &Id) -> anyhow::Result<()> {
189        self.verify_origin_for_location(location_id)?;
190        self.verify_entries_pow()
191    }
192
193    /// [`Self::verify_for_location`] minus the per-entry PoW check: proves a genuine server published this bundle for `location_id`,
194    /// but tolerates individual entries with bad PoW, which [`Self::merge`] drops one at a time.  For a server's own (direct) copy, so that
195    /// one bad entry it holds cannot erase all of its other feedback.  Never forward a bundle that has only passed this check.
196    pub fn verify_origin_for_location(&self, location_id: &Id) -> anyhow::Result<()> {
197        self.header.peer.verify().map_err(|e| anyhow::anyhow!("feedback bundle peer {} failed verification: {}", self.header.peer.id, e))?;
198        if self.header.location_id != *location_id {
199            anyhow::bail!("feedback bundle location_id {} does not match location_id {}", self.header.location_id, location_id);
200        }
201        self.verify_envelope()
202    }
203
204    pub fn get_post_pow_for_feedback_type(&self, post_id: &Id, feedback_type: u8) -> Pow {
205        for view in EncodedPostFeedbackViewV1::iter(&self.feedbacks_bytes).flatten() {
206            // Check feedback_type first as it short circuits and is therefore faster...
207            if view.feedback_type() == feedback_type && view.post_id_bytes() == post_id.as_ref() {
208                return view.pow();
209            }
210        }
211        Pow(0)
212    }
213
214    /// Unions an array of bundles by taking the highest-pow entry for each
215    /// `(post_id, feedback_type)` pair across all bundles.  The header is
216    /// taken from the most-recently-timestamped bundle.  Returns `None` if
217    /// `bundles` is empty.
218    ///
219    /// Entries whose claimed pow does not match what their salt achieves are
220    /// dropped individually.  The bundle header signature only proves which
221    /// server produced the bundle, not that its entries are honest, so a
222    /// dishonest (or poisoned) server must not be able to plant pow=255 flags.
223    /// Dropping per-entry rather than rejecting the whole bundle means one bad
224    /// entry cannot erase all other feedback for the bucket.
225    pub fn merge(bundles: &[Self]) -> Option<Self> {
226        let header = bundles.iter().max_by_key(|b| b.header.time_millis)?.header.clone();
227
228        let mut global_max: HashMap<(Id, u8), EncodedPostFeedbackV1> = HashMap::new();
229        for bundle in bundles {
230            for view in EncodedPostFeedbackViewV1::iter(&bundle.feedbacks_bytes) {
231                let Ok(view) = view else { continue };
232                let Ok(post_id) = Id::from_slice(view.post_id_bytes()) else { continue };
233                let Ok(salt) = Salt::from_slice(view.salt_bytes()) else { continue };
234                let encoded_post_feedback = EncodedPostFeedbackV1::new(post_id, view.feedback_type(), salt, view.pow());
235                if encoded_post_feedback.pow_verify().is_err() {
236                    continue;
237                }
238
239                let key = (post_id, encoded_post_feedback.feedback_type);
240                match global_max.entry(key) {
241                    std::collections::hash_map::Entry::Vacant(vacant) => {
242                        vacant.insert(encoded_post_feedback);
243                    }
244                    std::collections::hash_map::Entry::Occupied(mut occupied) => {
245                        if encoded_post_feedback.pow > occupied.get().pow {
246                            occupied.insert(encoded_post_feedback);
247                        }
248                    }
249                }
250            }
251        }
252
253        let mut feedbacks_bytes_mut = Vec::new();
254        for feedback in global_max.values() {
255            let _ = feedback.append_encode_to_bytes(&mut feedbacks_bytes_mut);
256        }
257
258        Some(Self { header, feedbacks_bytes: Bytes::from(feedbacks_bytes_mut) })
259    }
260
261    pub fn get_post_pows(&self, post_id: &Id) -> [Pow; 256] {
262        let mut result = [Pow(0); 256];
263        for view in EncodedPostFeedbackViewV1::iter(&self.feedbacks_bytes).flatten() {
264            if  view.post_id_bytes() == post_id.as_ref() {
265                result[view.feedback_type() as usize] = view.pow();
266            }
267        }
268
269        result
270    }
271}
272
273#[cfg(test)]
274pub(crate) mod test_fixtures {
275    use super::*;
276    use crate::tools::pow;
277    use crate::tools::server_id::ServerId;
278    use crate::tools::time_provider::time_provider::RealTimeProvider;
279
280    /// A feedback entry whose pow is exactly what its random salt achieves, so it passes `pow_verify`.
281    pub(crate) fn make_valid_feedback(post_id: Id, feedback_type: u8) -> anyhow::Result<EncodedPostFeedbackV1> {
282        let salt = Salt::random();
283        let (measured_pow, _) = pow::pow_measure(&[post_id.as_bytes(), &[feedback_type]], &salt)?;
284        let encoded_post_feedback = EncodedPostFeedbackV1::new(post_id, feedback_type, salt, measured_pow);
285        encoded_post_feedback.pow_verify()?;
286        Ok(encoded_post_feedback)
287    }
288
289    /// A feedback entry claiming `Pow(255)` with a random salt — what an attacker would plant.
290    pub(crate) fn make_forged_feedback(post_id: Id, feedback_type: u8) -> EncodedPostFeedbackV1 {
291        let encoded_post_feedback = EncodedPostFeedbackV1::new(post_id, feedback_type, Salt::random(), Pow(255));
292        assert!(encoded_post_feedback.pow_verify().is_err());
293        encoded_post_feedback
294    }
295
296    /// A bundle of `encoded_post_feedbacks` for `location_id`, signed by `server_id` as it would publish it.
297    pub(crate) fn make_bundle_signed_by(server_id: &ServerId, location_id: Id, time_millis: TimeMillis, encoded_post_feedbacks: &[EncodedPostFeedbackV1]) -> anyhow::Result<EncodedPostBundleFeedbackV1> {
298        let peer = server_id.to_peer(&RealTimeProvider)?;
299
300        let mut feedbacks_bytes_mut = Vec::new();
301        for encoded_post_feedback in encoded_post_feedbacks {
302            encoded_post_feedback.append_encode_to_bytes(&mut feedbacks_bytes_mut)?;
303        }
304        let feedbacks_bytes = Bytes::from(feedbacks_bytes_mut);
305
306        let mut header = EncodedPostBundleFeedbackHeaderV1 {
307            time_millis,
308            location_id,
309            feedbacks_bytes_hash: hashing::hash(feedbacks_bytes.as_ref()),
310            peer,
311            signature: Signature::zero(),
312        };
313        header.signature_generate(&server_id.keys.signature_key);
314        Ok(EncodedPostBundleFeedbackV1 { header, feedbacks_bytes })
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use super::test_fixtures::{make_bundle_signed_by, make_forged_feedback, make_valid_feedback};
322    use crate::tools::server_id::ServerId;
323    use crate::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
324    use crate::tools::pow;
325    use crate::tools::pow_generator::pow_generator::PowGenerator;
326    use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
327
328    /// Builds a valid single-entry feedback bundle.
329    async fn make_valid_bundle() -> anyhow::Result<EncodedPostBundleFeedbackV1> {
330        let time_provider = RealTimeProvider;
331        let pow_generator = SingleThreadedPowGenerator::new();
332        let server_id = ServerId::new("own_pow", &time_provider, Pow(0), true, &pow_generator).await?;
333        let peer = server_id.to_peer(&time_provider)?;
334
335        let post_id = Id::random();
336        let feedback_type = 1u8;
337        // One iteration with Pow(0) always succeeds immediately — cheap in tests
338        let data_hash = pow::pow_compute_data_hash(&[post_id.as_bytes(), &[feedback_type]]);
339        let (salt, achieved_pow, _) = pow_generator.generate_best_effort("make_valid_bundle_feedback", 1, Pow(0), data_hash).await?;
340        let feedback = EncodedPostFeedbackV1::new(post_id, feedback_type, salt, achieved_pow);
341
342        let mut feedbacks_bytes_mut = Vec::new();
343        feedback.append_encode_to_bytes(&mut feedbacks_bytes_mut)?;
344        let feedbacks_bytes = Bytes::from(feedbacks_bytes_mut);
345
346        let mut header = EncodedPostBundleFeedbackHeaderV1 {
347            time_millis: time_provider.current_time_millis(),
348            location_id: Id::random(),
349            feedbacks_bytes_hash: hashing::hash(feedbacks_bytes.as_ref()),
350            peer,
351            signature: Signature::zero(),
352        };
353        header.signature_generate(&server_id.keys.signature_key);
354
355        Ok(EncodedPostBundleFeedbackV1 { header, feedbacks_bytes })
356    }
357
358    #[tokio::test]
359    async fn test_verify_valid_bundle() -> anyhow::Result<()> {
360        let bundle = make_valid_bundle().await?;
361        bundle.verify()
362    }
363
364    #[tokio::test]
365    async fn test_verify_bad_header_signature() -> anyhow::Result<()> {
366        let mut bundle = make_valid_bundle().await?;
367        bundle.header.signature = Signature::zero();
368        assert!(bundle.verify().is_err());
369        Ok(())
370    }
371
372    #[tokio::test]
373    async fn test_verify_wrong_feedbacks_hash() -> anyhow::Result<()> {
374        let mut bundle = make_valid_bundle().await?;
375        let pow_generator = SingleThreadedPowGenerator::new();
376        let server_id = ServerId::new("own_pow", &RealTimeProvider, Pow(0), true, &pow_generator).await?;
377        bundle.header.feedbacks_bytes_hash = hashing::hash(b"wrong");
378        bundle.header.signature_generate(&server_id.keys.signature_key); // re-sign so header sig itself is valid
379        assert!(bundle.verify().is_err());
380        Ok(())
381    }
382
383    #[tokio::test]
384    async fn test_verify_partial_entry() -> anyhow::Result<()> {
385        let mut bundle = make_valid_bundle().await?;
386        let pow_generator = SingleThreadedPowGenerator::new();
387        let server_id = ServerId::new("own_pow", &RealTimeProvider, Pow(0), true, &pow_generator).await?;
388        // Append one extra byte to make the length not a multiple of ENTRY_SIZE
389        let mut bytes = bundle.feedbacks_bytes.to_vec();
390        bytes.push(0u8);
391        bundle.feedbacks_bytes = Bytes::from(bytes);
392        bundle.header.feedbacks_bytes_hash = hashing::hash(bundle.feedbacks_bytes.as_ref());
393        bundle.header.signature_generate(&server_id.keys.signature_key);
394        assert!(bundle.verify().is_err());
395        Ok(())
396    }
397
398    #[tokio::test]
399    async fn test_verify_wrong_pow() -> anyhow::Result<()> {
400        let mut bundle = make_valid_bundle().await?;
401        let pow_generator = SingleThreadedPowGenerator::new();
402        let server_id = ServerId::new("own_pow", &RealTimeProvider, Pow(0), true, &pow_generator).await?;
403        // Flip the last byte of the entry (the pow byte) to an incorrect value
404        let mut bytes = bundle.feedbacks_bytes.to_vec();
405        let last = bytes.last_mut().unwrap();
406        *last = last.wrapping_add(1);
407        bundle.feedbacks_bytes = Bytes::from(bytes);
408        bundle.header.feedbacks_bytes_hash = hashing::hash(bundle.feedbacks_bytes.as_ref());
409        bundle.header.signature_generate(&server_id.keys.signature_key);
410        assert!(bundle.verify().is_err());
411        Ok(())
412    }
413
414    /// Builds a feedback bundle from explicit `(post_id, feedback_type, pow)` entries with random salts.
415    /// The entries are NOT valid PoW: fine for `get_post_pows` / `get_post_pow_for_feedback_type`,
416    /// which read entries directly, but `merge` drops them — use `make_bundle_with_encoded_post_feedbacks`
417    /// with `make_valid_feedback` entries for merge tests.
418    async fn make_bundle_with_feedbacks(time_millis: TimeMillis, feedbacks: &[(Id, u8, Pow)]) -> anyhow::Result<EncodedPostBundleFeedbackV1> {
419        let encoded_post_feedbacks: Vec<EncodedPostFeedbackV1> = feedbacks.iter().map(|(post_id, feedback_type, pow)| EncodedPostFeedbackV1::new(*post_id, *feedback_type, Salt::random(), *pow)).collect();
420        make_bundle_with_encoded_post_feedbacks(time_millis, &encoded_post_feedbacks).await
421    }
422
423
424    async fn make_bundle_with_encoded_post_feedbacks(time_millis: TimeMillis, encoded_post_feedbacks: &[EncodedPostFeedbackV1]) -> anyhow::Result<EncodedPostBundleFeedbackV1> {
425        let server_id = ServerId::new("own_pow", &RealTimeProvider, Pow(0), true, &SingleThreadedPowGenerator::new()).await?;
426        make_bundle_signed_by(&server_id, Id::random(), time_millis, encoded_post_feedbacks)
427    }
428
429
430    #[tokio::test]
431    async fn get_post_pows_reports_pow_per_feedback_type() -> anyhow::Result<()> {
432        let post_a = Id::random();
433        let post_b = Id::random();
434        let bundle = make_bundle_with_feedbacks(TimeMillis(1000), &[
435            (post_a, 1, Pow(10)),
436            (post_a, 2, Pow(20)),
437            (post_b, 1, Pow(30)),
438        ]).await?;
439
440        // Per-type lookups.
441        assert_eq!(Pow(10), bundle.get_post_pow_for_feedback_type(&post_a, 1));
442        assert_eq!(Pow(20), bundle.get_post_pow_for_feedback_type(&post_a, 2));
443        assert_eq!(Pow(0), bundle.get_post_pow_for_feedback_type(&post_a, 3), "absent type => Pow(0)");
444        assert_eq!(Pow(0), bundle.get_post_pow_for_feedback_type(&post_b, 2), "post_b has no type-2 feedback");
445
446        // Full per-post arrays, indexed by feedback_type.
447        let pows_a = bundle.get_post_pows(&post_a);
448        assert_eq!(Pow(10), pows_a[1]);
449        assert_eq!(Pow(20), pows_a[2]);
450        assert_eq!(Pow(0), pows_a[0]);
451        assert_eq!(Pow(0), pows_a[3]);
452
453        let pows_b = bundle.get_post_pows(&post_b);
454        assert_eq!(Pow(30), pows_b[1]);
455        assert_eq!(Pow(0), pows_b[2]);
456        Ok(())
457    }
458
459    #[tokio::test]
460    async fn merge_keeps_highest_pow_per_post_and_type() -> anyhow::Result<()> {
461        let post_a = Id::random();
462
463        // Several valid candidates for (post_a, 1), spread over two bundles; random salts give a spread of measured pows.
464        let candidates_for_type_1: Vec<EncodedPostFeedbackV1> = (0..16).map(|_| make_valid_feedback(post_a, 1)).collect::<anyhow::Result<_>>()?;
465        let only_for_type_2 = make_valid_feedback(post_a, 2)?;
466        let expected_type_1_pow = candidates_for_type_1.iter().map(|encoded_post_feedback| encoded_post_feedback.pow).max().unwrap();
467
468        let (first_half, second_half) = candidates_for_type_1.split_at(8);
469        let mut first_bundle_feedbacks = first_half.to_vec();
470        first_bundle_feedbacks.push(only_for_type_2.clone());
471        let bundle_first = make_bundle_with_encoded_post_feedbacks(TimeMillis(1000), &first_bundle_feedbacks).await?;
472        let bundle_second = make_bundle_with_encoded_post_feedbacks(TimeMillis(2000), second_half).await?;
473
474        let merged = EncodedPostBundleFeedbackV1::merge(&[bundle_first, bundle_second]).expect("non-empty input");
475
476        assert_eq!(expected_type_1_pow, merged.get_post_pow_for_feedback_type(&post_a, 1), "the highest pow for (post_a, type 1) must win");
477        assert_eq!(only_for_type_2.pow, merged.get_post_pow_for_feedback_type(&post_a, 2), "the only pow for (post_a, type 2) must be preserved");
478        merged_entries_all_verify(&merged)?;
479        Ok(())
480    }
481
482    // Regression for C3: a forged pow=255 entry (from a dishonest server, or planted via an unchecked heal)
483    // must not win the merge, must not appear for keys that only have forged entries, and must not
484    // take the honest entries of the same bundle down with it.
485    #[tokio::test]
486    async fn merge_drops_entries_with_invalid_pow() -> anyhow::Result<()> {
487        let post_a = Id::random();
488        let post_b = Id::random();
489        let valid_for_post_a = make_valid_feedback(post_a, 1)?;
490
491        let poisoned_bundle = make_bundle_with_encoded_post_feedbacks(TimeMillis(1000), &[make_forged_feedback(post_a, 1), valid_for_post_a.clone(), make_forged_feedback(post_b, 2)]).await?;
492        let honest_bundle = make_bundle_with_encoded_post_feedbacks(TimeMillis(2000), &[]).await?;
493
494        let merged = EncodedPostBundleFeedbackV1::merge(&[poisoned_bundle, honest_bundle]).expect("non-empty input");
495
496        assert_eq!(valid_for_post_a.pow, merged.get_post_pow_for_feedback_type(&post_a, 1), "the valid entry must survive alongside a forged one");
497        assert_eq!(Pow(0), merged.get_post_pow_for_feedback_type(&post_b, 2), "a key with only a forged entry must be absent");
498        assert_eq!(merged.feedbacks_bytes.len(), ENTRY_SIZE, "only the one valid entry should remain");
499        merged_entries_all_verify(&merged)?;
500        Ok(())
501    }
502
503    // merge() output is a client-side view: its header (taken from the newest bundle) no longer covers its
504    // feedbacks_bytes, so it must never be forwarded to caches - only intact originals that pass verify() may be.
505    #[tokio::test]
506    async fn merged_bundle_is_a_client_view_not_a_signed_bundle() -> anyhow::Result<()> {
507        let post_a = Id::random();
508        let valid_for_type_1 = make_valid_feedback(post_a, 1)?;
509        let valid_for_type_2 = make_valid_feedback(post_a, 2)?;
510
511        let bundle_first = make_bundle_with_encoded_post_feedbacks(TimeMillis(1000), &[valid_for_type_1.clone()]).await?;
512        let bundle_second = make_bundle_with_encoded_post_feedbacks(TimeMillis(2000), &[valid_for_type_2.clone()]).await?;
513        bundle_first.verify()?;
514        bundle_second.verify()?;
515
516        let merged = EncodedPostBundleFeedbackV1::merge(&[bundle_first, bundle_second]).expect("non-empty input");
517        assert_eq!(valid_for_type_1.pow, merged.get_post_pow_for_feedback_type(&post_a, 1));
518        assert_eq!(valid_for_type_2.pow, merged.get_post_pow_for_feedback_type(&post_a, 2));
519        assert!(merged.verify().is_err(), "the merged union is not covered by any originator's signature");
520
521        // A signed bundle carrying a forged entry is not uploadable, yet merge still salvages its honest entry for the client view.
522        let poisoned_bundle = make_bundle_with_encoded_post_feedbacks(TimeMillis(3000), &[make_forged_feedback(post_a, 3), valid_for_type_1.clone()]).await?;
523        poisoned_bundle.header.verify()?;
524        assert!(poisoned_bundle.verify().is_err(), "a bundle with a forged entry must fail full verification");
525        let merged_from_poisoned = EncodedPostBundleFeedbackV1::merge(&[poisoned_bundle]).expect("non-empty input");
526        assert_eq!(valid_for_type_1.pow, merged_from_poisoned.get_post_pow_for_feedback_type(&post_a, 1));
527        assert_eq!(Pow(0), merged_from_poisoned.get_post_pow_for_feedback_type(&post_a, 3));
528        Ok(())
529    }
530
531    // H4: a feedback bundle uploaded into a location's cache must come from a genuine (PoW-backed) server and be for that location.
532    #[tokio::test]
533    async fn verify_for_location_rejects_wrong_location_and_unworked_peer() -> anyhow::Result<()> {
534        let location_id = Id::random();
535        let encoded_post_feedbacks = [make_valid_feedback(Id::random(), 1)?];
536
537        let genuine_server_id = ServerId::new("genuine", &RealTimeProvider, crate::tools::config::SERVER_KEY_POW_MIN, true, &SingleThreadedPowGenerator::new()).await?;
538        let genuine_bundle = make_bundle_signed_by(&genuine_server_id, location_id, TimeMillis(1000), &encoded_post_feedbacks)?;
539        genuine_bundle.verify_for_location(&location_id)?;
540        assert!(genuine_bundle.verify_for_location(&Id::random()).is_err(), "a bundle for another location must be rejected");
541
542        let unworked_server_id = loop {
543            let server_id = ServerId::new("unworked", &RealTimeProvider, Pow(0), true, &SingleThreadedPowGenerator::new()).await?;
544            if server_id.pow < crate::tools::config::SERVER_KEY_POW_MIN {
545                break server_id;
546            }
547        };
548        let unworked_bundle = make_bundle_signed_by(&unworked_server_id, location_id, TimeMillis(1000), &encoded_post_feedbacks)?;
549        unworked_bundle.verify()?;
550        assert!(unworked_bundle.verify_for_location(&location_id).is_err(), "a self-signed bundle from a pow-less identity must be rejected");
551        Ok(())
552    }
553
554    #[tokio::test]
555    async fn verify_origin_for_location_tolerates_bad_entries_but_not_bad_origin() -> anyhow::Result<()> {
556        let location_id = Id::random();
557        let genuine_server_id = ServerId::new("genuine", &RealTimeProvider, crate::tools::config::SERVER_KEY_POW_MIN, true, &SingleThreadedPowGenerator::new()).await?;
558        let poisoned_bundle = make_bundle_signed_by(&genuine_server_id, location_id, TimeMillis(1000), &[make_valid_feedback(Id::random(), 1)?, make_forged_feedback(Id::random(), 2)])?;
559        poisoned_bundle.verify_origin_for_location(&location_id)?;
560        assert!(poisoned_bundle.verify_for_location(&location_id).is_err(), "full verification still rejects the forged entry");
561        assert!(poisoned_bundle.verify_origin_for_location(&Id::random()).is_err(), "a bundle for another location must be rejected");
562
563        let mut tampered_bundle = poisoned_bundle.clone();
564        tampered_bundle.feedbacks_bytes = tampered_bundle.feedbacks_bytes.slice(0..ENTRY_SIZE);
565        assert!(tampered_bundle.verify_origin_for_location(&location_id).is_err(), "bytes not covered by the signed hash must be rejected");
566        Ok(())
567    }
568
569    fn merged_entries_all_verify(merged: &EncodedPostBundleFeedbackV1) -> anyhow::Result<()> {
570        for entry_bytes in merged.feedbacks_bytes.chunks_exact(ENTRY_SIZE) {
571            EncodedPostFeedbackV1::decode_from_bytes(entry_bytes)?.pow_verify()?;
572        }
573        Ok(())
574    }
575
576    #[tokio::test]
577    async fn encoded_post_bundle_header_v1_to_from_bytes_roundtrip() -> anyhow::Result<()> {
578        let time_provider = RealTimeProvider;
579        let pow_generator = SingleThreadedPowGenerator::new();
580        let server_id = ServerId::new("own_pow", &time_provider, Pow(0), true, &pow_generator).await?;
581        let peer = server_id.to_peer(&time_provider)?;
582        let feedbacks_bytes =  Bytes::new();
583
584        let mut header = EncodedPostBundleFeedbackHeaderV1 {
585            time_millis: TimeMillis::random(),
586            location_id: Id::random(),
587            feedbacks_bytes_hash: hashing::hash(feedbacks_bytes.as_ref()),
588            peer,
589            signature: Signature::zero(),
590        };
591
592        header.signature_generate(&server_id.keys.signature_key);
593        header.verify()?;
594
595        let bundle = EncodedPostBundleFeedbackV1 { header, feedbacks_bytes };
596
597        let bytes1 = bundle.to_bytes()?;
598        let decoded = EncodedPostBundleFeedbackV1::from_bytes(bytes1.clone())?;
599
600        assert_eq!(bundle, decoded);
601
602        // Optional extra sanity check: encoding the decoded struct should be stable.
603        let bytes2 = decoded.to_bytes()?;
604        assert_eq!(bytes1, bytes2);
605
606        Ok(())
607    }
608}