Skip to main content

hashiverse_server_lib/server/handlers/
dispatch.rs

1//! # Inbound RPC dispatch loop
2//!
3//! The hot path of the server: a single async loop that drains `IncomingRequest`s
4//! from the transport's `mpsc::Receiver`, decodes each packet via
5//! [`hashiverse_lib::protocol::rpc::rpc_request::RpcRequestPacketRx`], and routes
6//! by [`hashiverse_lib::protocol::payload::payload::PayloadRequestKind`] to the
7//! correct per-op handler (bootstrap, announce, get/submit/heal/cache post
8//! bundles, get/submit/heal/cache feedback, fetch URL preview, trending
9//! hashtags, ping).
10//!
11//! Per-request safety checks happen before any real work:
12//!
13//! - **PoW verification** — the packet's PoW must be sufficient for *this* server's
14//!   identity. Anything under-powered or stale is dropped immediately.
15//! - **Replay protection** — a short-lived salt cache rejects salts we've already
16//!   seen, so a valid signed request can't be replayed from another network vantage.
17//! - **Peer upgrade** — if the caller's embedded [`hashiverse_lib::protocol::peer::Peer`] carries a stronger PoW
18//!   than what we have in the tracker, the tracker is upgraded in place.
19//!
20//! The loop respects a `CancellationToken` so graceful shutdown drains in-flight
21//! work and stops accepting new requests cleanly.
22
23use crate::environment::environment::PostBundleMetadata;
24use crate::server::hashiverse_server::HashiverseServer;
25use crate::tools::tools::is_ssrf_protected_ip;
26use bytes::{Bytes, BytesMut};
27use hashiverse_lib::anyhow_assert_eq;
28use hashiverse_lib::protocol::payload::payload::{
29    AnnounceResponseV2, AnnounceV2, BootstrapResponseV1, CachePostBundleFeedbackResponseV1, CachePostBundleFeedbackV1, CachePostBundleResponseV1, CachePostBundleV1, ErrorResponseV1, FetchUrlPreviewResponseV1, FetchUrlPreviewV1,
30    GetPostBundleFeedbackResponseV1, GetPostBundleFeedbackV1, GetPostBundleResponseV1, GetPostBundleV1, HealPostBundleClaimResponseV1, HealPostBundleClaimTokenV1, HealPostBundleClaimV1, HealPostBundleCommitResponseV1, HealPostBundleCommitV1,
31    HealPostBundleFeedbackResponseV1, HealPostBundleFeedbackV1, PayloadRequestKind, PayloadResponseKind, PingResponseV1, PeerStatsRequestV1, PeerStatsResponseV1, SubmitPostClaimResponseV1, SubmitPostClaimTokenV1, SubmitPostClaimV1,
32    SubmitPostCommitResponseV1, SubmitPostCommitTokenV1, SubmitPostCommitV1, SubmitPostFeedbackResponseV1, SubmitPostFeedbackV1, TrendingHashtagV1, TrendingHashtagsFetchResponseV1, TrendingHashtagsFetchV1,
33};
34use hashiverse_lib::protocol::peer::{Peer, PeerPow};
35use hashiverse_lib::protocol::posting::amplification::get_minimum_post_pow;
36use hashiverse_lib::protocol::posting::encoded_post::EncodedPostV1;
37use hashiverse_lib::protocol::posting::encoded_post_bundle::{EncodedPostBundleHeaderV1, EncodedPostBundleV1};
38use hashiverse_lib::protocol::posting::encoded_post_bundle_feedback::{EncodedPostBundleFeedbackHeaderV1, EncodedPostBundleFeedbackV1};
39use hashiverse_lib::protocol::rpc::rpc_request::RpcRequestPacketRx;
40use hashiverse_lib::protocol::rpc::rpc_response::{RpcResponsePacketTx, RpcResponsePacketTxFlags};
41use hashiverse_lib::tools::buckets::{BucketLocation, BucketType, BUCKET_DURATIONS};
42use hashiverse_lib::tools::time::{TimeMillis, MILLIS_IN_SECOND};
43use hashiverse_lib::tools::types::{Id, Signature};
44use hashiverse_lib::tools::{hashing, url_preview};
45use hashiverse_lib::tools::{compression, config, json, signing, BytesGatherer};
46use hashiverse_lib::tools::tools::{self, LeadingAgreementBits};
47use hashiverse_lib::transport::transport::IncomingRequest;
48use futures::FutureExt;
49use log::{error, info, trace, warn};
50use std::collections::HashSet;
51use std::future::Future;
52use std::panic::AssertUnwindSafe;
53use std::sync::atomic::Ordering;
54
55use crate::server::stats::{environment_stats_subtree, kademlia_stats_subtree, request_counts_subtree, system_stats_subtree};
56use tokio::sync::mpsc;
57use tokio_util::sync::CancellationToken;
58
59/// Fallback hashtags used to top up a trending-hashtags response when the server
60/// does not yet know enough real trending hashtags to satisfy the requested limit.
61/// Applied in order, skipping any entry whose normalised form is already present
62/// in the real trending list. Filler entries are returned with `count = 0` so
63/// clients can distinguish seeded fillers from genuine trending data.
64const TRENDING_HASHTAGS_FALLBACK: &[&str] = &["hashiverse", "news"];
65
66/// Normalise a hashtag for equality comparison: lowercase, with any leading `#`
67/// stripped. Mirrors the canonicalisation performed by `Id::from_hashtag_str`.
68fn normalise_hashtag(hashtag: &str) -> String {
69    let lowercased = hashtag.to_lowercase();
70    match lowercased.strip_prefix('#') {
71        Some(stripped) => stripped.to_string(),
72        None => lowercased,
73    }
74}
75
76/// Top up `trending_hashtags` from `fallback_hashtags` (in order) until it reaches
77/// `limit`, skipping any fallback whose normalised form already appears in the list.
78/// Filler entries are inserted with `count = 0`. No-op if the list is already at
79/// or above the limit.
80/// Is `donor_id` close enough to `location_id` that it plausibly is (or was) one of the servers responsible for that location?
81///
82/// `nearest_peer_ids` is our own view of the nearest servers to the location (as returned by `get_peers_for_key(location_id, required_count)`,
83/// which may include ties beyond `required_count`).  The donor must be no more than `HEAL_DONOR_LEADING_AGREEMENT_BITS_SLACK` bits further out
84/// than the furthest of those.  Deliberately a distance test rather than "is the donor in our view": the donor may have died long ago.
85/// If we do not yet know `required_count` servers we cannot rule anyone out.
86fn is_plausible_heal_donor(donor_id: &Id, location_id: &Id, nearest_peer_ids: &[Id], required_count: usize) -> bool {
87    if nearest_peer_ids.len() < required_count {
88        return true;
89    }
90
91    let furthest_nearest_leading_agreement_bits = nearest_peer_ids
92        .iter()
93        .map(|nearest_peer_id| tools::leading_agreement_bits_xor(location_id.as_ref(), nearest_peer_id.as_ref()))
94        .min()
95        .unwrap_or(LeadingAgreementBits::MIN);
96    let donor_leading_agreement_bits = tools::leading_agreement_bits_xor(location_id.as_ref(), donor_id.as_ref());
97
98    donor_leading_agreement_bits >= furthest_nearest_leading_agreement_bits - config::HEAL_DONOR_LEADING_AGREEMENT_BITS_SLACK
99}
100
101fn top_up_trending_hashtags_with_fallback(trending_hashtags: &mut Vec<TrendingHashtagV1>, limit: u16, fallback_hashtags: &[&str]) {
102    let target_length = limit as usize;
103    if trending_hashtags.len() >= target_length {
104        return;
105    }
106
107    let mut existing_normalised_hashtags: HashSet<String> = trending_hashtags.iter()
108        .map(|entry| normalise_hashtag(&entry.hashtag))
109        .collect();
110
111    for fallback_hashtag in fallback_hashtags {
112        if trending_hashtags.len() >= target_length {
113            break;
114        }
115        let normalised_fallback_hashtag = normalise_hashtag(fallback_hashtag);
116        if existing_normalised_hashtags.contains(&normalised_fallback_hashtag) {
117            continue;
118        }
119        trending_hashtags.push(TrendingHashtagV1 {
120            hashtag: (*fallback_hashtag).to_string(),
121            count: 0,
122        });
123        existing_normalised_hashtags.insert(normalised_fallback_hashtag);
124    }
125}
126
127/// Awaits a DNS lookup for a URL preview host, giving up after `dns_timeout`.  The dispatch loop is serial, so an
128/// unbounded lookup here would stall every other request.  `lookup` is injected so the timeout can be tested without a network.
129async fn resolve_url_preview_host<I>(host: &str, dns_timeout: std::time::Duration, lookup: impl Future<Output = std::io::Result<I>>) -> anyhow::Result<Vec<std::net::SocketAddr>>
130where
131    I: Iterator<Item = std::net::SocketAddr>,
132{
133    match tokio::time::timeout(dns_timeout, lookup).await {
134        Err(_) => anyhow::bail!("FetchUrlPreviewV1: DNS resolution timed out for {}", host),
135        Ok(Err(e)) => anyhow::bail!("FetchUrlPreviewV1 SSRF: DNS resolution failed for {}: {}", host, e),
136        Ok(Ok(socket_addrs)) => Ok(socket_addrs.collect()),
137    }
138}
139
140/// Awaits `future`, converting a panic inside it into an `Err` so that a single bad
141/// request cannot unwind through the dispatch loop and stop the server.
142async fn catch_panic_as_error<T>(future: impl Future<Output = T>) -> anyhow::Result<T> {
143    match AssertUnwindSafe(future).catch_unwind().await {
144        Ok(value) => Ok(value),
145        Err(panic_payload) => {
146            let panic_message = if let Some(panic_message) = panic_payload.downcast_ref::<&str>() {
147                panic_message.to_string()
148            } else if let Some(panic_message) = panic_payload.downcast_ref::<String>() {
149                panic_message.clone()
150            } else {
151                "<non-string panic payload>".to_string()
152            };
153            Err(anyhow::anyhow!("panic: {}", panic_message))
154        }
155    }
156}
157
158/// Replies to (or abandons) `incoming` according to how dispatching it went.
159/// The outer `Err` is a handler panic (see [`catch_panic_as_error`]); it is scored as a bad request just like
160/// an ordinary `Err`, so a caller who has found a panic can't keep triggering it without being DDoS-scored.
161fn complete_incoming_request(incoming: IncomingRequest, dispatch_outcome: anyhow::Result<anyhow::Result<BytesGatherer>>) {
162    match dispatch_outcome {
163        Ok(Ok(bytes)) => {
164            let result = incoming.reply.send(bytes);
165            if result.is_err() { warn!("failed to send reply"); }
166        },
167        Err(handler_panic) => {
168            error!("handler panicked processing packet from {}: {}", incoming.caller_address, handler_panic);
169            incoming.report_bad_request();
170            drop(incoming.reply);
171        },
172        Ok(Err(e)) => {
173            warn!("failed to process packet from {}: {}", incoming.caller_address, e);
174            incoming.report_bad_request();
175            drop(incoming.reply);
176        },
177    }
178}
179
180impl HashiverseServer {
181    pub async fn wrap_and_dispatch_network_envelopes(&self, cancellation_token: CancellationToken, mut rx: mpsc::Receiver<IncomingRequest>) -> Result<(), anyhow::Error> {
182        loop {
183            tokio::select! {
184                _ = cancellation_token.cancelled() => { break },
185
186                receipt = rx.recv() => {
187                    match receipt {
188                        Some(incoming) => {
189                            // trace!("dispatch_network_envelopes received bytes={:?}", incoming.bytes);
190                            // A panic in any handler must not unwind out of this loop: it runs inside the server's
191                            // try_join! and would take the whole server down with it.
192                            let dispatch_outcome = catch_panic_as_error(self.wrap_and_dispatch_network_envelope(cancellation_token.clone(), &incoming)).await;
193                            complete_incoming_request(incoming, dispatch_outcome);
194                        },
195                        None => {
196                            warn!("channel closed");
197                            break;
198                        }
199                    }
200                }
201            }
202        }
203
204        Ok(())
205    }
206
207    async fn wrap_and_dispatch_network_envelope(&self, cancellation_token: CancellationToken, incoming: &IncomingRequest) -> anyhow::Result<BytesGatherer> {
208        let caller_address = incoming.caller_address.as_str();
209        let current_time_millis = self.runtime_services.time_provider.current_time_millis();
210
211        // Decode the envelope
212        let rpc_request_packet_rx = RpcRequestPacketRx::decode(&current_time_millis, &self.server_id.keys.verification_key_bytes, &self.server_id.keys.pq_commitment_bytes, incoming.bytes.clone())?;
213        // trace!("payload_request_kind={}", rpc_request_packet_rx.payload_request_kind);
214
215        // Count this inbound request. After decoding (so malformed traffic doesn't
216        // pollute the totals) but before the per-handler PoW check (so failed-PoW
217        // attempts still show up — adversarial load is the load we'd want to see).
218        let payload_request_kind_index = rpc_request_packet_rx.payload_request_kind.clone() as usize;
219        self.request_counters[payload_request_kind_index].fetch_add(1, Ordering::Relaxed);
220        // Also feed the decaying per-window rate estimates so the stats reflect
221        // recent load, not just the monotonic all-time total.
222        self.request_rate_windows[payload_request_kind_index].lock().record(current_time_millis);
223
224        // Check that we have not seen this salt recently (stops replay attacks)
225        {
226            if self.seen_salts.contains_key(&rpc_request_packet_rx.pow_salt) {
227                anyhow::bail!("replay detected: salt already seen");
228            }
229            self.seen_salts.insert(rpc_request_packet_rx.pow_salt, ());
230        }
231
232        // Keep this for our response
233        let pow_content_hash = rpc_request_packet_rx.pow_content_hash;
234
235        let dispatch_result: anyhow::Result<BytesGatherer> = try {
236            // Check that the pow is meaningful
237            let pow = match rpc_request_packet_rx.pow_server_known {
238                true => {
239                    let (pow, improved_pow_current_day, improved_pow_current_month) = {
240                        let peer_self = self.peer_self.read(); // Remember alphabetical locking order!
241                        let pow = PeerPow::new(
242                            rpc_request_packet_rx.pow_sponsor_id,
243                            &peer_self.verification_key_bytes,
244                            &peer_self.pq_commitment_bytes,
245                            rpc_request_packet_rx.pow_timestamp,
246                            rpc_request_packet_rx.pow_content_hash,
247                            rpc_request_packet_rx.pow_salt,
248                        )?;
249
250                        let improved_pow_current_day = pow.pow_decayed_day(current_time_millis) > peer_self.pow_current_day.pow_decayed_day(current_time_millis);
251                        let improved_pow_current_month = pow.pow_decayed_month(current_time_millis) > peer_self.pow_current_month.pow_decayed_month(current_time_millis);
252
253                        (pow, improved_pow_current_day, improved_pow_current_month)
254                    };
255
256                    // Check if we need to modify peer_self
257                    if improved_pow_current_day || improved_pow_current_month {
258                        let mut peer_self = self.peer_self.write(); // Remember alphabetical locking order!
259                        if improved_pow_current_day {
260                            trace!("pow_current_day upgraded {} -> {}", peer_self.pow_current_day, pow);
261                            peer_self.pow_current_day = pow.clone();
262                        }
263                        if improved_pow_current_month {
264                            trace!("pow_current_month upgraded {} -> {}", peer_self.pow_current_month, pow);
265                            peer_self.pow_current_month = pow.clone();
266                        }
267
268                        peer_self.sign(self.runtime_services.time_provider.as_ref(), &self.server_id.keys.signature_key)?;
269                    }
270
271                    Some(pow)
272                }
273
274                false => {
275                    // Only some request types are allowed anonymous pow
276                    match rpc_request_packet_rx.payload_request_kind {
277                        PayloadRequestKind::BootstrapV1 => {}
278                        _ => anyhow::bail!("Anonymous pow not allowed for {}", rpc_request_packet_rx.payload_request_kind),
279                    }
280
281                    None
282                }
283            };
284
285            // Dispatch appropriately
286            let (compress_response, payload_response_kind, payload) = self.dispatch_network_envelope(cancellation_token, pow, rpc_request_packet_rx).await?;
287            let response_flags = match compress_response {
288                true => RpcResponsePacketTxFlags::COMPRESSED,
289                false => RpcResponsePacketTxFlags::empty(),
290            };
291
292            // Encode response
293            RpcResponsePacketTx::encode(
294                &self.server_id.keys.signature_key,
295                &self.server_id.keys.verification_key_bytes,
296                &self.server_id.keys.pq_commitment_bytes,
297                &self.server_id.sponsor_id,
298                &self.server_id.timestamp,
299                &self.server_id.hash,
300                &self.server_id.salt,
301                &pow_content_hash,
302                response_flags,
303                payload_response_kind,
304                payload,
305            )?
306        };
307
308        match dispatch_result {
309            Ok(results) => Ok(results),
310            Err(e) => {
311                warn!("failed to dispatch packet from {}: {}", caller_address, e);
312                incoming.report_bad_request();
313
314                let payload_response_kind = PayloadResponseKind::ErrorResponseV1;
315                let response = ErrorResponseV1 { code: 0, message: e.to_string() };
316                let payload = BytesGatherer::from_bytes(json::struct_to_bytes(&response)?);
317
318                // Encode response
319                RpcResponsePacketTx::encode(
320                    &self.server_id.keys.signature_key,
321                    &self.server_id.keys.verification_key_bytes,
322                    &self.server_id.keys.pq_commitment_bytes,
323                    &self.server_id.sponsor_id,
324                    &self.server_id.timestamp,
325                    &self.server_id.hash,
326                    &self.server_id.salt,
327                    &pow_content_hash,
328                    RpcResponsePacketTxFlags::COMPRESSED,
329                    payload_response_kind,
330                    payload,
331                )
332            }
333        }
334    }
335
336    async fn dispatch_network_envelope(&self, cancellation_token: CancellationToken, pow: Option<PeerPow>, rpc_request_packet_rx: RpcRequestPacketRx) -> anyhow::Result<(bool, PayloadResponseKind, BytesGatherer)> {
337        // Where do we want to decide if we should compress?  Here in one block, or at the end of each individual dispatch_xxx?
338        let compress_response = match rpc_request_packet_rx.payload_request_kind {
339            PayloadRequestKind::GetPostBundleV1 => false,   // We don't compress these again as they are already predominantly compressed
340            PayloadRequestKind::CachePostBundleV1 => false, // We don't compress these again as they are already predominantly compressed
341            _ => true,
342        };
343
344        let (payload_response_kind, payload) = match rpc_request_packet_rx.payload_request_kind {
345            PayloadRequestKind::ErrorV1 => {
346                anyhow::bail!("Received ErrorV1");
347            }
348            PayloadRequestKind::PingV1 => self.dispatch_network_payload_x_PingV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await?,
349            PayloadRequestKind::BootstrapV1 => self.dispatch_network_payload_x_BootstrapV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await?,
350            PayloadRequestKind::RetiredAnnounceV1 => {
351                anyhow::bail!("AnnounceV1 is retired; use AnnounceV2");
352            }
353            PayloadRequestKind::GetPostBundleV1 => self.dispatch_network_payload_x_GetPostBundleV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await?,
354            PayloadRequestKind::GetPostBundleFeedbackV1 => { self.dispatch_network_payload_x_GetPostBundleFeedbackV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await? }
355            PayloadRequestKind::SubmitPostClaimV1 => { self.dispatch_network_payload_x_SubmitPostClaimV1(cancellation_token, pow, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await? }
356            PayloadRequestKind::SubmitPostCommitV1 => { self.dispatch_network_payload_x_SubmitPostCommitV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await? }
357            PayloadRequestKind::SubmitPostFeedbackV1 => { self.dispatch_network_payload_x_SubmitPostFeedbackV1(cancellation_token, pow, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await? }
358            PayloadRequestKind::HealPostBundleClaimV1 => { self.dispatch_network_payload_x_HealPostBundleClaimV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await? }
359            PayloadRequestKind::HealPostBundleCommitV1 => { self.dispatch_network_payload_x_HealPostBundleCommitV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await? }
360            PayloadRequestKind::HealPostBundleFeedbackV1 => { self.dispatch_network_payload_x_HealPostBundleFeedbackV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await? }
361            PayloadRequestKind::CachePostBundleV1 => self.dispatch_network_payload_x_CachePostBundleV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await?,
362            PayloadRequestKind::CachePostBundleFeedbackV1 => { self.dispatch_network_payload_x_CachePostBundleFeedbackV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await? }
363            PayloadRequestKind::FetchUrlPreviewV1 => self.dispatch_network_payload_x_FetchUrlPreviewV1(cancellation_token, pow, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await?,
364            PayloadRequestKind::TrendingHashtagsFetchV1 => self.dispatch_network_payload_x_TrendingHashtagsFetchV1(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await?,
365            PayloadRequestKind::PeerStatsRequestV1 => self.dispatch_network_payload_x_PeerStatsRequestV1(cancellation_token, pow, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await?,
366            PayloadRequestKind::AnnounceV2 => self.dispatch_network_payload_x_AnnounceV2(cancellation_token, rpc_request_packet_rx.payload_request_kind, rpc_request_packet_rx.bytes).await?,
367        };
368
369        Ok((compress_response, payload_response_kind, payload))
370    }
371
372    #[allow(non_snake_case)]
373    async fn dispatch_network_payload_x_PingV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, _bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
374        anyhow_assert_eq!(&PayloadRequestKind::PingV1, &payload_request_kind);
375        let peer = self.peer_self.read().clone();
376        let json = json::struct_to_bytes(&PingResponseV1 { peer })?;
377        Ok((PayloadResponseKind::PingResponseV1, BytesGatherer::from_bytes(json)))
378    }
379
380    #[allow(non_snake_case)]
381    async fn dispatch_network_payload_x_BootstrapV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, _bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
382        anyhow_assert_eq!(&PayloadRequestKind::BootstrapV1, &payload_request_kind);
383        let peers_random = self.kademlia.read().get_peers_random(config::BOOTSTRAP_V1_NUM_PEERS);
384        let json = json::struct_to_bytes(&BootstrapResponseV1 { peers_random })?;
385        Ok((PayloadResponseKind::BootstrapResponseV1, BytesGatherer::from_bytes(json)))
386    }
387
388    #[allow(non_snake_case)]
389    async fn dispatch_network_payload_x_AnnounceV2(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
390        anyhow_assert_eq!(&PayloadRequestKind::AnnounceV2, &payload_request_kind);
391
392        let request = json::bytes_to_struct::<AnnounceV2>(&bytes)?;
393
394        let peer = request.peer_self;
395        let peer_id = peer.id;
396        let now = self.runtime_services.time_provider.as_ref().current_time_millis();
397
398        // Gate Kademlia admission on the transport-specific ownership proof. For HTTPS that's
399        // an offline X.509 chain validation against bundled webpki-roots with an IP-SAN match;
400        // for mem / TCP it's the empty-marker check. A failed proof is a hard error: no
401        // Kademlia add, no nearest-peers list returned. The dispatcher's Err -> ErrorResponseV1
402        // wrapper also reports the request as bad to the DDoS layer, so repeat offenders get
403        // throttled by per-IP scoring.
404        let ownership_proof = self.transport_server.get_transport_ownership_proof();
405        if !ownership_proof.prove(&peer, &request.proof_payload, now) {
406            anyhow::bail!("AnnounceV2 from peer {} at {} failed ownership proof", peer_id, peer.address);
407        }
408        self.add_potential_peer_to_kademlia(peer, now).await;
409
410        let (peers_nearest, _) = self.kademlia.read().get_peers_for_key(&peer_id, config::ANNOUNCE_NUM_PEERS);
411
412        let json = json::struct_to_bytes(&AnnounceResponseV2 {
413            peer_self: self.peer_self.read().clone(),
414            peers_nearest,
415        })?;
416        Ok((PayloadResponseKind::AnnounceResponseV2, BytesGatherer::from_bytes(json)))
417    }
418
419    #[allow(non_snake_case)]
420    async fn dispatch_network_payload_x_GetPostBundleV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
421        anyhow_assert_eq!(&PayloadRequestKind::GetPostBundleV1, &payload_request_kind);
422
423        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
424
425        let request = json::bytes_to_struct::<GetPostBundleV1>(&bytes)?;
426        trace!("received GetPostBundleV1: bucket_location={}", request.bucket_location);
427
428        // Check that the location_id makes sense
429        request.bucket_location.validate()?;
430
431        // Store the provided Peers
432        {
433            for peer in request.peers_visited {
434                self.add_potential_peer_to_kademlia(peer, time_millis).await;
435            }
436        }
437
438        let peer_self = self.peer_self.read().clone();
439
440        // Check our own records to see that we are close enough to store this post
441        let (peers_nearer, among_peers_nearer) = self.kademlia.read().get_peers_for_key(&request.bucket_location.location_id, config::REDUNDANT_SERVERS_PER_POST);
442        if !among_peers_nearer {
443            warn!("I am not in peers_nearer {}", peer_self);
444        }
445
446        let post_bundle = match among_peers_nearer {
447            true => {
448                // At some point, for parallelisms sake, we may wish to replace this with a read lock followed by a release and write lock and recheck
449                // But then again - if our server is getting load, the federated cache mechanism should alleviate it, so this may be overkill...
450                let _post_bundle_lock = self.environment.get_write_lock_for_location_id(&request.bucket_location.location_id);
451
452                let mut encoded_post_bundle_bytes: Option<Bytes> = None;
453
454                // Try to load bytes from the disk (if we even have them)
455                let post_bundle_metadata = self.environment.get_post_bundle_metadata(time_millis, &request.bucket_location.location_id)?;
456                if let Some(mut post_bundle_metadata) = post_bundle_metadata {
457                    encoded_post_bundle_bytes = self.environment.get_post_bundle_bytes(time_millis, &request.bucket_location.location_id)?;
458
459                    // Has the PostBundle become sealed since the last time it was written to disk?  Perhaps enough time has passed
460                    if !post_bundle_metadata.sealed {
461                        let sealed = request.bucket_location.is_sealed_at(time_millis, false);
462                        if sealed {
463                            // We can rewrite the postbundle on disk for the final time now that it is sealed
464                            if let Some(encoded_post_bundle_bytes_old) = encoded_post_bundle_bytes {
465                                let mut encoded_post_bundle = EncodedPostBundleV1::from_bytes(encoded_post_bundle_bytes_old, true)?;
466                                encoded_post_bundle.header.time_millis = time_millis;
467                                encoded_post_bundle.header.sealed = true;
468                                encoded_post_bundle.header.signature_generate(&self.server_id.keys.signature_key)?;
469                                let encoded_post_bundle_bytes_new = encoded_post_bundle.to_bytes()?;
470                                self.environment.put_post_bundle_bytes(time_millis, &request.bucket_location.location_id, &encoded_post_bundle_bytes_new)?;
471                                encoded_post_bundle_bytes = Some(encoded_post_bundle_bytes_new);
472                            }
473
474                            // And the updated metadata
475                            post_bundle_metadata.sealed = true;
476                            self.environment.put_post_bundle_metadata(time_millis, &request.bucket_location.location_id, &post_bundle_metadata, 0)?;
477                        }
478                        else {
479                            // We must simply update the timestamp - we need this for client caching.  It's expensive, but caching should help us out here if it is a truly busy bucket
480                            if let Some(encoded_post_bundle_bytes_old) = encoded_post_bundle_bytes {
481                                let mut encoded_post_bundle = EncodedPostBundleV1::from_bytes(encoded_post_bundle_bytes_old, true)?;
482                                encoded_post_bundle.header.time_millis = time_millis;
483                                encoded_post_bundle.header.signature_generate(&self.server_id.keys.signature_key)?;
484                                let encoded_post_bundle_bytes_new = encoded_post_bundle.to_bytes()?;
485                                encoded_post_bundle_bytes = Some(encoded_post_bundle_bytes_new);
486                            }
487                        }
488                    }
489                };
490
491                // If we dont have the metadata, or the bytes on disk, return a fresh one
492                // Generally if we have the metadata, we should always have bytes on disk, except if a request ot post was granted but they then never came back with the data...
493                if encoded_post_bundle_bytes.is_none() {
494                    let sealed = request.bucket_location.is_sealed_at(time_millis, false);
495
496                    let mut header = EncodedPostBundleHeaderV1 {
497                        time_millis,
498                        location_id: request.bucket_location.location_id,
499                        overflowed: false,
500                        sealed,
501                        num_posts: 0,
502                        encoded_post_ids: vec![],
503                        encoded_post_lengths: vec![],
504                        encoded_post_healed: HashSet::new(),
505                        peer: peer_self.clone(),
506                        signature: Signature::zero(),
507                    };
508                    header.signature_generate(&self.server_id.keys.signature_key)?;
509
510                    let encoded_post_bundle = EncodedPostBundleV1 { header, encoded_posts_bytes: Bytes::new() };
511                    encoded_post_bundle_bytes = Some(encoded_post_bundle.to_bytes()?);
512                }
513
514                encoded_post_bundle_bytes
515            }
516            false => None,
517        };
518
519        let cache_result = self.post_bundle_cache.on_get(&request.bucket_location, &request.already_retrieved_peer_ids, &peer_self, &self.server_id, time_millis);
520
521        let get_post_bundle_response = GetPostBundleResponseV1 {
522            peers_nearer,
523            cache_request_token: cache_result.cache_request_token,
524            post_bundles_cached: cache_result.cached_items,
525            post_bundle,
526        };
527        Ok((PayloadResponseKind::GetPostBundleResponseV1, get_post_bundle_response.to_bytes_gatherer()?))
528    }
529
530    #[allow(non_snake_case)]
531    async fn dispatch_network_payload_x_GetPostBundleFeedbackV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
532        anyhow_assert_eq!(&PayloadRequestKind::GetPostBundleFeedbackV1, &payload_request_kind);
533
534        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
535
536        let request = json::bytes_to_struct::<GetPostBundleFeedbackV1>(&bytes)?;
537        trace!("received GetPostBundleFeedbackV1");
538
539        // Store the provided Peers
540        {
541            for peer in request.peers_visited {
542                self.add_potential_peer_to_kademlia(peer, time_millis).await;
543            }
544        }
545
546        // Check our own records to see that we are close enough to store this post
547        let (peers_nearer, among_peers_nearer) = self.kademlia.read().get_peers_for_key(&request.bucket_location.location_id, config::REDUNDANT_SERVERS_PER_POST);
548
549        let mut post_bundle_encoded_feedbacks_bytes: Option<Bytes> = None;
550
551        if among_peers_nearer {
552            // We only support feedbacks if we know about this post_bundle
553            let post_bundle_metadata = self.environment.get_post_bundle_metadata(time_millis, &request.bucket_location.location_id)?;
554            if post_bundle_metadata.is_some() {
555                post_bundle_encoded_feedbacks_bytes = Some(self.environment.get_post_bundle_encoded_post_feedbacks_bytes(time_millis, &request.bucket_location.location_id)?);
556            }
557        }
558
559        // Wrap the feedbacks with a header (if we have any)
560        let peer_self = self.peer_self.read().clone();
561        let encoded_post_bundle_feedback = match post_bundle_encoded_feedbacks_bytes {
562            Some(feedbacks_bytes) => {
563
564                let feedbacks_bytes_hash = hashing::hash(feedbacks_bytes.as_ref());
565
566                let mut header = EncodedPostBundleFeedbackHeaderV1 {
567                    time_millis,
568                    location_id: request.bucket_location.location_id,
569                    feedbacks_bytes_hash,
570                    peer: peer_self.clone(),
571                    signature: Signature::zero(),
572                };
573                header.signature_generate(&self.server_id.keys.signature_key);
574
575                let encoded_post_bundle_feedback = EncodedPostBundleFeedbackV1 {
576                    header,
577                    feedbacks_bytes,
578                };
579                Some(encoded_post_bundle_feedback.to_bytes()?)
580            }
581            None => None,
582        };
583
584        let cache_result = self.post_bundle_feedback_cache.on_get(&request.bucket_location, &request.already_retrieved_peer_ids, &peer_self, &self.server_id, time_millis);
585
586        let get_post_bundle_feedback_response = GetPostBundleFeedbackResponseV1 {
587            peers_nearer,
588            cache_request_token: cache_result.cache_request_token,
589            post_bundle_feedbacks_cached: cache_result.cached_items,
590            encoded_post_bundle_feedback,
591        };
592        Ok((PayloadResponseKind::GetPostBundleFeedbackResponseV1, get_post_bundle_feedback_response.to_bytes_gatherer()?))
593    }
594
595    #[allow(non_snake_case)]
596    async fn dispatch_network_payload_x_SubmitPostClaimV1(&self, _cancellation_token: CancellationToken, pow: Option<PeerPow>, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
597        anyhow_assert_eq!(&PayloadRequestKind::SubmitPostClaimV1, &payload_request_kind);
598
599        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
600
601        let pow = match pow {
602            Some(pow) => pow,
603            None => anyhow::bail!("We need pow for a submit post claim"),
604        };
605
606        let request = SubmitPostClaimV1::from_bytes(&mut bytes)?;
607        trace!("received SubmitPostClaimV1");
608
609        // Check that the location_id makes sense
610        request.bucket_location.validate()?;
611
612        // Check that we support the bucket duration
613        let bucket_duration = {
614            let bucket_duration = BUCKET_DURATIONS.iter().find(|bucket_duration| **bucket_duration == request.bucket_location.duration);
615            match bucket_duration {
616                Some(bucket_duration) => *bucket_duration,
617                None => anyhow::bail!("Unrecognised bucket duration provided"),
618            }
619        };
620
621        let decoded_post = EncodedPostV1::decode_from_bytes(request.encoded_post_bytes, &request.bucket_location.base_id, false, false)?;
622
623        // Check that enough pow has been done for this post
624        {
625            let pow_minimum = get_minimum_post_pow(decoded_post.header.post_length, decoded_post.header.linked_base_ids.len(), request.bucket_location.duration)?;
626            if pow.pow < pow_minimum {
627                anyhow::bail!("Insufficient proof of work for this post: actual={} < expected={}", pow.pow, pow_minimum);
628            }
629        }
630
631        // Check that the post matches the bucket
632        {
633            // Ensure that the bucket timestamp fits the post
634            let timestamp = BucketLocation::round_down_to_bucket_start(decoded_post.header.time_millis, bucket_duration);
635            if timestamp != request.bucket_location.bucket_time_millis {
636                anyhow::bail!("The post timestamp does not match the bucket");
637            }
638        }
639
640        let client_id = decoded_post.header.client_id()?;
641
642        // Ensure that the base_id is related to the post in the linked_base_ids.
643        if !decoded_post.header.linked_base_ids.contains(&request.bucket_location.base_id) {
644            anyhow::bail!("The base_id is not related to the post");
645        }
646
647        // Check that only the posting user is allowed to post to a bucket of type USER
648        if request.bucket_location.bucket_type == BucketType::User && request.bucket_location.base_id != client_id.id {
649            anyhow::bail!("Only the posting user is allowed to post to a bucket of type USER");
650        }
651
652        // For ReplyToPost and Sequel buckets, verify the referenced post is real (valid signature).
653        // For Sequel buckets, additionally verify same-author.
654        if matches!(request.bucket_location.bucket_type, BucketType::ReplyToPost | BucketType::Sequel) {
655            let original_header_bytes = request.referenced_post_header_bytes
656                .ok_or_else(|| anyhow::anyhow!("{:?} posts require the original post's header bytes", request.bucket_location.bucket_type))?;
657
658            // Decode the original post header using the submitter's client_id as the decryption password.
659            // decode_from_bytes verifies the signature — a forged header will fail here.
660            let original_post = EncodedPostV1::decode_from_bytes(original_header_bytes, &client_id.id, false, false)?;
661
662            // Verify the original post's post_id matches the bucket's base_id
663            if original_post.post_id != request.bucket_location.base_id {
664                anyhow::bail!("Referenced post header's post_id does not match the bucket's base_id");
665            }
666
667            // For Sequel buckets, additionally verify the sequel author matches the original post author
668            if request.bucket_location.bucket_type == BucketType::Sequel {
669                let original_client_id = original_post.header.client_id()?;
670                if original_client_id != client_id {
671                    anyhow::bail!("Sequel post author does not match original post author");
672                }
673            }
674        }
675
676        // Check that the post timestamp is reasonable
677        {
678            let delta = (time_millis - decoded_post.header.time_millis).abs();
679            if delta > config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD {
680                anyhow::bail!("The post timestamp delta is too large ({} > {})", delta, config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD);
681            }
682        }
683
684        // Check our own records to see that we are close enough to store this post
685        let (peers_nearer, among_peers_nearer) = self.kademlia.read().get_peers_for_key(&request.bucket_location.location_id, config::REDUNDANT_SERVERS_PER_POST);
686
687        let submit_post_claim_token = match among_peers_nearer {
688            true => {
689                // Are we still willing to accept this post?
690                let _post_bundle_lock = self.environment.get_write_lock_for_location_id(&request.bucket_location.location_id);
691                let post_bundle_metadata = self.environment.get_post_bundle_metadata(time_millis, &request.bucket_location.location_id)?;
692                let mut post_bundle_metadata = post_bundle_metadata.unwrap_or_else(PostBundleMetadata::zero);
693
694                // If it is not yet sealed, update our metadata
695                if !post_bundle_metadata.sealed {
696                    post_bundle_metadata.num_posts_granted += 1;
697                    post_bundle_metadata.overflowed = post_bundle_metadata.num_posts > config::ENCODED_POST_BUNDLE_V1_OVERFLOWED_NUM_POSTS || post_bundle_metadata.num_posts_granted > config::ENCODED_POST_BUNDLE_V1_OVERFLOWED_NUM_POSTS_GRANTED;
698                    post_bundle_metadata.sealed = request.bucket_location.is_sealed_at(time_millis, post_bundle_metadata.overflowed);
699
700                    self.environment.put_post_bundle_metadata(time_millis, &request.bucket_location.location_id, &post_bundle_metadata, 0)?;
701                }
702
703                // We grant the token if we are not yet sealed
704                match post_bundle_metadata.sealed {
705                    false => {
706                        info!("Granted for {}: num_posts={} num_posts_granted={}", request.bucket_location, post_bundle_metadata.num_posts, post_bundle_metadata.num_posts_granted);
707                        Some(SubmitPostClaimTokenV1::new(self.peer_self.read().clone(), request.bucket_location.clone(), decoded_post.post_id, &self.server_id.keys.signature_key))
708                    }
709                    true => {
710                        info!(
711                            "Not granting SubmitPostClaimTokenV1 to {} as we have num_posts={} num_posts_granted={}",
712                            request.bucket_location, post_bundle_metadata.num_posts, post_bundle_metadata.num_posts_granted
713                        );
714                        None
715                    }
716                }
717            }
718
719            false => None,
720        };
721
722        // If we are willing to accept this post, then we are willing to track the trendiness of its hashtags
723        if submit_post_claim_token.is_some() {
724            // Track referenced hashtags for trending (only for User bucket posts, where the hashtags originate)
725            if request.bucket_location.bucket_type == BucketType::User && !request.referenced_hashtags.is_empty() {
726                let author_verification_key_bytes = &decoded_post.header.verification_key_bytes;
727                for referenced_hashtag in &request.referenced_hashtags {
728                    let hashtag_id = match Id::from_hashtag_str(referenced_hashtag) {
729                        Ok(id) => id,
730                        Err(_) => continue, // Skip invalid hashtags silently
731                    };
732                    if !decoded_post.header.linked_base_ids.contains(&hashtag_id) {
733                        continue; // Hashtag not backed by a linked_base_id (no PoW), skip it
734                    }
735                    let mut hll = self.trending_hashtags.get(referenced_hashtag).unwrap_or_default();
736                    hll.insert(author_verification_key_bytes.as_ref());
737                    self.trending_hashtags.insert(referenced_hashtag.clone(), hll);
738                }
739            }
740        }
741
742        let response = SubmitPostClaimResponseV1 { peers_nearer, submit_post_claim_token };
743        Ok((PayloadResponseKind::SubmitPostClaimResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
744    }
745
746    #[allow(non_snake_case)]
747    async fn dispatch_network_payload_x_SubmitPostCommitV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
748        anyhow_assert_eq!(&PayloadRequestKind::SubmitPostCommitV1, &payload_request_kind);
749
750        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
751
752        let request = SubmitPostCommitV1::from_bytes(&mut bytes)?;
753        trace!("received SubmitPostCommitV1");
754
755        let peer_self = self.peer_self.read(); // Remember alphabetical locking order!
756
757        // Is the submit_post_claim_token from us?
758        if request.submit_post_claim_token.peer.id != peer_self.id {
759            anyhow::bail!("The submit_post_claim_token is not from us");
760        }
761
762        // Check that the location_id still makes sense
763        request.bucket_location.validate()?;
764        if request.bucket_location != request.submit_post_claim_token.bucket_location {
765            anyhow::bail!("The location_id in the SubmitPostCommit does not match the bucket_location in the SubmitPostClaimToken");
766        }
767
768        // Check that we can decode the post with the bucket's base_id as password
769        let decoded_post = EncodedPostV1::decode_from_bytes(request.encoded_post_bytes.clone(), &request.bucket_location.base_id, true, false)?;
770
771        // Check that the committed post matches what was claimed
772        if decoded_post.post_id != request.submit_post_claim_token.post_id {
773            anyhow::bail!("The post_id of the committed post does not match the post_id in the SubmitPostClaimToken");
774        }
775
776        // Update the postbundle and metadata
777        let _post_bundle_lock = self.environment.get_write_lock_for_location_id(&request.bucket_location.location_id);
778        let post_bundle_metadata = self.environment.get_post_bundle_metadata(time_millis, &request.bucket_location.location_id)?;
779        let post_bundle_bytes = self.environment.get_post_bundle_bytes(time_millis, &request.bucket_location.location_id)?;
780
781        // We should always have the metadata, but just in case!
782        let mut post_bundle_metadata = post_bundle_metadata.unwrap_or_else(PostBundleMetadata::zero);
783
784        // Make a PostBundle if we dont have one on disk
785        let mut post_bundle = match post_bundle_bytes {
786            Some(bytes) => {
787                let bytes = Bytes::from_owner(bytes);
788                
789                EncodedPostBundleV1::from_bytes(bytes, true)?
790            }
791            None => {
792                let header = EncodedPostBundleHeaderV1 {
793                    time_millis: TimeMillis::zero(),
794                    location_id: request.bucket_location.location_id,
795                    overflowed: false,
796                    sealed: false,
797                    num_posts: 0,
798                    encoded_post_ids: vec![],
799                    encoded_post_lengths: vec![],
800                    encoded_post_healed: HashSet::new(),
801                    peer: self.peer_self.read().clone(),
802                    signature: Signature::zero(),
803                };
804
805                EncodedPostBundleV1 { header, encoded_posts_bytes: Bytes::new() }
806            }
807        };
808
809        // Sanity check that we don't already have this post
810        if post_bundle.header.encoded_post_ids.contains(&decoded_post.post_id) {
811            anyhow::bail!("Post {} is already in the bundle", decoded_post.post_id);
812        }
813
814        // The post bundle
815        post_bundle.header.time_millis = time_millis;
816        post_bundle.header.num_posts += 1;
817        post_bundle.header.overflowed = post_bundle.header.num_posts > config::ENCODED_POST_BUNDLE_V1_OVERFLOWED_NUM_POSTS || post_bundle_metadata.num_posts_granted > config::ENCODED_POST_BUNDLE_V1_OVERFLOWED_NUM_POSTS_GRANTED;
818        post_bundle.header.sealed = request.bucket_location.is_sealed_at(time_millis, post_bundle.header.overflowed);
819        post_bundle.header.encoded_post_ids.push(decoded_post.post_id);
820        post_bundle.header.encoded_post_lengths.push(request.encoded_post_bytes.len());
821        post_bundle.header.signature_generate(&self.server_id.keys.signature_key)?;
822        let mut posts_mut = BytesMut::from(post_bundle.encoded_posts_bytes.as_ref());
823        posts_mut.extend_from_slice(request.encoded_post_bytes.as_ref());
824        post_bundle.encoded_posts_bytes = posts_mut.freeze();
825        let post_bundle_bytes_new = post_bundle.to_bytes()?;
826
827        // The metadata
828        post_bundle_metadata.num_posts = post_bundle.header.num_posts;
829        post_bundle_metadata.overflowed = post_bundle.header.overflowed;
830        post_bundle_metadata.sealed = post_bundle.header.sealed;
831        post_bundle_metadata.size = post_bundle.header.encoded_post_lengths.iter().sum();
832
833        {
834            self.environment.put_post_bundle_bytes(time_millis, &request.bucket_location.location_id, &post_bundle_bytes_new)?;
835            self.environment
836                .put_post_bundle_metadata(time_millis, &request.bucket_location.location_id, &post_bundle_metadata, request.encoded_post_bytes.len())?;
837        }
838
839        info!("Persisted for {}: num_posts={} num_posts_granted={}", request.bucket_location, post_bundle_metadata.num_posts, post_bundle_metadata.num_posts_granted);
840
841        let submit_post_commit_token = SubmitPostCommitTokenV1::new(peer_self.clone(), request.bucket_location, decoded_post.post_id, &self.server_id.keys.signature_key);
842
843        let response = SubmitPostCommitResponseV1 { submit_post_commit_token };
844        Ok((PayloadResponseKind::SubmitPostCommitResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
845    }
846
847    #[allow(non_snake_case)]
848    async fn dispatch_network_payload_x_SubmitPostFeedbackV1(&self, _cancellation_token: CancellationToken, pow: Option<PeerPow>, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
849        anyhow_assert_eq!(&PayloadRequestKind::SubmitPostFeedbackV1, &payload_request_kind);
850
851        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
852
853        let request = SubmitPostFeedbackV1::from_bytes(&mut bytes)?;
854        trace!("received SubmitPostFeedbackV1");
855
856        // Enforce minimum PoW for feedback submission
857        let pow = pow.ok_or_else(|| anyhow::anyhow!("pow required for SubmitPostFeedbackV1"))?;
858        if pow.pow < config::POW_MINIMUM_PER_FEEDBACK {
859            anyhow::bail!("Insufficient pow for feedback: {} < {}", pow.pow, config::POW_MINIMUM_PER_FEEDBACK);
860        }
861
862        // Check the feedback makes sense
863        request.encoded_post_feedback.pow_verify()?;
864
865        let location_id = request.bucket_location.location_id;
866
867        // Check our own records to see that we are close enough to store this post feedback
868        let (peers_nearer, among_peers_nearer) = self.kademlia.read().get_peers_for_key(&location_id, config::REDUNDANT_SERVERS_PER_POST);
869
870        let accepted = (|| -> anyhow::Result<bool> {
871            if !among_peers_nearer {
872                return Ok(false);
873            }
874
875            // Do we recognise the post associated with this feedback
876            let _post_bundle_lock = self.environment.get_read_lock_for_location_id(&location_id);
877            let Some(post_bundle_bytes) = self.environment.get_post_bundle_bytes(time_millis, &location_id)?
878            else {
879                return Ok(false);
880            };
881
882            let post_bundle = EncodedPostBundleV1::from_bytes(Bytes::from_owner(post_bundle_bytes), false)?;
883            if !post_bundle.header.encoded_post_ids.contains(&request.encoded_post_feedback.post_id) {
884                return Ok(false);
885            }
886
887            Ok(true)
888        })()?;
889
890        // Update our environment with the feedback
891        if accepted {
892            trace!("Accepted post feedback for location_id={} encoded_post_feedback={:?}", location_id, request.encoded_post_feedback);
893            self.environment.put_post_feedback_if_more_powerful(time_millis, &location_id, &request.encoded_post_feedback)?;
894        }
895
896        let response = SubmitPostFeedbackResponseV1 { peers_nearer, accepted };
897        Ok((PayloadResponseKind::SubmitPostFeedbackResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
898    }
899
900    /// A heal lets a client push posts into our bundle that we never admitted ourselves (and so never saw the post PoW for).
901    /// We only accept that on the word of a donor bundle published by a genuine server that is - or was, it may since have died -
902    /// plausibly responsible for this location: that server admitted the posts, or itself healed them from such a server.
903    fn verify_heal_donor_header(&self, donor_header: &EncodedPostBundleHeaderV1, time_millis: TimeMillis) -> anyhow::Result<()> {
904        donor_header.verify()?;
905        donor_header.peer.verify().map_err(|e| anyhow::anyhow!("heal donor peer {} failed verification: {}", donor_header.peer.id, e))?;
906
907        // The donor's timestamp bounds the post timestamps we will accept, so it must not be in the future
908        if donor_header.time_millis > time_millis + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD {
909            anyhow::bail!("heal donor header time_millis {} is in the future (now {})", donor_header.time_millis, time_millis);
910        }
911
912        let (nearest_peers, _) = self.kademlia.read().get_peers_for_key(&donor_header.location_id, config::REDUNDANT_SERVERS_PER_POST);
913        let nearest_peer_ids: Vec<Id> = nearest_peers.iter().map(|peer| peer.id).collect();
914        if !is_plausible_heal_donor(&donor_header.peer.id, &donor_header.location_id, &nearest_peer_ids, config::REDUNDANT_SERVERS_PER_POST) {
915            anyhow::bail!("heal donor peer {} is too far from location_id {}", donor_header.peer.id, donor_header.location_id);
916        }
917
918        Ok(())
919    }
920
921    #[allow(non_snake_case)]
922    async fn dispatch_network_payload_x_HealPostBundleClaimV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
923        anyhow_assert_eq!(&PayloadRequestKind::HealPostBundleClaimV1, &payload_request_kind);
924
925        fn generate_negatory_response() -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
926            let response = HealPostBundleClaimResponseV1 { needed_post_ids: vec![], token: None };
927            Ok((PayloadResponseKind::HealPostBundleClaimResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
928        }
929
930        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
931        let request = HealPostBundleClaimV1::from_bytes(&mut bytes)?;
932        trace!("received HealPostBundleClaimV1");
933
934        // Verify the bucket_location is internally consistent and maps to the donor_header's location_id
935        request.bucket_location.validate()?;
936        if request.bucket_location.location_id != request.donor_header.location_id {
937            anyhow::bail!("HealPostBundleClaimV1: bucket_location.location_id does not match donor_header.location_id");
938        }
939
940        // Verify the donor_header provided by the client is self-consistent, properly signed, and published by a plausible server
941        self.verify_heal_donor_header(&request.donor_header, time_millis)?;
942
943        // Only heal if we are among the nearest peers for this location
944        let (_, among_peers_nearer) = self.kademlia.read().get_peers_for_key(&request.donor_header.location_id, config::REDUNDANT_SERVERS_PER_POST);
945        if !among_peers_nearer {
946            return generate_negatory_response();
947        }
948
949        // Reject if a heal for this location is already in progress (another client beat us to it)
950        if self.heal_in_progress.contains_key(&request.donor_header.location_id) {
951            return generate_negatory_response();
952        }
953
954        // Load our current bundle (header only) to see what post_ids we already have
955        let _lock = self.environment.get_read_lock_for_location_id(&request.donor_header.location_id);
956        let post_bundle_bytes = self.environment.get_post_bundle_bytes(time_millis, &request.donor_header.location_id)?;
957
958        let our_post_ids: HashSet<Id> = match post_bundle_bytes {
959            Some(bytes) => {
960                let bundle = EncodedPostBundleV1::from_bytes(Bytes::from_owner(bytes), false)?;
961                bundle.header.encoded_post_ids.into_iter().collect()
962            }
963            None => HashSet::new(),
964        };
965
966        // Posts in the donor_header that we do not yet have, in canonical order
967        let needed_post_ids: Vec<Id> = request.donor_header.encoded_post_ids.iter().filter(|id| !our_post_ids.contains(*id)).copied().collect();
968
969        if needed_post_ids.is_empty() {
970            return generate_negatory_response();
971        }
972
973        self.heal_in_progress.insert(request.donor_header.location_id, ());
974
975        let token = Some(HealPostBundleClaimTokenV1::new(
976            self.peer_self.read().clone(),
977            request.bucket_location,
978            needed_post_ids.clone(),
979            request.donor_header.signature,
980            &self.server_id.keys.signature_key,
981        ));
982        let response = HealPostBundleClaimResponseV1 { needed_post_ids, token };
983        Ok((PayloadResponseKind::HealPostBundleClaimResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
984    }
985
986    #[allow(non_snake_case)]
987    async fn dispatch_network_payload_x_HealPostBundleCommitV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
988        anyhow_assert_eq!(&PayloadRequestKind::HealPostBundleCommitV1, &payload_request_kind);
989
990        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
991        let request = HealPostBundleCommitV1::from_bytes(&mut bytes)?;
992        trace!("received HealPostBundleCommitV1");
993
994        // Verify the token was issued by this server
995        let peer_self = self.peer_self.read().clone();
996        if request.token.peer.id != peer_self.id {
997            anyhow::bail!("HealPostBundleCommitV1: token was not issued by this server");
998        }
999        request.token.verify()?;
1000
1001        // From here on the token is genuinely ours, so whatever happens we release the claim it was issued under.
1002        // Keyed on the token's (signed) location, not anything else the client sent, so a forged or foreign token
1003        // can never release a claim held by someone else. Without this, any failed commit would block healing for
1004        // this location until the heal_in_progress TTL expired.
1005        let token_location_id = request.token.bucket_location.location_id;
1006        let commit_result = self.commit_heal_post_bundle(&request, &peer_self, time_millis);
1007        self.heal_in_progress.invalidate(&token_location_id);
1008        let added_any = commit_result?;
1009
1010        let response = HealPostBundleCommitResponseV1 { accepted: added_any };
1011        Ok((PayloadResponseKind::HealPostBundleCommitResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
1012    }
1013
1014    /// The body of a HealPostBundleCommitV1 once its token has been verified as ours. Returns whether any post was added.
1015    fn commit_heal_post_bundle(&self, request: &HealPostBundleCommitV1, peer_self: &Peer, time_millis: TimeMillis) -> anyhow::Result<bool> {
1016        // Verify the donor_header matches what the token was issued for
1017        if request.donor_header.signature != request.token.donor_header_signature {
1018            anyhow::bail!("HealPostBundleCommitV1: donor_header signature does not match token");
1019        }
1020        self.verify_heal_donor_header(&request.donor_header, time_millis)?;
1021
1022        if request.token.bucket_location.location_id != request.donor_header.location_id {
1023            anyhow::bail!("HealPostBundleCommitV1: token location_id does not match donor_header");
1024        }
1025
1026        let location_id = request.donor_header.location_id;
1027
1028        // Parse the post bytes supplied by the client (one entry per token.needed_post_id, in order)
1029        let mut remaining_bytes = request.encoded_posts_bytes.clone();
1030        let mut posts_to_add: Vec<(Id, Bytes)> = Vec::new();
1031        for post_id in &request.token.needed_post_ids {
1032            let len = request
1033                .donor_header
1034                .encoded_post_ids
1035                .iter()
1036                .zip(request.donor_header.encoded_post_lengths.iter())
1037                .find(|(id, _)| *id == post_id)
1038                .map(|(_, len)| *len)
1039                .ok_or_else(|| anyhow::anyhow!("needed_post_id {} not found in donor_header", post_id))?;
1040            if remaining_bytes.len() < len {
1041                anyhow::bail!("HealPostBundleCommitV1: not enough bytes for post {}", post_id);
1042            }
1043            let post_bytes = remaining_bytes.split_to(len);
1044            posts_to_add.push((*post_id, post_bytes));
1045        }
1046        if !remaining_bytes.is_empty() {
1047            anyhow::bail!("HealPostBundleCommitV1: {} excess bytes", remaining_bytes.len());
1048        }
1049
1050        // Validate each post: it must decrypt and verify under the bucket's base_id, be the post it claims to be,
1051        // and pass the same admission rules we would have applied had it been submitted to us directly.
1052        // The reference time is the earlier of the donor's time and ours: the donor header may itself be up to the threshold ahead of
1053        // our clock, and using it alone would let healed posts sit twice the threshold in the future.
1054        let admission_reference_time_millis = std::cmp::min(request.donor_header.time_millis, time_millis);
1055        for (post_id, post_bytes) in &posts_to_add {
1056            let decoded_post = EncodedPostV1::decode_from_bytes(post_bytes.clone(), &request.token.bucket_location.base_id, true, true)
1057                .map_err(|e| anyhow::anyhow!("HealPostBundleCommitV1: post {} failed decryption: {}", post_id, e))?;
1058            if decoded_post.post_id != *post_id {
1059                anyhow::bail!("HealPostBundleCommitV1: post bytes are for {} not {}", decoded_post.post_id, post_id);
1060            }
1061            decoded_post
1062                .check_admissible_to_bucket(&request.token.bucket_location, admission_reference_time_millis)
1063                .map_err(|e| anyhow::anyhow!("HealPostBundleCommitV1: {}", e))?;
1064        }
1065
1066        // Load our current bundle (or start empty)
1067        let _lock = self.environment.get_write_lock_for_location_id(&location_id);
1068        let post_bundle_bytes = self.environment.get_post_bundle_bytes(time_millis, &location_id)?;
1069        let post_bundle_metadata = self.environment.get_post_bundle_metadata(time_millis, &location_id)?;
1070        let mut post_bundle_metadata = post_bundle_metadata.unwrap_or_else(PostBundleMetadata::zero);
1071
1072        let mut post_bundle = match post_bundle_bytes {
1073            Some(b) => EncodedPostBundleV1::from_bytes(Bytes::from_owner(b), true)?,
1074            None => EncodedPostBundleV1 {
1075                header: EncodedPostBundleHeaderV1 {
1076                    time_millis: TimeMillis::zero(),
1077                    location_id,
1078                    overflowed: false, // Never adopt the donor's flags - they are recomputed from our own state below
1079                    sealed: false,
1080                    num_posts: 0,
1081                    encoded_post_ids: vec![],
1082                    encoded_post_lengths: vec![],
1083                    encoded_post_healed: HashSet::new(),
1084                    peer: peer_self.clone(),
1085                    signature: Signature::zero(),
1086                },
1087                encoded_posts_bytes: Bytes::new(),
1088            },
1089        };
1090
1091        let our_post_ids: HashSet<Id> = post_bundle.header.encoded_post_ids.iter().copied().collect();
1092
1093        let mut posts_mut = BytesMut::from(post_bundle.encoded_posts_bytes.as_ref());
1094        let mut added_any = false;
1095        for (post_id, post_bytes) in posts_to_add {
1096            if post_bundle.header.encoded_post_ids.len() >= u8::MAX as usize {
1097                warn!("HealPostBundleCommitV1: bundle for location_id={} is full so not healing any more posts into it", location_id);
1098                break;
1099            }
1100            if !our_post_ids.contains(&post_id) {
1101                let len = post_bytes.len();
1102                posts_mut.extend_from_slice(&post_bytes);
1103                post_bundle.header.encoded_post_ids.push(post_id);
1104                post_bundle.header.encoded_post_lengths.push(len);
1105                post_bundle.header.encoded_post_healed.insert(post_id);
1106                added_any = true;
1107            }
1108        }
1109        post_bundle.encoded_posts_bytes = posts_mut.freeze();
1110
1111        if added_any {
1112            post_bundle.header.time_millis = time_millis;
1113            post_bundle.header.num_posts = post_bundle.header.encoded_post_ids.len() as u8;
1114            post_bundle.header.overflowed = post_bundle.header.num_posts > config::ENCODED_POST_BUNDLE_V1_OVERFLOWED_NUM_POSTS || post_bundle_metadata.num_posts_granted > config::ENCODED_POST_BUNDLE_V1_OVERFLOWED_NUM_POSTS_GRANTED;
1115            post_bundle.header.sealed = request.token.bucket_location.is_sealed_at(time_millis, post_bundle.header.overflowed);
1116            post_bundle.header.signature_generate(&self.server_id.keys.signature_key)?;
1117
1118            let new_bytes = post_bundle.to_bytes()?;
1119            post_bundle_metadata.num_posts = post_bundle.header.num_posts;
1120            post_bundle_metadata.size = post_bundle.header.encoded_post_lengths.iter().sum();
1121            post_bundle_metadata.overflowed = post_bundle.header.overflowed;
1122            post_bundle_metadata.sealed = post_bundle.header.sealed;
1123
1124            self.environment.put_post_bundle_bytes(time_millis, &location_id, &new_bytes)?;
1125            self.environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, 0)?;
1126
1127            info!("Healed {} post(s) for location_id={}", post_bundle.header.encoded_post_healed.len(), location_id);
1128        }
1129
1130        Ok(added_any)
1131    }
1132
1133    #[allow(non_snake_case)]
1134    async fn dispatch_network_payload_x_HealPostBundleFeedbackV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
1135        anyhow_assert_eq!(&PayloadRequestKind::HealPostBundleFeedbackV1, &payload_request_kind);
1136
1137        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
1138        let request = HealPostBundleFeedbackV1::from_bytes(&mut bytes)?;
1139        trace!("received HealPostBundleFeedbackV1 for location_id={}", request.location_id);
1140
1141        // Only accept if we are among the nearest peers for this location
1142        let (_, among_peers_nearer) = self.kademlia.read().get_peers_for_key(&request.location_id, config::REDUNDANT_SERVERS_PER_POST);
1143        if !among_peers_nearer {
1144            let response = HealPostBundleFeedbackResponseV1 { accepted_count: 0 };
1145            return Ok((PayloadResponseKind::HealPostBundleFeedbackResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)));
1146        }
1147
1148        // Check we actually have a post bundle for this location (same guard as SubmitPostFeedbackV1)
1149        let _post_bundle_lock = self.environment.get_read_lock_for_location_id(&request.location_id);
1150        let post_bundle_bytes = self.environment.get_post_bundle_bytes(time_millis, &request.location_id)?;
1151        let Some(post_bundle_bytes) = post_bundle_bytes
1152        else {
1153            let response = HealPostBundleFeedbackResponseV1 { accepted_count: 0 };
1154            return Ok((PayloadResponseKind::HealPostBundleFeedbackResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)));
1155        };
1156        let post_bundle = EncodedPostBundleV1::from_bytes(Bytes::from_owner(post_bundle_bytes), false)?;
1157
1158        let mut accepted_count: u32 = 0;
1159        for feedback in &request.encoded_post_feedbacks {
1160            // Only store feedback for posts we actually hold
1161            if !post_bundle.header.encoded_post_ids.contains(&feedback.post_id) {
1162                continue;
1163            }
1164            // Same check as SubmitPostFeedbackV1: the claimed pow must be what the salt actually achieves,
1165            // otherwise anyone could plant pow=255 on any (post, feedback_type) with one cheap RPC.
1166            if let Err(e) = feedback.pow_verify() {
1167                warn!("Rejecting healed feedback with invalid pow for post_id={} at location_id={}: {}", feedback.post_id, request.location_id, e);
1168                continue;
1169            }
1170            self.environment.put_post_feedback_if_more_powerful(time_millis, &request.location_id, feedback)?;
1171            accepted_count += 1;
1172        }
1173
1174        if accepted_count > 0 {
1175            trace!("Accepted {} healed feedback(s) for location_id={}", accepted_count, request.location_id);
1176        }
1177
1178        let response = HealPostBundleFeedbackResponseV1 { accepted_count };
1179        Ok((PayloadResponseKind::HealPostBundleFeedbackResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
1180    }
1181
1182    #[allow(non_snake_case)]
1183    async fn dispatch_network_payload_x_CachePostBundleV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
1184        anyhow_assert_eq!(&PayloadRequestKind::CachePostBundleV1, &payload_request_kind);
1185
1186        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
1187        let request = CachePostBundleV1::from_bytes(&mut bytes)?;
1188        trace!("received CachePostBundleV1 for bucket_location={}", request.token.bucket_location);
1189
1190        // Verify token was issued by this server and has not expired
1191        let peer_self = self.peer_self.read().clone();
1192        if request.token.peer.id != peer_self.id {
1193            anyhow::bail!("CachePostBundleV1: token was not issued by this server");
1194        }
1195        request.token.verify()?;
1196        if request.token.is_expired(time_millis) {
1197            anyhow::bail!("CachePostBundleV1: token has expired");
1198        }
1199
1200        let mut any_accepted = false;
1201        for bundle_bytes in request.encoded_post_bundles {
1202            let parse_result: anyhow::Result<()> = try {
1203                let mut encoded_post_bundle = EncodedPostBundleV1::from_bytes(bundle_bytes.clone(), true)?;
1204
1205                // Check that this proposed cache content is a genuine server's bundle for this very bucket - otherwise
1206                // free self-signed (or empty) bundles could squat this location's originator slots.
1207                encoded_post_bundle.verify_for_bucket(&request.token.bucket_location, time_millis)?;
1208                if encoded_post_bundle.header.num_posts == 0 {
1209                    Err(anyhow::anyhow!("empty bundle is not worth caching"))?; // NB: not bail!, which would return from the handler rather than this try block
1210                }
1211
1212                // Never trust the publisher's own sealed flag: a sealed bundle is cached with no expiry.
1213                // NB: only the flag is taken from the normalised copy - we cache the original signed bytes.
1214                encoded_post_bundle.normalise_flags_for_bucket(&request.token.bucket_location, time_millis);
1215                let originator_peer_id = encoded_post_bundle.header.peer.id;
1216                let is_sealed = encoded_post_bundle.header.sealed;
1217                let bundle_time_millis = encoded_post_bundle.header.time_millis;
1218                if self.post_bundle_cache.on_upload(request.token.bucket_location.location_id, originator_peer_id, bundle_bytes, bundle_time_millis, time_millis, is_sealed) {
1219                    any_accepted = true;
1220                }
1221            };
1222            if let Err(e) = &parse_result {
1223                warn!("CachePostBundleV1: failed to parse bundle: {}", e);
1224            }
1225        }
1226        let response = CachePostBundleResponseV1 { accepted: any_accepted };
1227        Ok((PayloadResponseKind::CachePostBundleResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
1228    }
1229
1230    #[allow(non_snake_case)]
1231    async fn dispatch_network_payload_x_CachePostBundleFeedbackV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
1232        anyhow_assert_eq!(&PayloadRequestKind::CachePostBundleFeedbackV1, &payload_request_kind);
1233
1234        let time_millis = self.runtime_services.time_provider.as_ref().current_time_millis();
1235        let request = CachePostBundleFeedbackV1::from_bytes(&mut bytes)?;
1236        trace!("received CachePostBundleFeedbackV1 for bucket_location={}", request.token.bucket_location);
1237
1238        // Verify token was issued by this server and has not expired
1239        let peer_self = self.peer_self.read().clone();
1240        if request.token.peer.id != peer_self.id {
1241            anyhow::bail!("CachePostBundleFeedbackV1: token was not issued by this server");
1242        }
1243        request.token.verify()?;
1244        if request.token.is_expired(time_millis) {
1245            anyhow::bail!("CachePostBundleFeedbackV1: token has expired");
1246        }
1247
1248        let result: anyhow::Result<bool> = try {
1249            let encoded_post_bundle_feedback = EncodedPostBundleFeedbackV1::from_bytes(request.encoded_post_bundle_feedback_bytes.clone())?;
1250
1251            encoded_post_bundle_feedback.verify_for_location(&request.token.bucket_location.location_id)?;
1252
1253            let originator_peer_id = encoded_post_bundle_feedback.header.peer.id;
1254            // Feedback bundles have no sealed flag — treat as live (5-min TTL)
1255            self.post_bundle_feedback_cache
1256                .on_upload(request.token.bucket_location.location_id, originator_peer_id, request.encoded_post_bundle_feedback_bytes, time_millis, false)
1257        };
1258        let accepted = result.unwrap_or_else(|e| {
1259            warn!("CachePostBundleFeedbackV1: parse error: {}", e);
1260            false
1261        });
1262
1263        let response = CachePostBundleFeedbackResponseV1 { accepted };
1264        Ok((PayloadResponseKind::CachePostBundleFeedbackResponseV1, BytesGatherer::from_bytes(json::struct_to_bytes(&response)?)))
1265    }
1266
1267    #[allow(non_snake_case)]
1268    async fn dispatch_network_payload_x_FetchUrlPreviewV1(&self, _cancellation_token: CancellationToken, pow: Option<PeerPow>, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
1269        anyhow_assert_eq!(&PayloadRequestKind::FetchUrlPreviewV1, &payload_request_kind);
1270
1271        // Outbound fetches are expensive for us and for the third-party site, so demand the extra pow before doing any work.
1272        let pow = pow.ok_or_else(|| anyhow::anyhow!("pow required for FetchUrlPreviewV1"))?;
1273        if pow.pow < config::POW_MINIMUM_PER_URL_FETCH {
1274            anyhow::bail!("Insufficient pow for FetchUrlPreviewV1: {} < {}", pow.pow, config::POW_MINIMUM_PER_URL_FETCH);
1275        }
1276
1277        let request = FetchUrlPreviewV1::from_bytes(&mut bytes)?;
1278        trace!("received FetchUrlPreviewV1 for url={}", request.url);
1279
1280        // SSRF protection:
1281        // https:// only — blocks plaintext metadata endpoints and forces TLS cert validation.
1282        if !request.url.starts_with("https://") {
1283            anyhow::bail!("FetchUrlPreviewV1 SSRF: only https:// URLs are allowed");
1284        }
1285
1286        // Extract host; reject bare IP literals (IPv4 and IPv6 bracket form).
1287        let host_and_port = request.url["https://".len()..].split(&['/', '?', '#'][..]).next().unwrap_or("");
1288        let host = if host_and_port.starts_with('[') {
1289            // IPv6 literal [addr] or [addr]:port
1290            host_and_port.trim_start_matches('[').split(']').next().unwrap_or("")
1291        } else {
1292            // hostname or IPv4 — strip optional port
1293            host_and_port.split(':').next().unwrap_or(host_and_port)
1294        };
1295        if host.is_empty() {
1296            anyhow::bail!("FetchUrlPreviewV1 SSRF: could not extract host from URL");
1297        }
1298        if host.parse::<std::net::IpAddr>().is_ok() {
1299            anyhow::bail!("FetchUrlPreviewV1 SSRF: bare IP addresses are not allowed");
1300        }
1301
1302        // Resolve once and validate every returned address.  Collecting into a Vec lets us
1303        // re-use the same addresses to pin the reqwest client, closing the DNS-rebinding window
1304        // (nip.io, metadata.google.internal, TTL=0 rebind, etc.).
1305        let resolved_socket_addrs: Vec<std::net::SocketAddr> = resolve_url_preview_host(host, config::URL_FETCH_DNS_TIMEOUT_MILLIS.into(), tokio::net::lookup_host((host, 443u16))).await?;
1306        if resolved_socket_addrs.is_empty() {
1307            anyhow::bail!("FetchUrlPreviewV1 SSRF: DNS returned no addresses for {}", host);
1308        }
1309        for socket_addr in &resolved_socket_addrs {
1310            let ip = socket_addr.ip();
1311            if is_ssrf_protected_ip(ip) {
1312                anyhow::bail!("FetchUrlPreviewV1 SSRF: {} resolved to protected address {}", host, ip);
1313            }
1314        }
1315
1316        // Build a client that:
1317        //   - resolve_to_addrs: skips re-resolution entirely — the validated IPs are used directly
1318        //   - redirect::none: prevents a server-side redirect to an unvalidated internal URL
1319        //   - no_proxy: ignores HTTP_PROXY / NO_PROXY env vars that could route to internal hosts
1320        //   - times out quickly in unreasonable scenarios
1321        //   - limits download size
1322        let http_client = reqwest::Client::builder()
1323            .connect_timeout(config::URL_FETCH_CONNECT_TIMEOUT_MILLIS.into())
1324            .timeout(config::URL_FETCH_TOTAL_TIMEOUT_MILLIS.into())
1325            .user_agent("hashiverse-preview/1.0")
1326            .resolve_to_addrs(host, &resolved_socket_addrs)
1327            .redirect(reqwest::redirect::Policy::none())
1328            .no_proxy()
1329            .build()?;
1330
1331        const URL_FETCH_MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
1332        let mut http_response = http_client.get(&request.url).send().await?;
1333
1334        // Reject early if Content-Length already exceeds the limit — before reading any body bytes.
1335        if let Some(content_length) = http_response.content_length() {
1336            if content_length > URL_FETCH_MAX_BODY_BYTES as u64 {
1337                anyhow::bail!("FetchUrlPreviewV1: Content-Length {} exceeds {} byte limit", content_length, URL_FETCH_MAX_BODY_BYTES);
1338            }
1339        }
1340        let mut body_bytes = BytesMut::new();
1341        while let Some(chunk) = http_response.chunk().await? {
1342            // body_bytes.len() is already within the limit; only the new chunk can overflow.
1343            // We copy only as many bytes as fit rather than bailing, so a single large chunk
1344            // (which reqwest has already buffered) doesn't cause an error — we just truncate.
1345            // This is sufficient for HTML preview extraction which only needs the <head>.
1346            let remaining = URL_FETCH_MAX_BODY_BYTES - body_bytes.len();
1347            body_bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
1348            if body_bytes.len() >= URL_FETCH_MAX_BODY_BYTES {
1349                break;
1350            }
1351        }
1352        let html = String::from_utf8_lossy(&body_bytes).into_owned();
1353
1354        let preview_data = url_preview::extract_url_preview(&html);
1355
1356        let response = FetchUrlPreviewResponseV1 {
1357            url: if preview_data.canonical_url.is_empty() { request.url } else { preview_data.canonical_url },
1358            title: preview_data.title,
1359            description: preview_data.description,
1360            image_url: preview_data.image_url,
1361        };
1362
1363        Ok((PayloadResponseKind::FetchUrlPreviewResponseV1, BytesGatherer::from_bytes(response.to_bytes()?)))
1364    }
1365
1366    #[allow(non_snake_case)]
1367    async fn dispatch_network_payload_x_TrendingHashtagsFetchV1(&self, _cancellation_token: CancellationToken, payload_request_kind: PayloadRequestKind, mut bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
1368        anyhow_assert_eq!(&PayloadRequestKind::TrendingHashtagsFetchV1, &payload_request_kind);
1369
1370        let request = TrendingHashtagsFetchV1::from_bytes(&mut bytes)?;
1371        trace!("received TrendingHashtagsFetchV1 with limit={}", request.limit);
1372
1373        let time_millis = self.runtime_services.time_provider.current_time_millis();
1374
1375        // Check if we have a cached response that is less than a few minutes old
1376        let cached_response = {
1377            let cache = self.trending_hashtags_response_cache.lock();
1378            match cache.as_ref() {
1379                Some((cached_time, cached_response)) if (time_millis - *cached_time) < MILLIS_IN_SECOND.const_mul(30) => {
1380                    Some(cached_response.clone())
1381                }
1382                _ => None,
1383            }
1384        };
1385
1386        let mut response = match cached_response {
1387            Some(mut cached) => {
1388                cached.trending_hashtags.truncate(request.limit as usize);
1389                cached
1390            }
1391            None => {
1392                // Recalculate: iterate the trending_hashtags cache, sort by unique author count
1393                let mut trending_hashtags: Vec<TrendingHashtagV1> = self.trending_hashtags.iter()
1394                    .map(|(hashtag, hll)| TrendingHashtagV1 {
1395                        hashtag: hashtag.as_ref().clone(),
1396                        count: hll.count(),
1397                    })
1398                    .filter(|entry| entry.count > 0)
1399                    .collect();
1400
1401                trending_hashtags.sort_by_key(|entry| std::cmp::Reverse(entry.count));
1402
1403                let full_response = TrendingHashtagsFetchResponseV1 { trending_hashtags };
1404
1405                // Cache the full response (real trending only — fallbacks are applied per-response after truncation)
1406                {
1407                    let mut cache = self.trending_hashtags_response_cache.lock();
1408                    *cache = Some((time_millis, full_response.clone()));
1409                }
1410
1411                let mut truncated_response = full_response;
1412                truncated_response.trending_hashtags.truncate(request.limit as usize);
1413                truncated_response
1414            }
1415        };
1416
1417        top_up_trending_hashtags_with_fallback(&mut response.trending_hashtags, request.limit, TRENDING_HASHTAGS_FALLBACK);
1418
1419        Ok((PayloadResponseKind::TrendingHashtagsFetchResponseV1, BytesGatherer::from_bytes(response.to_bytes()?)))
1420    }
1421
1422    #[allow(non_snake_case)]
1423    async fn dispatch_network_payload_x_PeerStatsRequestV1(&self, _cancellation_token: CancellationToken, pow: Option<PeerPow>, payload_request_kind: PayloadRequestKind, bytes: Bytes) -> anyhow::Result<(PayloadResponseKind, BytesGatherer)> {
1424        anyhow_assert_eq!(&PayloadRequestKind::PeerStatsRequestV1, &payload_request_kind);
1425
1426        let _request = PeerStatsRequestV1::from_bytes(&bytes)?;
1427
1428        let pow = pow.ok_or_else(|| anyhow::anyhow!("pow required for PeerStatsRequestV1"))?;
1429        if pow.pow < config::POW_MINIMUM_PER_PEER_STATS {
1430            anyhow::bail!("Insufficient pow for PeerStatsRequestV1: {} < {}", pow.pow, config::POW_MINIMUM_PER_PEER_STATS);
1431        }
1432
1433        let time_millis = self.runtime_services.time_provider.current_time_millis();
1434
1435        // Cache check — hand back the same signed blob within the TTL so callers
1436        // re-sharing it operate on a single canonical byte sequence per minute.
1437        let cached_response = {
1438            let cache = self.peer_stats_response_cache.lock();
1439            match cache.as_ref() {
1440                Some((cached_time, cached_response)) if (time_millis - *cached_time) < MILLIS_IN_SECOND.const_mul(60) => Some(cached_response.clone()),
1441                _ => None,
1442            }
1443        };
1444
1445        let response = match cached_response {
1446            Some(cached) => cached,
1447            None => {
1448                let doc = serde_json::json!({
1449                    "version":     env!("CARGO_PKG_VERSION"),
1450                    "requests":    request_counts_subtree(&self.request_counters, &self.request_rate_windows, time_millis),
1451                    "system":      system_stats_subtree(),
1452                    "kademlia":    kademlia_stats_subtree(&self.kademlia.read()),
1453                    "environment": environment_stats_subtree(&self.environment),
1454                });
1455
1456                let json_bytes = serde_json::to_vec(&doc)?;
1457                let json_compressed = compression::compress_for_speed(&json_bytes)?.to_bytes();
1458
1459                let signing_input = PeerStatsResponseV1::signing_input(time_millis, &json_compressed);
1460                let signature = signing::sign(&self.server_id.keys.signature_key, &signing_input);
1461
1462                let response = PeerStatsResponseV1 {
1463                    peer: self.peer_self.read().clone(),
1464                    timestamp: time_millis,
1465                    json_compressed,
1466                    signature,
1467                };
1468
1469                *self.peer_stats_response_cache.lock() = Some((time_millis, response.clone()));
1470                response
1471            }
1472        };
1473
1474        Ok((PayloadResponseKind::PeerStatsResponseV1, BytesGatherer::from_bytes(response.to_bytes()?)))
1475    }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480    use super::*;
1481
1482    mod complete_incoming_request_outcomes {
1483        use super::*;
1484        use hashiverse_lib::transport::ddos::ddos::{DdosConnectionGuard, DdosProtection};
1485        use hashiverse_lib::transport::ddos::mem_ddos::MemDdosProtection;
1486        use hashiverse_lib::tools::time_provider::manual_time_provider::ManualTimeProvider;
1487        use std::sync::Arc;
1488        use tokio::sync::oneshot;
1489
1490        const CALLER_IP: &str = "10.1.2.3";
1491
1492        /// Threshold 1, no decay, penalty 1: a single bad-request report blocks the IP.
1493        fn make_incoming_request() -> (Arc<MemDdosProtection>, IncomingRequest, oneshot::Receiver<BytesGatherer>) {
1494            let ddos = Arc::new(MemDdosProtection::new(1.0, 0.0, 1.0, 8, Arc::new(ManualTimeProvider::default())));
1495            let ddos_connection_guard = DdosConnectionGuard::try_new(ddos.clone(), CALLER_IP).expect("fresh ip must get a connection slot");
1496            let (reply_tx, reply_rx) = oneshot::channel();
1497            let incoming = IncomingRequest::new(CALLER_IP.to_string(), Bytes::new(), reply_tx, Arc::new(ddos_connection_guard));
1498            (ddos, incoming, reply_rx)
1499        }
1500
1501        /// Probe the ban state without touching the score (`allow_request` itself adds a point per call).
1502        fn is_blocked(ddos: &MemDdosProtection) -> bool {
1503            !ddos.try_acquire_connection(CALLER_IP)
1504        }
1505
1506        #[tokio::test]
1507        async fn panic_outcome_reports_bad_request() {
1508            let (ddos, incoming, reply_rx) = make_incoming_request();
1509            assert!(!is_blocked(&ddos));
1510            complete_incoming_request(incoming, Err(anyhow::anyhow!("panic: boom")));
1511            assert!(is_blocked(&ddos), "a handler panic must count against the caller");
1512            assert!(reply_rx.await.is_err(), "no reply must be sent for a panic");
1513        }
1514
1515        #[tokio::test]
1516        async fn error_outcome_reports_bad_request() {
1517            let (ddos, incoming, reply_rx) = make_incoming_request();
1518            complete_incoming_request(incoming, Ok(Err(anyhow::anyhow!("bad packet"))));
1519            assert!(is_blocked(&ddos));
1520            assert!(reply_rx.await.is_err());
1521        }
1522
1523        #[tokio::test]
1524        async fn success_outcome_does_not_report_and_replies() {
1525            let (ddos, incoming, reply_rx) = make_incoming_request();
1526            complete_incoming_request(incoming, Ok(Ok(BytesGatherer::from_bytes(Bytes::from_static(b"ok")))));
1527            assert!(!is_blocked(&ddos));
1528            assert_eq!(reply_rx.await.expect("reply must be sent").to_bytes(), Bytes::from_static(b"ok"));
1529        }
1530    }
1531
1532    #[tokio::test]
1533    async fn catch_panic_as_error_passes_through_value() {
1534        let result = catch_panic_as_error(async { 42 }).await;
1535        assert_eq!(result.unwrap(), 42);
1536    }
1537
1538    #[tokio::test]
1539    async fn catch_panic_as_error_converts_str_panic() {
1540        let result: anyhow::Result<()> = catch_panic_as_error(async { panic!("boom") }).await;
1541        let error_message = result.unwrap_err().to_string();
1542        assert!(error_message.contains("boom"), "error_message={}", error_message);
1543    }
1544
1545    #[tokio::test]
1546    async fn catch_panic_as_error_converts_formatted_panic() {
1547        let duration = 60000;
1548        let result: anyhow::Result<()> = catch_panic_as_error(async move { panic!("bad duration {}", duration) }).await;
1549        let error_message = result.unwrap_err().to_string();
1550        assert!(error_message.contains("bad duration 60000"), "error_message={}", error_message);
1551    }
1552
1553    #[tokio::test]
1554    async fn catch_panic_as_error_converts_assert_panic() {
1555        let result: anyhow::Result<()> = catch_panic_as_error(async { assert!(1 > 2) }).await;
1556        assert!(result.is_err());
1557    }
1558
1559    fn make_trending_hashtag(hashtag: &str, count: u64) -> TrendingHashtagV1 {
1560        TrendingHashtagV1 { hashtag: hashtag.to_string(), count }
1561    }
1562
1563    #[test]
1564    fn top_up_adds_fallback_when_empty() {
1565        let mut trending_hashtags: Vec<TrendingHashtagV1> = vec![];
1566        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 5, &["#hashiverse", "#news"]);
1567        assert_eq!(trending_hashtags.len(), 2);
1568        assert_eq!(trending_hashtags[0].hashtag, "#hashiverse");
1569        assert_eq!(trending_hashtags[0].count, 0);
1570        assert_eq!(trending_hashtags[1].hashtag, "#news");
1571        assert_eq!(trending_hashtags[1].count, 0);
1572    }
1573
1574    #[test]
1575    fn top_up_respects_limit_smaller_than_fallback_list() {
1576        let mut trending_hashtags: Vec<TrendingHashtagV1> = vec![];
1577        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 1, &["#hashiverse", "#news"]);
1578        assert_eq!(trending_hashtags.len(), 1);
1579        assert_eq!(trending_hashtags[0].hashtag, "#hashiverse");
1580    }
1581
1582    #[test]
1583    fn top_up_preserves_fallback_order() {
1584        let mut trending_hashtags: Vec<TrendingHashtagV1> = vec![];
1585        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 3, &["#first", "#second", "#third"]);
1586        assert_eq!(trending_hashtags[0].hashtag, "#first");
1587        assert_eq!(trending_hashtags[1].hashtag, "#second");
1588        assert_eq!(trending_hashtags[2].hashtag, "#third");
1589    }
1590
1591    #[test]
1592    fn top_up_is_noop_when_already_at_limit() {
1593        let mut trending_hashtags = vec![make_trending_hashtag("#rust", 10), make_trending_hashtag("#golang", 5)];
1594        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 2, &["#hashiverse", "#news"]);
1595        assert_eq!(trending_hashtags.len(), 2);
1596        assert_eq!(trending_hashtags[0].hashtag, "#rust");
1597        assert_eq!(trending_hashtags[1].hashtag, "#golang");
1598    }
1599
1600    #[test]
1601    fn top_up_partially_fills_when_real_trending_exists() {
1602        let mut trending_hashtags = vec![make_trending_hashtag("#rust", 10)];
1603        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 3, &["#hashiverse", "#news"]);
1604        assert_eq!(trending_hashtags.len(), 3);
1605        assert_eq!(trending_hashtags[0].hashtag, "#rust");
1606        assert_eq!(trending_hashtags[0].count, 10);
1607        assert_eq!(trending_hashtags[1].hashtag, "#hashiverse");
1608        assert_eq!(trending_hashtags[1].count, 0);
1609        assert_eq!(trending_hashtags[2].hashtag, "#news");
1610        assert_eq!(trending_hashtags[2].count, 0);
1611    }
1612
1613    #[test]
1614    fn top_up_skips_fallback_already_present_exact_match() {
1615        let mut trending_hashtags = vec![make_trending_hashtag("#hashiverse", 42)];
1616        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 3, &["#hashiverse", "#news"]);
1617        assert_eq!(trending_hashtags.len(), 2);
1618        assert_eq!(trending_hashtags[0].hashtag, "#hashiverse");
1619        assert_eq!(trending_hashtags[0].count, 42, "real trending entry must not be overwritten by filler");
1620        assert_eq!(trending_hashtags[1].hashtag, "#news");
1621        assert_eq!(trending_hashtags[1].count, 0);
1622    }
1623
1624    #[test]
1625    fn top_up_dedup_is_case_insensitive_and_prefix_agnostic() {
1626        // Existing entry "HashiVerse" (no `#`, mixed case) should match fallback "#hashiverse"
1627        let mut trending_hashtags = vec![make_trending_hashtag("HashiVerse", 7)];
1628        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 3, &["#hashiverse", "#news"]);
1629        assert_eq!(trending_hashtags.len(), 2);
1630        assert_eq!(trending_hashtags[0].hashtag, "HashiVerse");
1631        assert_eq!(trending_hashtags[1].hashtag, "#news");
1632    }
1633
1634    #[test]
1635    fn top_up_with_empty_fallback_is_noop() {
1636        let mut trending_hashtags: Vec<TrendingHashtagV1> = vec![];
1637        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 5, &[]);
1638        assert_eq!(trending_hashtags.len(), 0);
1639    }
1640
1641    #[test]
1642    fn top_up_handles_zero_limit() {
1643        let mut trending_hashtags: Vec<TrendingHashtagV1> = vec![];
1644        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 0, &["#hashiverse", "#news"]);
1645        assert_eq!(trending_hashtags.len(), 0);
1646    }
1647
1648    #[test]
1649    fn top_up_exhausts_fallback_without_reaching_limit() {
1650        // Limit is larger than what real trending + fallback together can satisfy
1651        let mut trending_hashtags: Vec<TrendingHashtagV1> = vec![];
1652        top_up_trending_hashtags_with_fallback(&mut trending_hashtags, 10, &["#hashiverse", "#news"]);
1653        assert_eq!(trending_hashtags.len(), 2, "should stop at the end of the fallback list, not pad further");
1654    }
1655
1656    mod fetch_url_preview {
1657        use super::*;
1658        use crate::environment::mem_environment_store::MemEnvironmentFactory;
1659        use crate::environment::environment::EnvironmentFactory;
1660        use crate::server::args::Args;
1661        use crate::server::hashiverse_server::HashiverseServer;
1662        use hashiverse_lib::protocol::peer::PeerPow;
1663        use hashiverse_lib::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
1664        use hashiverse_lib::tools::runtime_services::RuntimeServices;
1665        use hashiverse_lib::tools::time_provider::time_provider::RealTimeProvider;
1666        use hashiverse_lib::tools::types::Pow;
1667        use hashiverse_lib::transport::mem_transport::MemTransportFactory;
1668        use std::sync::Arc;
1669        use std::time::Duration;
1670
1671        async fn make_server() -> anyhow::Result<Arc<HashiverseServer>> {
1672            let time_provider = Arc::new(RealTimeProvider);
1673            let transport_factory = MemTransportFactory::default();
1674            let pow_generator = Arc::new(SingleThreadedPowGenerator::new());
1675            let runtime_services = Arc::new(RuntimeServices { time_provider, transport_factory, pow_generator });
1676            let environment_factory = Arc::new(MemEnvironmentFactory::new(""));
1677            let args = Args::default_for_testing();
1678            HashiverseServer::new(runtime_services, environment_factory, args).await
1679        }
1680
1681        fn synthetic_pow(pow: Pow) -> PeerPow {
1682            let mut peer_pow = PeerPow::zero();
1683            peer_pow.pow = pow;
1684            peer_pow
1685        }
1686
1687        /// A non-https URL: if the pow check passes, the handler rejects it on the SSRF check without touching the network.
1688        fn request_bytes() -> Bytes {
1689            FetchUrlPreviewV1::new_to_bytes("http://example.com/").expect("FetchUrlPreviewV1 must serialise")
1690        }
1691
1692        async fn fetch_url_preview_error(pow: Option<PeerPow>) -> String {
1693            let server = make_server().await.expect("server must start");
1694            let result = server.dispatch_network_payload_x_FetchUrlPreviewV1(CancellationToken::new(), pow, PayloadRequestKind::FetchUrlPreviewV1, request_bytes()).await;
1695            result.err().expect("handler must reject this request").to_string()
1696        }
1697
1698        #[tokio::test]
1699        async fn rejects_missing_pow() {
1700            let error = fetch_url_preview_error(None).await;
1701            assert!(error.contains("pow required for FetchUrlPreviewV1"), "unexpected error: {}", error);
1702        }
1703
1704        #[tokio::test]
1705        async fn rejects_insufficient_pow() {
1706            let error = fetch_url_preview_error(Some(synthetic_pow(Pow(config::POW_MINIMUM_PER_URL_FETCH.0 - 1)))).await;
1707            assert!(error.contains("Insufficient pow for FetchUrlPreviewV1"), "unexpected error: {}", error);
1708        }
1709
1710        #[tokio::test]
1711        async fn accepts_threshold_pow() {
1712            let error = fetch_url_preview_error(Some(synthetic_pow(config::POW_MINIMUM_PER_URL_FETCH))).await;
1713            assert!(error.contains("only https:// URLs are allowed"), "pow check should pass and fall through to the SSRF check, got: {}", error);
1714        }
1715
1716        #[tokio::test]
1717        async fn dns_lookup_times_out() {
1718            let never_resolving_lookup = std::future::pending::<std::io::Result<std::vec::IntoIter<std::net::SocketAddr>>>();
1719            let error = resolve_url_preview_host("example.com", Duration::from_millis(50), never_resolving_lookup).await.err().expect("lookup must time out");
1720            assert!(error.to_string().contains("DNS resolution timed out for example.com"), "unexpected error: {}", error);
1721        }
1722
1723        #[tokio::test]
1724        async fn dns_lookup_passes_through_results_and_errors() {
1725            let socket_addr: std::net::SocketAddr = "93.184.216.34:443".parse().unwrap();
1726            let resolved = resolve_url_preview_host("example.com", Duration::from_secs(1), async move { Ok(vec![socket_addr].into_iter()) }).await.expect("lookup must succeed");
1727            assert_eq!(resolved, vec![socket_addr]);
1728
1729            let failed_lookup = async { Err::<std::vec::IntoIter<std::net::SocketAddr>, _>(std::io::Error::other("nxdomain")) };
1730            let error = resolve_url_preview_host("example.com", Duration::from_secs(1), failed_lookup).await.err().expect("lookup must fail");
1731            assert!(error.to_string().contains("DNS resolution failed for example.com"), "unexpected error: {}", error);
1732        }
1733    }
1734
1735    mod peer_stats {
1736        use super::*;
1737        use crate::environment::mem_environment_store::MemEnvironmentFactory;
1738        use crate::environment::environment::EnvironmentFactory;
1739        use crate::server::args::Args;
1740        use crate::server::hashiverse_server::HashiverseServer;
1741        use hashiverse_lib::protocol::payload::payload::{PAYLOAD_REQUEST_KIND_COUNT, PeerStatsRequestV1, PeerStatsResponseV1};
1742        use hashiverse_lib::protocol::peer::PeerPow;
1743        use hashiverse_lib::tools::compression;
1744        use hashiverse_lib::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
1745        use hashiverse_lib::tools::runtime_services::RuntimeServices;
1746        use hashiverse_lib::tools::time::TimeMillis;
1747        use hashiverse_lib::tools::time_provider::time_provider::RealTimeProvider;
1748        use hashiverse_lib::tools::types::{Pow, VerificationKey};
1749        use hashiverse_lib::transport::mem_transport::MemTransportFactory;
1750        use std::sync::Arc;
1751        use std::sync::atomic::Ordering;
1752
1753        async fn make_server() -> anyhow::Result<Arc<HashiverseServer>> {
1754            let time_provider = Arc::new(RealTimeProvider);
1755            let transport_factory = MemTransportFactory::default();
1756            let pow_generator = Arc::new(SingleThreadedPowGenerator::new());
1757            let runtime_services = Arc::new(RuntimeServices { time_provider, transport_factory, pow_generator });
1758            let environment_factory = Arc::new(MemEnvironmentFactory::new(""));
1759            let args = Args::default_for_testing();
1760            HashiverseServer::new(runtime_services, environment_factory, args).await
1761        }
1762
1763        /// Build a synthetic PeerPow with the requested pow value. The handler under
1764        /// test only checks the threshold against `config::POW_MINIMUM_PER_PEER_STATS`
1765        /// and does not re-verify the underlying PoW computation, so this is enough.
1766        fn synthetic_pow(pow: Pow) -> PeerPow {
1767            let mut peer_pow = PeerPow::zero();
1768            peer_pow.pow = pow;
1769            peer_pow
1770        }
1771
1772        fn empty_request_bytes() -> Bytes {
1773            PeerStatsRequestV1 {}.to_bytes().expect("PeerStatsRequestV1 must serialise")
1774        }
1775
1776        fn decode_doc(response: &PeerStatsResponseV1) -> serde_json::Value {
1777            let bytes = compression::decompress(&response.json_compressed).expect("decompress doc").to_bytes();
1778            serde_json::from_slice(&bytes).expect("doc must be valid JSON")
1779        }
1780
1781        #[tokio::test]
1782        async fn rejects_insufficient_pow() {
1783            let server = make_server().await.expect("server must start");
1784            let result = server
1785                .dispatch_network_payload_x_PeerStatsRequestV1(
1786                    CancellationToken::new(),
1787                    Some(synthetic_pow(Pow(config::POW_MINIMUM_PER_PEER_STATS.0.saturating_sub(1)))),
1788                    PayloadRequestKind::PeerStatsRequestV1,
1789                    empty_request_bytes(),
1790                )
1791                .await;
1792            assert!(result.is_err(), "expected insufficient PoW to be rejected");
1793        }
1794
1795        #[tokio::test]
1796        async fn returns_response_with_expected_top_level_keys() {
1797            let server = make_server().await.expect("server must start");
1798            let (response_kind, gatherer) = server
1799                .dispatch_network_payload_x_PeerStatsRequestV1(
1800                    CancellationToken::new(),
1801                    Some(synthetic_pow(config::POW_MINIMUM_PER_PEER_STATS)),
1802                    PayloadRequestKind::PeerStatsRequestV1,
1803                    empty_request_bytes(),
1804                )
1805                .await
1806                .expect("handler must succeed at threshold pow");
1807            assert_eq!(response_kind, PayloadResponseKind::PeerStatsResponseV1);
1808
1809            let response_bytes = gatherer.to_bytes();
1810            let response = PeerStatsResponseV1::from_bytes(&response_bytes).expect("response must decode");
1811            let doc = decode_doc(&response);
1812
1813            assert!(doc.get("version").and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false), "version must be a non-empty string");
1814            assert_eq!(doc["version"].as_str().unwrap(), env!("CARGO_PKG_VERSION"));
1815            assert!(doc.get("requests").is_some(), "requests subtree missing");
1816            assert!(doc.get("system").is_some(), "system subtree missing");
1817            assert!(doc.get("kademlia").is_some(), "kademlia subtree missing");
1818            assert!(doc.get("environment").is_some(), "environment subtree missing");
1819
1820            for key in ["memory_total_bytes", "memory_free_bytes", "disk_total_bytes", "disk_free_bytes", "load_1m", "load_5m", "load_15m"] {
1821                assert!(doc["system"].get(key).map(|v| v.is_number()).unwrap_or(false), "system.{key} must be a number");
1822            }
1823            for key in ["post_bundle_count", "post_bundle_feedback_count", "post_bundle_total_bytes"] {
1824                assert!(doc["environment"].get(key).map(|v| v.is_number()).unwrap_or(false), "environment.{key} must be a number");
1825            }
1826        }
1827
1828        #[tokio::test]
1829        async fn counters_reflect_recorded_dispatches() {
1830            let server = make_server().await.expect("server must start");
1831
1832            // Simulate inbound PingV1 dispatches by bumping the same counter the
1833            // wrap_and_dispatch path bumps. This avoids the full RPC envelope dance
1834            // while exercising the read path used by the stats handler.
1835            for _ in 0..7 {
1836                server.request_counters[PayloadRequestKind::PingV1 as usize].fetch_add(1, Ordering::Relaxed);
1837            }
1838
1839            let (_, gatherer) = server
1840                .dispatch_network_payload_x_PeerStatsRequestV1(
1841                    CancellationToken::new(),
1842                    Some(synthetic_pow(config::POW_MINIMUM_PER_PEER_STATS)),
1843                    PayloadRequestKind::PeerStatsRequestV1,
1844                    empty_request_bytes(),
1845                )
1846                .await
1847                .expect("handler must succeed");
1848            let response = PeerStatsResponseV1::from_bytes(&gatherer.to_bytes()).expect("response must decode");
1849            let doc = decode_doc(&response);
1850            assert_eq!(doc["requests"]["PingV1"]["total"].as_u64(), Some(7));
1851        }
1852
1853        #[tokio::test]
1854        async fn cache_returns_byte_identical_response_within_ttl() {
1855            let server = make_server().await.expect("server must start");
1856            let pow = synthetic_pow(config::POW_MINIMUM_PER_PEER_STATS);
1857
1858            let (_, gatherer_a) = server
1859                .dispatch_network_payload_x_PeerStatsRequestV1(CancellationToken::new(), Some(pow.clone()), PayloadRequestKind::PeerStatsRequestV1, empty_request_bytes())
1860                .await
1861                .expect("first call must succeed");
1862            let bytes_a = gatherer_a.to_bytes();
1863
1864            // Mutate a counter between calls; if the cache hands back a freshly
1865            // built response we'd see the new count, but the cached blob predates it.
1866            server.request_counters[PayloadRequestKind::PingV1 as usize].fetch_add(99, Ordering::Relaxed);
1867
1868            let (_, gatherer_b) = server
1869                .dispatch_network_payload_x_PeerStatsRequestV1(CancellationToken::new(), Some(pow), PayloadRequestKind::PeerStatsRequestV1, empty_request_bytes())
1870                .await
1871                .expect("second call must succeed");
1872            let bytes_b = gatherer_b.to_bytes();
1873
1874            assert_eq!(bytes_a, bytes_b, "cached response should be byte-identical across the TTL");
1875        }
1876
1877        #[tokio::test]
1878        async fn signature_verifies_and_fails_on_tamper() {
1879            let server = make_server().await.expect("server must start");
1880            let (_, gatherer) = server
1881                .dispatch_network_payload_x_PeerStatsRequestV1(
1882                    CancellationToken::new(),
1883                    Some(synthetic_pow(config::POW_MINIMUM_PER_PEER_STATS)),
1884                    PayloadRequestKind::PeerStatsRequestV1,
1885                    empty_request_bytes(),
1886                )
1887                .await
1888                .expect("handler must succeed");
1889            let response = PeerStatsResponseV1::from_bytes(&gatherer.to_bytes()).expect("response must decode");
1890
1891            let verification_key = VerificationKey::from_bytes(&response.peer.verification_key_bytes).expect("verification key must decode");
1892            let signing_input = PeerStatsResponseV1::signing_input(response.timestamp, &response.json_compressed);
1893            signing::verify(&verification_key, &response.signature, &signing_input).expect("signature must verify against transmitted bytes");
1894
1895            // Mutate one byte of the compressed JSON: verification must fail.
1896            let mut tampered = response.json_compressed.to_vec();
1897            let tamper_index = tampered.len() / 2;
1898            tampered[tamper_index] ^= 0xff;
1899            let tampered_signing_input = PeerStatsResponseV1::signing_input(response.timestamp, &tampered);
1900            assert!(signing::verify(&verification_key, &response.signature, &tampered_signing_input).is_err(), "verification must fail when json_compressed is tampered");
1901
1902            // Mutate the timestamp: verification must also fail.
1903            let bumped_signing_input = PeerStatsResponseV1::signing_input(TimeMillis(response.timestamp.0 + 1), &response.json_compressed);
1904            assert!(signing::verify(&verification_key, &response.signature, &bumped_signing_input).is_err(), "verification must fail when timestamp is mutated");
1905        }
1906
1907        #[test]
1908        fn request_counts_subtree_covers_every_variant() {
1909            // Belt-and-braces guard for PAYLOAD_REQUEST_KIND_COUNT staying in lockstep
1910            // with the enum at the server-stats layer.
1911            let counters: [std::sync::atomic::AtomicU64; PAYLOAD_REQUEST_KIND_COUNT] = std::array::from_fn(|_| std::sync::atomic::AtomicU64::new(0));
1912            let windows: [parking_lot::Mutex<crate::server::stats::RequestRateWindows>; PAYLOAD_REQUEST_KIND_COUNT] =
1913                std::array::from_fn(|_| parking_lot::Mutex::new(crate::server::stats::RequestRateWindows::new()));
1914            let subtree = request_counts_subtree(&counters, &windows, TimeMillis::zero());
1915            let map = subtree.as_object().expect("request_counts subtree must be an object");
1916            assert_eq!(map.len(), PAYLOAD_REQUEST_KIND_COUNT);
1917        }
1918    }
1919
1920    mod announce_v2 {
1921        use super::*;
1922        use crate::environment::mem_environment_store::MemEnvironmentFactory;
1923        use crate::environment::environment::EnvironmentFactory;
1924        use crate::server::args::Args;
1925        use crate::server::hashiverse_server::HashiverseServer;
1926        use hashiverse_lib::protocol::payload::payload::AnnounceV2;
1927        use hashiverse_lib::protocol::peer::Peer;
1928        use hashiverse_lib::tools::pow_generator::pow_generator::PowGenerator;
1929        use hashiverse_lib::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
1930        use hashiverse_lib::tools::runtime_services::RuntimeServices;
1931        use hashiverse_lib::tools::server_id::ServerId;
1932        use hashiverse_lib::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
1933        use hashiverse_lib::transport::mem_transport::MemTransportFactory;
1934        use std::sync::Arc;
1935
1936        async fn make_test_server() -> Arc<HashiverseServer> {
1937            let time_provider = Arc::new(RealTimeProvider);
1938            let transport_factory = MemTransportFactory::default();
1939            let pow_generator = Arc::new(SingleThreadedPowGenerator::new());
1940            let runtime_services = Arc::new(RuntimeServices { time_provider, transport_factory, pow_generator });
1941            let environment_factory = Arc::new(MemEnvironmentFactory::new(""));
1942            let args = Args::default_for_testing();
1943            HashiverseServer::new(runtime_services, environment_factory, args).await.expect("server must start")
1944        }
1945
1946        /// Build a freshly-signed `Peer` to play the role of an inbound announcer. Uses a
1947        /// brand-new `ServerId` (separate identity from the receiving server) so the announce
1948        /// looks like it comes from a different node.
1949        async fn make_announcing_peer(time_provider: &dyn TimeProvider, pow_generator: &dyn PowGenerator, address: &str) -> Peer {
1950            let server_id = ServerId::new("own_pow", time_provider, config::SERVER_KEY_POW_MIN, true, pow_generator).await.expect("ServerId::new must succeed in tests (PoW reduced under cfg(test))");
1951            let mut peer = server_id.to_peer(time_provider).expect("to_peer must succeed");
1952            peer.address = address.to_string();
1953            peer.sign(time_provider, &server_id.keys.signature_key).expect("sign must succeed");
1954            peer
1955        }
1956
1957        #[tokio::test]
1958        async fn v2_admits_peer_when_proof_is_valid_for_mem_transport() {
1959            let server = make_test_server().await;
1960            let pow_generator = SingleThreadedPowGenerator::new();
1961            let announcing_peer = make_announcing_peer(server.runtime_services.time_provider.as_ref(), &pow_generator, "9999").await;
1962            let announcing_peer_id = announcing_peer.id;
1963
1964            let baseline_len = server.kademlia.read().len();
1965
1966            // Mem-transport's EmptyMarkerOwnershipProof accepts the empty proof — the announce should be admitted.
1967            let request_bytes = json::struct_to_bytes(&AnnounceV2 { peer_self: announcing_peer, proof_payload: vec![] }).expect("encode V2");
1968
1969            let (response_kind, _gatherer) = server
1970                .dispatch_network_payload_x_AnnounceV2(CancellationToken::new(), PayloadRequestKind::AnnounceV2, request_bytes)
1971                .await
1972                .expect("valid V2 must succeed on mem-transport");
1973
1974            assert_eq!(response_kind, PayloadResponseKind::AnnounceResponseV2);
1975            assert_eq!(server.kademlia.read().len(), baseline_len + 1, "announcer should now be in Kademlia");
1976
1977            // The peer is actually findable by id, not just that the count went up.
1978            let (peers, _) = server.kademlia.read().get_peers_for_key(&announcing_peer_id, 8);
1979            assert!(peers.iter().any(|p| p.id == announcing_peer_id), "announcing peer must be reachable through get_peers_for_key");
1980        }
1981
1982        #[tokio::test]
1983        async fn v2_bails_and_does_not_admit_when_proof_fails() {
1984            let server = make_test_server().await;
1985            let pow_generator = SingleThreadedPowGenerator::new();
1986            let announcing_peer = make_announcing_peer(server.runtime_services.time_provider.as_ref(), &pow_generator, "8888").await;
1987            let announcing_peer_id = announcing_peer.id;
1988
1989            let baseline_len = server.kademlia.read().len();
1990
1991            // Non-empty proof bytes on a mem-transport receiver — EmptyMarkerOwnershipProof::prove
1992            // returns false, so the handler should bail. This is the cross-transport-mismatch case
1993            // (an HTTPS-shaped postcard blob arriving at a mem-transport server).
1994            let request_bytes = json::struct_to_bytes(&AnnounceV2 { peer_self: announcing_peer, proof_payload: vec![1, 2, 3, 4] }).expect("encode V2");
1995
1996            let result = server
1997                .dispatch_network_payload_x_AnnounceV2(CancellationToken::new(), PayloadRequestKind::AnnounceV2, request_bytes)
1998                .await;
1999
2000            assert!(result.is_err(), "failed proof must bail so the dispatcher wraps it as ErrorResponseV1");
2001            assert_eq!(server.kademlia.read().len(), baseline_len, "no Kademlia mutation on failed proof");
2002
2003            // The peer is not findable by id.
2004            let (peers, _) = server.kademlia.read().get_peers_for_key(&announcing_peer_id, 8);
2005            assert!(!peers.iter().any(|p| p.id == announcing_peer_id), "rejected peer must not be reachable through get_peers_for_key");
2006        }
2007
2008        // AnnounceV1 had no transport ownership proof, so it was a way into Kademlia that bypassed V2's gate.
2009        // Its wire slot is kept reserved but the dispatcher must reject it outright.
2010        #[tokio::test]
2011        async fn retired_announce_v1_is_rejected() -> anyhow::Result<()> {
2012            use hashiverse_lib::protocol::rpc::rpc_request::{RpcRequestPacketTx, RpcRequestPacketTxFlags};
2013
2014            let server = make_test_server().await;
2015            let time_provider = server.runtime_services.time_provider.clone();
2016            let pow_generator = SingleThreadedPowGenerator::new();
2017            let announcing_peer = make_announcing_peer(time_provider.as_ref(), &pow_generator, "7777").await;
2018            let announcing_peer_id = announcing_peer.id;
2019            let baseline_len = server.kademlia.read().len();
2020
2021            // The old V1 body was just the peer record — send exactly that under the retired kind.
2022            #[derive(serde::Serialize)]
2023            struct LegacyAnnounceV1 {
2024                peer_self: Peer,
2025            }
2026            let legacy_payload = json::struct_to_bytes(&LegacyAnnounceV1 { peer_self: announcing_peer })?;
2027
2028            let rpc_request_packet_tx = RpcRequestPacketTx::encode(
2029                time_provider.as_ref(),
2030                config::POW_MINIMUM_PER_RPC_SERVER_KNOWN,
2031                RpcRequestPacketTxFlags::SERVER_KNOWN,
2032                PayloadRequestKind::RetiredAnnounceV1,
2033                &server.server_id.id,
2034                &server.server_id.keys.verification_key_bytes,
2035                &server.server_id.keys.pq_commitment_bytes,
2036                legacy_payload,
2037                &pow_generator,
2038            )
2039            .await?;
2040            let rpc_request_packet_rx = RpcRequestPacketRx::decode(&time_provider.current_time_millis(), &server.server_id.keys.verification_key_bytes, &server.server_id.keys.pq_commitment_bytes, rpc_request_packet_tx.bytes)?;
2041            assert_eq!(rpc_request_packet_rx.payload_request_kind, PayloadRequestKind::RetiredAnnounceV1);
2042
2043            let error = server.dispatch_network_envelope(CancellationToken::new(), None, rpc_request_packet_rx).await.err().expect("retired AnnounceV1 must be rejected");
2044            assert!(error.to_string().contains("retired"), "error={}", error);
2045
2046            assert_eq!(server.kademlia.read().len(), baseline_len, "no Kademlia mutation from a retired announce");
2047            let (peers, _) = server.kademlia.read().get_peers_for_key(&announcing_peer_id, 8);
2048            assert!(!peers.iter().any(|p| p.id == announcing_peer_id));
2049            Ok(())
2050        }
2051    }
2052
2053    mod heal_donor_plausibility {
2054        use super::*;
2055
2056        /// An id that agrees with `location_id` in exactly `leading_agreement_bits` leading bits.
2057        fn id_with_leading_agreement_bits(location_id: &Id, leading_agreement_bits: usize) -> Id {
2058            let mut bytes = location_id.as_ref().to_vec();
2059            bytes[leading_agreement_bits / 8] ^= 0x80 >> (leading_agreement_bits % 8);
2060            Id::from_slice(&bytes).expect("32 bytes")
2061        }
2062
2063        #[test]
2064        fn id_helper_produces_requested_agreement() {
2065            let location_id = Id::random();
2066            for leading_agreement_bits in [0usize, 1, 7, 8, 13, 40] {
2067                let id = id_with_leading_agreement_bits(&location_id, leading_agreement_bits);
2068                assert_eq!(tools::leading_agreement_bits_xor(location_id.as_ref(), id.as_ref()), leading_agreement_bits as LeadingAgreementBits);
2069            }
2070        }
2071
2072        #[test]
2073        fn accepts_anyone_when_we_know_too_few_servers() {
2074            let location_id = Id::random();
2075            let nearest_peer_ids = vec![id_with_leading_agreement_bits(&location_id, 20), id_with_leading_agreement_bits(&location_id, 21)];
2076            let far_donor_id = id_with_leading_agreement_bits(&location_id, 0);
2077            assert!(is_plausible_heal_donor(&far_donor_id, &location_id, &nearest_peer_ids, 3));
2078        }
2079
2080        #[test]
2081        fn accepts_donors_within_slack_of_furthest_nearest_and_refuses_beyond() {
2082            let location_id = Id::random();
2083            // Our view: the 3 nearest live servers agree with the location in 12, 14 and 10 bits; the furthest of them is 10
2084            let nearest_peer_ids = vec![
2085                id_with_leading_agreement_bits(&location_id, 12),
2086                id_with_leading_agreement_bits(&location_id, 14),
2087                id_with_leading_agreement_bits(&location_id, 10),
2088            ];
2089            let slack = config::HEAL_DONOR_LEADING_AGREEMENT_BITS_SLACK as usize;
2090
2091            // A current stand-in, or a dead original that was nearer than anyone we know now
2092            assert!(is_plausible_heal_donor(&id_with_leading_agreement_bits(&location_id, 10), &location_id, &nearest_peer_ids, 3));
2093            assert!(is_plausible_heal_donor(&id_with_leading_agreement_bits(&location_id, 30), &location_id, &nearest_peer_ids, 3));
2094
2095            // A dead original that has since been slightly out-ranked by newer, closer servers
2096            assert!(is_plausible_heal_donor(&id_with_leading_agreement_bits(&location_id, 10 - slack), &location_id, &nearest_peer_ids, 3));
2097
2098            // Too far away to ever have been responsible
2099            assert!(!is_plausible_heal_donor(&id_with_leading_agreement_bits(&location_id, 10 - slack - 1), &location_id, &nearest_peer_ids, 3));
2100            assert!(!is_plausible_heal_donor(&id_with_leading_agreement_bits(&location_id, 0), &location_id, &nearest_peer_ids, 3));
2101        }
2102    }
2103
2104    mod submit_post_claim {
2105        use super::*;
2106        use crate::environment::environment::EnvironmentFactory;
2107        use crate::environment::mem_environment_store::MemEnvironmentFactory;
2108        use crate::server::args::Args;
2109        use hashiverse_lib::client::key_locker::key_locker::{KeyLocker, KeyLockerManager};
2110        use hashiverse_lib::client::key_locker::mem_key_locker::MemKeyLockerManager;
2111        use hashiverse_lib::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
2112        use hashiverse_lib::tools::runtime_services::RuntimeServices;
2113        use hashiverse_lib::tools::time::MILLIS_IN_MINUTE;
2114        use hashiverse_lib::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
2115        use hashiverse_lib::transport::mem_transport::MemTransportFactory;
2116        use std::sync::Arc;
2117
2118        // Regression: a SubmitPostClaimV1 into a 1-minute bucket used to hit an assert! in get_minimum_post_pow,
2119        // unwinding through the dispatch loop and stopping the whole server.
2120        // With zero PoW it must now get past the minimum-PoW computation and be rejected as under-powered.
2121        #[tokio::test]
2122        async fn one_minute_bucket_is_rejected_for_insufficient_pow_not_panic() -> anyhow::Result<()> {
2123            let runtime_services = Arc::new(RuntimeServices { time_provider: Arc::new(RealTimeProvider), transport_factory: MemTransportFactory::default(), pow_generator: Arc::new(SingleThreadedPowGenerator::new()) });
2124            let server = HashiverseServer::new(runtime_services, Arc::new(MemEnvironmentFactory::new("")), Args::default_for_testing()).await?;
2125
2126            let key_locker_manager = MemKeyLockerManager::new().await?;
2127            let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("one minute keyphrase".to_string()).await?;
2128            let time_millis_now = RealTimeProvider.current_time_millis();
2129            let bucket_location = BucketLocation::new(BucketType::User, key_locker.client_id().id, MILLIS_IN_MINUTE, time_millis_now)?;
2130
2131            let mut encoded_post = EncodedPostV1::new(key_locker.client_id(), time_millis_now, vec![bucket_location.base_id], "one minute post");
2132            let encoded_post_bytes = encoded_post.encode_to_bytes_direct(&key_locker).await?;
2133            let request_bytes = SubmitPostClaimV1::new_to_bytes(&bucket_location, None, &[], encoded_post_bytes.bytes_without_body())?;
2134
2135            let result = catch_panic_as_error(server.dispatch_network_payload_x_SubmitPostClaimV1(CancellationToken::new(), Some(PeerPow::zero()), PayloadRequestKind::SubmitPostClaimV1, request_bytes)).await?;
2136            let error_message = result.err().expect("zero PoW must be rejected").to_string();
2137            assert!(error_message.contains("Insufficient proof of work"), "error_message={}", error_message);
2138            Ok(())
2139        }
2140    }
2141
2142    mod heal_post_bundle {
2143        use super::*;
2144        use crate::environment::environment::EnvironmentFactory;
2145        use crate::environment::mem_environment_store::MemEnvironmentFactory;
2146        use crate::server::args::Args;
2147        use crate::server::hashiverse_server::HashiverseServer;
2148        use hashiverse_lib::client::key_locker::key_locker::{KeyLocker, KeyLockerManager};
2149        use hashiverse_lib::client::key_locker::mem_key_locker::MemKeyLockerManager;
2150        use hashiverse_lib::protocol::peer::Peer;
2151        use hashiverse_lib::protocol::posting::encoded_post_feedback::{self, EncodedPostFeedbackV1};
2152        use hashiverse_lib::tools::pow;
2153        use hashiverse_lib::tools::types::Salt;
2154        use hashiverse_lib::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
2155        use hashiverse_lib::tools::runtime_services::RuntimeServices;
2156        use hashiverse_lib::tools::server_id::ServerId;
2157        use hashiverse_lib::tools::time::MILLIS_IN_MONTH;
2158        use hashiverse_lib::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
2159        use hashiverse_lib::tools::types::Pow;
2160        use hashiverse_lib::transport::mem_transport::MemTransportFactory;
2161        use std::sync::Arc;
2162
2163        pub(super) struct HealFixture {
2164            pub(super) server: Arc<HashiverseServer>,
2165            pub(super) key_locker: Arc<dyn KeyLocker>,
2166            pub(super) bucket_location: BucketLocation,
2167            pub(super) time_millis_now: TimeMillis,
2168        }
2169
2170        impl HealFixture {
2171            pub(super) async fn new() -> anyhow::Result<Self> {
2172                let time_provider = Arc::new(RealTimeProvider);
2173                let transport_factory = MemTransportFactory::default();
2174                let pow_generator = Arc::new(SingleThreadedPowGenerator::new());
2175                let runtime_services = Arc::new(RuntimeServices { time_provider, transport_factory, pow_generator });
2176                let environment_factory = Arc::new(MemEnvironmentFactory::new(""));
2177                let server = HashiverseServer::new(runtime_services, environment_factory, Args::default_for_testing()).await?;
2178
2179                let key_locker_manager = MemKeyLockerManager::new().await?;
2180                let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("heal keyphrase".to_string()).await?;
2181                let time_millis_now = RealTimeProvider.current_time_millis();
2182                let bucket_location = BucketLocation::new(BucketType::User, key_locker.client_id().id, MILLIS_IN_MONTH, time_millis_now)?;
2183                Ok(Self { server, key_locker, bucket_location, time_millis_now })
2184            }
2185
2186            pub(super) async fn make_server_id(pow_min: Pow) -> anyhow::Result<ServerId> {
2187                ServerId::new("heal_donor", &RealTimeProvider, pow_min, true, &SingleThreadedPowGenerator::new()).await
2188            }
2189
2190            async fn make_post_by(key_locker: &Arc<dyn KeyLocker>, timestamp: TimeMillis, linked_base_ids: Vec<Id>) -> anyhow::Result<(Id, Bytes)> {
2191                let mut encoded_post = EncodedPostV1::new(key_locker.client_id(), timestamp, linked_base_ids, "healed post");
2192                let encoded_post_bytes = encoded_post.encode_to_bytes_direct(key_locker).await?;
2193                Ok((encoded_post.post_id, Bytes::copy_from_slice(encoded_post_bytes.bytes())))
2194            }
2195
2196            pub(super) async fn make_own_post(&self) -> anyhow::Result<(Id, Bytes)> {
2197                Self::make_post_by(&self.key_locker, self.time_millis_now, vec![self.bucket_location.base_id]).await
2198            }
2199
2200            pub(super) fn make_donor_header(&self, donor_server_id: &ServerId, posts: &[(Id, Bytes)], customise: impl FnOnce(&mut EncodedPostBundleHeaderV1)) -> anyhow::Result<EncodedPostBundleHeaderV1> {
2201                let mut donor_header = EncodedPostBundleHeaderV1 {
2202                    time_millis: self.time_millis_now,
2203                    location_id: self.bucket_location.location_id,
2204                    overflowed: false,
2205                    sealed: false,
2206                    num_posts: posts.len() as u8,
2207                    encoded_post_ids: posts.iter().map(|(post_id, _)| *post_id).collect(),
2208                    encoded_post_lengths: posts.iter().map(|(_, post_bytes)| post_bytes.len()).collect(),
2209                    encoded_post_healed: HashSet::new(),
2210                    peer: donor_server_id.to_peer(&RealTimeProvider)?,
2211                    signature: Signature::zero(),
2212                };
2213                customise(&mut donor_header);
2214                donor_header.signature_generate(&donor_server_id.keys.signature_key)?;
2215                Ok(donor_header)
2216            }
2217
2218            async fn claim(&self, donor_header: &EncodedPostBundleHeaderV1) -> anyhow::Result<HealPostBundleClaimResponseV1> {
2219                let request_bytes = HealPostBundleClaimV1::new_to_bytes(donor_header, &self.bucket_location)?;
2220                let (_, bytes_gatherer) = self.server.dispatch_network_payload_x_HealPostBundleClaimV1(CancellationToken::new(), PayloadRequestKind::HealPostBundleClaimV1, request_bytes).await?;
2221                json::bytes_to_struct::<HealPostBundleClaimResponseV1>(&bytes_gatherer.to_bytes())
2222            }
2223
2224            /// Claims then commits; returns whether the commit was accepted.
2225            async fn heal(&self, donor_header: &EncodedPostBundleHeaderV1, posts: &[(Id, Bytes)]) -> anyhow::Result<bool> {
2226                let claim_response = self.claim(donor_header).await?;
2227                let token = claim_response.token.ok_or_else(|| anyhow::anyhow!("no claim token"))?;
2228                let encoded_posts_bytes = Self::encoded_posts_bytes_for_token(&token, posts)?;
2229                self.commit(&token, donor_header, &encoded_posts_bytes).await
2230            }
2231
2232            /// The concatenated post bytes a commit must carry: one entry per `token.needed_post_ids`, in order.
2233            fn encoded_posts_bytes_for_token(token: &HealPostBundleClaimTokenV1, posts: &[(Id, Bytes)]) -> anyhow::Result<Vec<u8>> {
2234                let mut encoded_posts_bytes = Vec::new();
2235                for needed_post_id in &token.needed_post_ids {
2236                    let (_, post_bytes) = posts.iter().find(|(post_id, _)| post_id == needed_post_id).ok_or_else(|| anyhow::anyhow!("missing post"))?;
2237                    encoded_posts_bytes.extend_from_slice(post_bytes);
2238                }
2239                Ok(encoded_posts_bytes)
2240            }
2241
2242            async fn commit(&self, token: &HealPostBundleClaimTokenV1, donor_header: &EncodedPostBundleHeaderV1, encoded_posts_bytes: &[u8]) -> anyhow::Result<bool> {
2243                let request_bytes = HealPostBundleCommitV1::new_to_bytes(token, donor_header, encoded_posts_bytes)?;
2244                let (_, bytes_gatherer) = self.server.dispatch_network_payload_x_HealPostBundleCommitV1(CancellationToken::new(), PayloadRequestKind::HealPostBundleCommitV1, request_bytes).await?;
2245                Ok(json::bytes_to_struct::<HealPostBundleCommitResponseV1>(&bytes_gatherer.to_bytes())?.accepted)
2246            }
2247
2248            fn stored_bundle(&self) -> anyhow::Result<EncodedPostBundleV1> {
2249                let post_bundle_bytes = self.server.environment.get_post_bundle_bytes(self.time_millis_now, &self.bucket_location.location_id)?.ok_or_else(|| anyhow::anyhow!("no stored bundle"))?;
2250                EncodedPostBundleV1::from_bytes(post_bundle_bytes, true)
2251            }
2252
2253            async fn make_server_id_at_distance(&self, predicate: impl Fn(LeadingAgreementBits) -> bool) -> anyhow::Result<ServerId> {
2254                loop {
2255                    let server_id = Self::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2256                    if predicate(tools::leading_agreement_bits_xor(self.bucket_location.location_id.as_ref(), server_id.id.as_ref())) {
2257                        return Ok(server_id);
2258                    }
2259                }
2260            }
2261
2262            /// Fills our kademlia with genuine servers that agree with the location in at least `minimum_leading_agreement_bits` bits.
2263            async fn add_near_servers_to_kademlia(&self, count: usize, minimum_leading_agreement_bits: LeadingAgreementBits) -> anyhow::Result<()> {
2264                for _ in 0..count {
2265                    let server_id = self.make_server_id_at_distance(|leading_agreement_bits| leading_agreement_bits >= minimum_leading_agreement_bits).await?;
2266                    let peer: Peer = server_id.to_peer(&RealTimeProvider)?;
2267                    self.server.kademlia.write().add_peer(peer, self.time_millis_now)?;
2268                }
2269                Ok(())
2270            }
2271        }
2272
2273        async fn heal_feedbacks(fixture: &HealFixture, encoded_post_feedbacks: &[EncodedPostFeedbackV1]) -> anyhow::Result<u32> {
2274            let request_bytes = HealPostBundleFeedbackV1::new_to_bytes(&fixture.bucket_location.location_id, encoded_post_feedbacks)?;
2275            let (_, bytes_gatherer) = fixture.server.dispatch_network_payload_x_HealPostBundleFeedbackV1(CancellationToken::new(), PayloadRequestKind::HealPostBundleFeedbackV1, request_bytes).await?;
2276            Ok(json::bytes_to_struct::<HealPostBundleFeedbackResponseV1>(&bytes_gatherer.to_bytes())?.accepted_count)
2277        }
2278
2279        fn stored_feedbacks(fixture: &HealFixture) -> anyhow::Result<Vec<EncodedPostFeedbackV1>> {
2280            let feedbacks_bytes = fixture.server.environment.get_post_bundle_encoded_post_feedbacks_bytes(fixture.time_millis_now, &fixture.bucket_location.location_id)?;
2281            feedbacks_bytes.chunks_exact(encoded_post_feedback::ENTRY_SIZE).map(EncodedPostFeedbackV1::decode_from_bytes).collect()
2282        }
2283
2284        fn make_valid_feedback(post_id: Id, feedback_type: u8) -> anyhow::Result<EncodedPostFeedbackV1> {
2285            let salt = Salt::random();
2286            let (measured_pow, _) = pow::pow_measure(&[post_id.as_bytes(), &[feedback_type]], &salt)?;
2287            Ok(EncodedPostFeedbackV1::new(post_id, feedback_type, salt, measured_pow))
2288        }
2289
2290        /// Heals one post into the server so it holds a bundle, and returns that post's id.
2291        async fn fixture_with_stored_post() -> anyhow::Result<(HealFixture, Id)> {
2292            let fixture = HealFixture::new().await?;
2293            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2294            let posts = vec![fixture.make_own_post().await?];
2295            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |_| {})?;
2296            assert!(fixture.heal(&donor_header, &posts).await?);
2297            let post_id = posts[0].0;
2298            Ok((fixture, post_id))
2299        }
2300
2301        // Regression for C3: a healed feedback claiming pow=255 with a salt that doesn't achieve it must not be stored.
2302        #[tokio::test]
2303        async fn heal_feedback_rejects_forged_pow() -> anyhow::Result<()> {
2304            let (fixture, post_id) = fixture_with_stored_post().await?;
2305            let forged_feedback = EncodedPostFeedbackV1::new(post_id, 3, Salt::random(), Pow(255));
2306            assert!(forged_feedback.pow_verify().is_err());
2307
2308            assert_eq!(heal_feedbacks(&fixture, &[forged_feedback]).await?, 0);
2309            assert!(stored_feedbacks(&fixture)?.is_empty(), "forged feedback must not be stored");
2310            Ok(())
2311        }
2312
2313        #[tokio::test]
2314        async fn heal_feedback_accepts_valid_pow_and_skips_forged_in_same_request() -> anyhow::Result<()> {
2315            let (fixture, post_id) = fixture_with_stored_post().await?;
2316            let valid_feedback = make_valid_feedback(post_id, 1)?;
2317            let forged_feedback = EncodedPostFeedbackV1::new(post_id, 2, Salt::random(), Pow(255));
2318
2319            assert_eq!(heal_feedbacks(&fixture, &[forged_feedback, valid_feedback.clone()]).await?, 1);
2320            assert_eq!(stored_feedbacks(&fixture)?, vec![valid_feedback]);
2321            Ok(())
2322        }
2323
2324        // A commit that fails after its token has been verified as ours must release the claim straight away,
2325        // rather than blocking every other client from healing this location until heal_in_progress's TTL expires.
2326        #[tokio::test]
2327        async fn failed_commit_releases_heal_claim() -> anyhow::Result<()> {
2328            let fixture = HealFixture::new().await?;
2329            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2330            let posts = vec![fixture.make_own_post().await?];
2331            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |_| {})?;
2332
2333            let token = fixture.claim(&donor_header).await?.token.expect("first claim must get a token");
2334            assert!(fixture.claim(&donor_header).await?.token.is_none(), "claim must be held while a heal is in progress");
2335
2336            let mut encoded_posts_bytes = HealFixture::encoded_posts_bytes_for_token(&token, &posts)?;
2337            encoded_posts_bytes.push(0u8);
2338            let commit_error = fixture.commit(&token, &donor_header, &encoded_posts_bytes).await.expect_err("commit with excess bytes must fail");
2339            assert!(commit_error.to_string().contains("excess bytes"), "commit_error={}", commit_error);
2340
2341            assert!(fixture.claim(&donor_header).await?.token.is_some(), "a failed commit must release the claim");
2342            Ok(())
2343        }
2344
2345        // A commit carrying a token we didn't issue must not be able to release a claim held by someone else.
2346        #[tokio::test]
2347        async fn commit_with_foreign_token_does_not_release_heal_claim() -> anyhow::Result<()> {
2348            let fixture = HealFixture::new().await?;
2349            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2350            let posts = vec![fixture.make_own_post().await?];
2351            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |_| {})?;
2352
2353            let genuine_token = fixture.claim(&donor_header).await?.token.expect("first claim must get a token");
2354
2355            let foreign_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2356            let foreign_token = HealPostBundleClaimTokenV1::new(foreign_server_id.to_peer(&RealTimeProvider)?, fixture.bucket_location.clone(), genuine_token.needed_post_ids.clone(), donor_header.signature, &foreign_server_id.keys.signature_key);
2357            let encoded_posts_bytes = HealFixture::encoded_posts_bytes_for_token(&foreign_token, &posts)?;
2358            let commit_error = fixture.commit(&foreign_token, &donor_header, &encoded_posts_bytes).await.expect_err("commit with a foreign token must fail");
2359            assert!(commit_error.to_string().contains("not issued by this server"), "commit_error={}", commit_error);
2360
2361            assert!(fixture.claim(&donor_header).await?.token.is_none(), "a foreign token must not release the claim");
2362            Ok(())
2363        }
2364
2365        #[tokio::test]
2366        async fn honest_heal_succeeds_and_does_not_adopt_donor_flags() -> anyhow::Result<()> {
2367            let fixture = HealFixture::new().await?;
2368            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2369            let posts = vec![fixture.make_own_post().await?];
2370            // The donor claims sealed + overflowed for a bucket that is neither
2371            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |donor_header| {
2372                donor_header.sealed = true;
2373                donor_header.overflowed = true;
2374            })?;
2375
2376            assert!(fixture.heal(&donor_header, &posts).await?);
2377
2378            let stored_bundle = fixture.stored_bundle()?;
2379            assert_eq!(stored_bundle.header.encoded_post_ids, vec![posts[0].0]);
2380            assert!(stored_bundle.header.encoded_post_healed.contains(&posts[0].0));
2381            assert!(!stored_bundle.header.sealed, "donor's sealed flag must not be adopted");
2382            assert!(!stored_bundle.header.overflowed, "donor's overflowed flag must not be adopted");
2383            stored_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now)?;
2384            Ok(())
2385        }
2386
2387        #[tokio::test]
2388        async fn stand_in_donor_whose_posts_are_all_healed_is_accepted() -> anyhow::Result<()> {
2389            let fixture = HealFixture::new().await?;
2390            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2391            let posts = vec![fixture.make_own_post().await?, fixture.make_own_post().await?];
2392            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |donor_header| {
2393                donor_header.encoded_post_healed = donor_header.encoded_post_ids.iter().copied().collect();
2394            })?;
2395
2396            assert!(fixture.heal(&donor_header, &posts).await?);
2397            assert_eq!(fixture.stored_bundle()?.header.num_posts, 2);
2398            Ok(())
2399        }
2400
2401        #[tokio::test]
2402        async fn donor_without_server_pow_is_refused() -> anyhow::Result<()> {
2403            let fixture = HealFixture::new().await?;
2404            let unworked_server_id = loop {
2405                let server_id = HealFixture::make_server_id(Pow(0)).await?;
2406                if server_id.pow < config::SERVER_KEY_POW_MIN {
2407                    break server_id;
2408                }
2409            };
2410            let posts = vec![fixture.make_own_post().await?];
2411            let donor_header = fixture.make_donor_header(&unworked_server_id, &posts, |_| {})?;
2412            donor_header.verify()?; // The old, self-keyed check was happy with this
2413
2414            assert!(fixture.claim(&donor_header).await.is_err());
2415            assert!(fixture.stored_bundle().is_err(), "nothing must have been stored");
2416            Ok(())
2417        }
2418
2419        #[tokio::test]
2420        async fn donor_with_future_timestamp_is_refused() -> anyhow::Result<()> {
2421            let fixture = HealFixture::new().await?;
2422            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2423            let posts = vec![fixture.make_own_post().await?];
2424            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |donor_header| {
2425                donor_header.time_millis = donor_header.time_millis + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD + MILLIS_IN_SECOND;
2426            })?;
2427
2428            assert!(fixture.claim(&donor_header).await.is_err());
2429            Ok(())
2430        }
2431
2432        #[tokio::test]
2433        async fn future_post_is_judged_against_our_clock_not_a_future_donor_clock() -> anyhow::Result<()> {
2434            let fixture = HealFixture::new().await?;
2435            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2436
2437            // The donor header is just inside the future-time allowance, and the post is within the allowance of the donor's time
2438            // but beyond the allowance of ours
2439            let donor_time_millis = fixture.time_millis_now + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD - MILLIS_IN_SECOND;
2440            let future_post_time_millis = fixture.time_millis_now + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD + MILLIS_IN_SECOND.const_mul(60);
2441            if BucketLocation::round_down_to_bucket_start(future_post_time_millis, fixture.bucket_location.duration) != fixture.bucket_location.bucket_time_millis {
2442                return Ok(()); // Too close to the end of the month for this test to be meaningful
2443            }
2444            let future_post = HealFixture::make_post_by(&fixture.key_locker, future_post_time_millis, vec![fixture.bucket_location.base_id]).await?;
2445            let posts = vec![future_post];
2446            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |donor_header| {
2447                donor_header.time_millis = donor_time_millis;
2448            })?;
2449
2450            assert!(fixture.heal(&donor_header, &posts).await.is_err());
2451            assert!(fixture.stored_bundle().is_err(), "nothing must have been stored");
2452            Ok(())
2453        }
2454
2455        #[tokio::test]
2456        async fn far_donor_is_refused_but_dead_near_donor_is_accepted() -> anyhow::Result<()> {
2457            let fixture = HealFixture::new().await?;
2458            let slack = config::HEAL_DONOR_LEADING_AGREEMENT_BITS_SLACK;
2459            let near_leading_agreement_bits = slack + 2;
2460
2461            // Our view of the location's nearest servers is populated with servers at least this close
2462            fixture.add_near_servers_to_kademlia(config::REDUNDANT_SERVERS_PER_POST, near_leading_agreement_bits).await?;
2463            let posts = vec![fixture.make_own_post().await?];
2464
2465            // A donor that could never have been responsible for this location
2466            let far_donor_server_id = fixture.make_server_id_at_distance(|leading_agreement_bits| leading_agreement_bits < near_leading_agreement_bits - slack).await?;
2467            let far_donor_header = fixture.make_donor_header(&far_donor_server_id, &posts, |_| {})?;
2468            assert!(fixture.claim(&far_donor_header).await.is_err());
2469
2470            // A donor at least as close as our nearest servers, which is not in our kademlia at all (e.g. it has since died)
2471            let dead_near_donor_server_id = fixture.make_server_id_at_distance(|leading_agreement_bits| leading_agreement_bits >= near_leading_agreement_bits).await?;
2472            assert!(!fixture.server.kademlia.read().get_all_peers().iter().any(|peer| peer.id == dead_near_donor_server_id.id));
2473            let dead_near_donor_header = fixture.make_donor_header(&dead_near_donor_server_id, &posts, |_| {})?;
2474            // Passes the donor checks (whether we then go on to heal depends on whether we are among the nearest ourselves)
2475            fixture.claim(&dead_near_donor_header).await?;
2476            Ok(())
2477        }
2478
2479        #[tokio::test]
2480        async fn out_of_bucket_post_is_refused() -> anyhow::Result<()> {
2481            let fixture = HealFixture::new().await?;
2482            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2483            // A genuine post by the same user, but from the previous bucket
2484            let old_post = HealFixture::make_post_by(&fixture.key_locker, fixture.bucket_location.bucket_time_millis - MILLIS_IN_SECOND, vec![fixture.bucket_location.base_id]).await?;
2485            let posts = vec![old_post];
2486            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |_| {})?;
2487
2488            assert!(fixture.heal(&donor_header, &posts).await.is_err());
2489            assert!(fixture.stored_bundle().is_err(), "nothing must have been stored");
2490            Ok(())
2491        }
2492
2493        #[tokio::test]
2494        async fn foreign_author_in_user_bucket_is_refused() -> anyhow::Result<()> {
2495            let fixture = HealFixture::new().await?;
2496            let donor_server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2497
2498            // An attacker links the victim's id so that their post decrypts under the victim's User bucket
2499            let attacker_key_locker_manager = MemKeyLockerManager::new().await?;
2500            let attacker_key_locker: Arc<dyn KeyLocker> = attacker_key_locker_manager.create("attacker keyphrase".to_string()).await?;
2501            let foreign_post = HealFixture::make_post_by(&attacker_key_locker, fixture.time_millis_now, vec![fixture.bucket_location.base_id]).await?;
2502            let posts = vec![foreign_post];
2503            let donor_header = fixture.make_donor_header(&donor_server_id, &posts, |_| {})?;
2504
2505            assert!(fixture.heal(&donor_header, &posts).await.is_err());
2506            assert!(fixture.stored_bundle().is_err(), "nothing must have been stored");
2507            Ok(())
2508        }
2509    }
2510
2511    // H4: a cache upload must not let free self-signed, empty, mis-located or self-"sealed" bundles squat a location's cache slots.
2512    mod cache_upload {
2513        use super::*;
2514        use super::heal_post_bundle::HealFixture;
2515        use crate::server::post_bundle_caching_shared::CACHE_HIT_THRESHOLD;
2516        use hashiverse_lib::protocol::posting::encoded_post_feedback::EncodedPostFeedbackV1;
2517        use hashiverse_lib::tools::pow;
2518        use hashiverse_lib::tools::server_id::ServerId;
2519        use hashiverse_lib::tools::time::MILLIS_IN_MINUTE;
2520        use hashiverse_lib::tools::time_provider::time_provider::RealTimeProvider;
2521        use hashiverse_lib::tools::types::{Pow, Salt};
2522
2523        async fn make_unworked_server_id() -> anyhow::Result<ServerId> {
2524            loop {
2525                let server_id = HealFixture::make_server_id(Pow(0)).await?;
2526                if server_id.pow < config::SERVER_KEY_POW_MIN {
2527                    return Ok(server_id);
2528                }
2529            }
2530        }
2531
2532        /// Signs a post bundle holding `posts`, as `server_id` would publish it.
2533        fn make_post_bundle_bytes(fixture: &HealFixture, server_id: &ServerId, posts: &[(Id, Bytes)], customise: impl FnOnce(&mut EncodedPostBundleHeaderV1)) -> anyhow::Result<Bytes> {
2534            let header = fixture.make_donor_header(server_id, posts, customise)?;
2535            let encoded_posts_bytes: Vec<u8> = posts.iter().flat_map(|(_, post_bytes)| post_bytes.iter().copied()).collect();
2536            EncodedPostBundleV1 { header, encoded_posts_bytes: Bytes::from(encoded_posts_bytes) }.to_bytes()
2537        }
2538
2539        /// Gets a genuine token by hitting the cache up to its threshold, then uploads `encoded_post_bundle_bytes` with it.
2540        async fn upload_post_bundle(fixture: &HealFixture, encoded_post_bundle_bytes: &Bytes) -> anyhow::Result<bool> {
2541            let peer_self = fixture.server.peer_self.read().clone();
2542            let mut cache_request_token = None;
2543            for _ in 0..CACHE_HIT_THRESHOLD {
2544                cache_request_token = cache_request_token.or(fixture.server.post_bundle_cache.on_get(&fixture.bucket_location, &[], &peer_self, &fixture.server.server_id, fixture.time_millis_now).cache_request_token);
2545            }
2546            let cache_request_token = cache_request_token.ok_or_else(|| anyhow::anyhow!("no cache request token issued"))?;
2547            let request_bytes = CachePostBundleV1::new_to_bytes(&cache_request_token, &[encoded_post_bundle_bytes.as_ref()])?;
2548            let (_, bytes_gatherer) = fixture.server.dispatch_network_payload_x_CachePostBundleV1(CancellationToken::new(), PayloadRequestKind::CachePostBundleV1, request_bytes).await?;
2549            Ok(json::bytes_to_struct::<CachePostBundleResponseV1>(&bytes_gatherer.to_bytes())?.accepted)
2550        }
2551
2552        fn cached_post_bundle_count_at(fixture: &HealFixture, time_millis: TimeMillis) -> usize {
2553            let peer_self = fixture.server.peer_self.read().clone();
2554            fixture.server.post_bundle_cache.on_get(&fixture.bucket_location, &[], &peer_self, &fixture.server.server_id, time_millis).cached_items.len()
2555        }
2556
2557        #[tokio::test]
2558        async fn genuine_bundle_is_cached() -> anyhow::Result<()> {
2559            let fixture = HealFixture::new().await?;
2560            let server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2561            let posts = vec![fixture.make_own_post().await?];
2562            assert!(upload_post_bundle(&fixture, &make_post_bundle_bytes(&fixture, &server_id, &posts, |_| {})?).await?);
2563            assert_eq!(cached_post_bundle_count_at(&fixture, fixture.time_millis_now), 1);
2564            Ok(())
2565        }
2566
2567        #[tokio::test]
2568        async fn empty_bundle_is_rejected() -> anyhow::Result<()> {
2569            let fixture = HealFixture::new().await?;
2570            let server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2571            assert!(!upload_post_bundle(&fixture, &make_post_bundle_bytes(&fixture, &server_id, &[], |_| {})?).await?);
2572            assert_eq!(cached_post_bundle_count_at(&fixture, fixture.time_millis_now), 0);
2573            Ok(())
2574        }
2575
2576        #[tokio::test]
2577        async fn unworked_publisher_is_rejected() -> anyhow::Result<()> {
2578            let fixture = HealFixture::new().await?;
2579            let unworked_server_id = make_unworked_server_id().await?;
2580            let posts = vec![fixture.make_own_post().await?];
2581            assert!(!upload_post_bundle(&fixture, &make_post_bundle_bytes(&fixture, &unworked_server_id, &posts, |_| {})?).await?);
2582            assert_eq!(cached_post_bundle_count_at(&fixture, fixture.time_millis_now), 0);
2583            Ok(())
2584        }
2585
2586        #[tokio::test]
2587        async fn bundle_for_other_location_is_rejected() -> anyhow::Result<()> {
2588            let fixture = HealFixture::new().await?;
2589            let server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2590            let posts = vec![fixture.make_own_post().await?];
2591            let encoded_post_bundle_bytes = make_post_bundle_bytes(&fixture, &server_id, &posts, |header| header.location_id = Id::random())?;
2592            assert!(!upload_post_bundle(&fixture, &encoded_post_bundle_bytes).await?);
2593            assert_eq!(cached_post_bundle_count_at(&fixture, fixture.time_millis_now), 0);
2594            Ok(())
2595        }
2596
2597        #[tokio::test]
2598        async fn self_asserted_sealed_is_not_pinned() -> anyhow::Result<()> {
2599            let fixture = HealFixture::new().await?;
2600            let server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2601            let posts = vec![fixture.make_own_post().await?];
2602            // The bucket is current and nowhere near overflowing, so it cannot really be sealed.
2603            let encoded_post_bundle_bytes = make_post_bundle_bytes(&fixture, &server_id, &posts, |header| header.sealed = true)?;
2604            assert!(upload_post_bundle(&fixture, &encoded_post_bundle_bytes).await?);
2605            assert_eq!(cached_post_bundle_count_at(&fixture, fixture.time_millis_now), 1);
2606            assert_eq!(cached_post_bundle_count_at(&fixture, fixture.time_millis_now + MILLIS_IN_MINUTE.const_mul(6)), 0, "a self-asserted seal must not pin the bundle in the cache");
2607            Ok(())
2608        }
2609
2610        fn make_feedback_bundle_bytes(server_id: &ServerId, location_id: Id, time_millis: TimeMillis) -> anyhow::Result<Bytes> {
2611            let post_id = Id::random();
2612            let salt = Salt::random();
2613            let (measured_pow, _) = pow::pow_measure(&[post_id.as_bytes(), &[1u8]], &salt)?;
2614            let mut feedbacks_bytes = Vec::new();
2615            EncodedPostFeedbackV1::new(post_id, 1, salt, measured_pow).append_encode_to_bytes(&mut feedbacks_bytes)?;
2616            let feedbacks_bytes = Bytes::from(feedbacks_bytes);
2617            let mut header = EncodedPostBundleFeedbackHeaderV1 {
2618                time_millis,
2619                location_id,
2620                feedbacks_bytes_hash: hashing::hash(feedbacks_bytes.as_ref()),
2621                peer: server_id.to_peer(&RealTimeProvider)?,
2622                signature: Signature::zero(),
2623            };
2624            header.signature_generate(&server_id.keys.signature_key);
2625            EncodedPostBundleFeedbackV1 { header, feedbacks_bytes }.to_bytes()
2626        }
2627
2628        async fn upload_post_bundle_feedback(fixture: &HealFixture, encoded_post_bundle_feedback_bytes: &Bytes) -> anyhow::Result<bool> {
2629            let peer_self = fixture.server.peer_self.read().clone();
2630            let mut cache_request_token = None;
2631            for _ in 0..CACHE_HIT_THRESHOLD {
2632                cache_request_token = cache_request_token.or(fixture.server.post_bundle_feedback_cache.on_get(&fixture.bucket_location, &[], &peer_self, &fixture.server.server_id, fixture.time_millis_now).cache_request_token);
2633            }
2634            let cache_request_token = cache_request_token.ok_or_else(|| anyhow::anyhow!("no cache request token issued"))?;
2635            let request_bytes = CachePostBundleFeedbackV1::new_to_bytes(&cache_request_token, encoded_post_bundle_feedback_bytes)?;
2636            let (_, bytes_gatherer) = fixture.server.dispatch_network_payload_x_CachePostBundleFeedbackV1(CancellationToken::new(), PayloadRequestKind::CachePostBundleFeedbackV1, request_bytes).await?;
2637            Ok(json::bytes_to_struct::<CachePostBundleFeedbackResponseV1>(&bytes_gatherer.to_bytes())?.accepted)
2638        }
2639
2640        #[tokio::test]
2641        async fn genuine_feedback_is_cached() -> anyhow::Result<()> {
2642            let fixture = HealFixture::new().await?;
2643            let server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2644            assert!(upload_post_bundle_feedback(&fixture, &make_feedback_bundle_bytes(&server_id, fixture.bucket_location.location_id, fixture.time_millis_now)?).await?);
2645            Ok(())
2646        }
2647
2648        #[tokio::test]
2649        async fn feedback_from_unworked_publisher_is_rejected() -> anyhow::Result<()> {
2650            let fixture = HealFixture::new().await?;
2651            let unworked_server_id = make_unworked_server_id().await?;
2652            assert!(!upload_post_bundle_feedback(&fixture, &make_feedback_bundle_bytes(&unworked_server_id, fixture.bucket_location.location_id, fixture.time_millis_now)?).await?);
2653            Ok(())
2654        }
2655
2656        #[tokio::test]
2657        async fn feedback_for_other_location_is_rejected() -> anyhow::Result<()> {
2658            let fixture = HealFixture::new().await?;
2659            let server_id = HealFixture::make_server_id(config::SERVER_KEY_POW_MIN).await?;
2660            assert!(!upload_post_bundle_feedback(&fixture, &make_feedback_bundle_bytes(&server_id, Id::random(), fixture.time_millis_now)?).await?);
2661            Ok(())
2662        }
2663    }
2664}