fips-core 0.3.79

Reusable FIPS mesh, endpoint, transport, and protocol library
Documentation
use serde_json::Value;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerRatingHealth {
    pub score: i64,
    pub sample_count: u64,
    pub reason: String,
}

pub fn compute_peer_rating(peer: &Value) -> Option<PeerRatingHealth> {
    let mut score = 50_i64;
    let mut signals = 0_usize;
    let mut misbehavior_signals = 0_usize;
    let mut reasons = Vec::new();

    if let Some(mmp) = peer.get("mmp").and_then(Value::as_object) {
        if let Some(loss) = json_f64_prefer(mmp, &["smoothed_loss", "loss_rate"]) {
            let loss = loss.clamp(0.0, 1.0);
            signals += 1;
            if loss <= 0.005 {
                score += 10;
            } else if loss <= 0.02 {
                score += 4;
            } else {
                score -= ((loss * 100.0).round() as i64).clamp(4, 45);
            }
            reasons.push(format!("loss={loss:.3}"));
        }

        if let Some(delivery) = average_delivery_ratio(mmp) {
            let delivery = delivery.clamp(0.0, 1.0);
            signals += 1;
            if delivery >= 0.995 {
                score += 15;
            } else if delivery >= 0.98 {
                score += 8;
            } else if delivery < 0.80 {
                score -= 35;
            } else if delivery < 0.90 {
                score -= 25;
            } else if delivery < 0.97 {
                score -= 12;
            }
            reasons.push(format!("delivery={delivery:.3}"));
        }

        if let Some(etx) = json_f64_prefer(mmp, &["smoothed_etx", "etx"]) {
            let etx = etx.max(1.0);
            signals += 1;
            if etx <= 1.05 {
                score += 12;
            } else if etx <= 1.20 {
                score += 6;
            } else if etx >= 3.0 {
                score -= 35;
            } else if etx >= 2.0 {
                score -= 25;
            } else if etx >= 1.5 {
                score -= 15;
            }
            reasons.push(format!("etx={etx:.2}"));
        }

        if let Some(srtt_ms) = json_f64_prefer(mmp, &["srtt_ms"]) {
            signals += 1;
            if srtt_ms <= 50.0 {
                score += 8;
            } else if srtt_ms <= 150.0 {
                score += 3;
            } else if srtt_ms >= 1_000.0 {
                score -= 25;
            } else if srtt_ms >= 300.0 {
                score -= 15;
            }
            reasons.push(format!("srtt_ms={srtt_ms:.0}"));
        }

        if let Some(goodput_bps) = json_f64_prefer(mmp, &["goodput_bps"]) {
            signals += 1;
            if goodput_bps >= 5_000_000.0 {
                score += 8;
            } else if goodput_bps >= 1_000_000.0 {
                score += 4;
            }
            reasons.push(format!("goodput_bps={goodput_bps:.0}"));
        }
    }

    let decrypt_failures = json_u64_field(peer, "consecutive_decrypt_failures").unwrap_or(0);
    if decrypt_failures > 0 {
        signals += 1;
        misbehavior_signals += 1;
        score -= i64::try_from(decrypt_failures.saturating_mul(20).min(70)).unwrap_or(70);
        reasons.push(format!("decrypt_failures={decrypt_failures}"));
    }

    let replay_suppressed = json_u64_field(peer, "replay_suppressed").unwrap_or(0);
    if replay_suppressed > 0 {
        signals += 1;
        misbehavior_signals += 1;
        score -= i64::try_from(replay_suppressed.saturating_mul(5).min(30)).unwrap_or(30);
        reasons.push(format!("replay_suppressed={replay_suppressed}"));
    }

    if signals == 0 {
        return None;
    }

    let score = if misbehavior_signals == 0 {
        score.clamp(50, 100)
    } else {
        score.clamp(0, 100)
    };
    let sample_count = peer_sample_count(peer).max(signals as u64);
    Some(PeerRatingHealth {
        score,
        sample_count,
        reason: reasons.join(" "),
    })
}

fn average_delivery_ratio(map: &serde_json::Map<String, Value>) -> Option<f64> {
    let forward = json_f64_prefer(map, &["delivery_ratio_forward"]);
    let reverse = json_f64_prefer(map, &["delivery_ratio_reverse"]);
    match (forward, reverse) {
        (Some(forward), Some(reverse)) => Some((forward + reverse) / 2.0),
        (Some(forward), None) => Some(forward),
        (None, Some(reverse)) => Some(reverse),
        (None, None) => None,
    }
}

fn peer_sample_count(peer: &Value) -> u64 {
    ["packets_sent", "packets_recv"]
        .into_iter()
        .filter_map(|key| nested_u64_field(peer, "stats", key))
        .fold(0_u64, u64::saturating_add)
}

fn json_u64_field(value: &Value, key: &str) -> Option<u64> {
    value.get(key).and_then(Value::as_u64)
}

fn nested_u64_field(value: &Value, object_key: &str, key: &str) -> Option<u64> {
    value
        .get(object_key)
        .and_then(|object| object.get(key))
        .and_then(Value::as_u64)
}

fn json_f64_prefer(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<f64> {
    keys.iter()
        .filter_map(|key| object.get(*key).and_then(Value::as_f64))
        .find(|value| value.is_finite())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn healthy_peer_rating_promotes() {
        let peer = json!({
            "npub": "npub1good",
            "stats": {"packets_sent": 100, "packets_recv": 120},
            "mmp": {
                "smoothed_loss": 0.001,
                "smoothed_etx": 1.01,
                "delivery_ratio_forward": 0.999,
                "delivery_ratio_reverse": 0.998,
                "srtt_ms": 20.0,
                "goodput_bps": 8_000_000.0
            },
            "replay_suppressed": 0,
            "consecutive_decrypt_failures": 0
        });

        let health = compute_peer_rating(&peer).expect("health rating");

        assert!(health.score > 50, "{health:?}");
        assert_eq!(health.sample_count, 220);
    }

    #[test]
    fn performance_only_weakness_does_not_downvote_peer() {
        let peer = json!({
            "npub": "npub1mobile",
            "stats": {"packets_sent": 100, "packets_recv": 120},
            "mmp": {
                "smoothed_loss": 0.20,
                "smoothed_etx": 3.2,
                "delivery_ratio_forward": 0.73,
                "delivery_ratio_reverse": 0.81,
                "srtt_ms": 1200.0,
                "goodput_bps": 50_000.0
            },
            "replay_suppressed": 0,
            "consecutive_decrypt_failures": 0
        });

        let health = compute_peer_rating(&peer).expect("health rating");

        assert_eq!(health.score, 50, "{health:?}");
    }

    #[test]
    fn detected_misbehavior_downranks_peer() {
        let peer = json!({
            "npub": "npub1bad",
            "stats": {"packets_sent": 100, "packets_recv": 120},
            "mmp": {
                "smoothed_loss": 0.25,
                "smoothed_etx": 3.2,
                "delivery_ratio_forward": 0.73,
                "delivery_ratio_reverse": 0.81,
                "srtt_ms": 1200.0,
                "goodput_bps": 50_000.0
            },
            "replay_suppressed": 2,
            "consecutive_decrypt_failures": 2
        });

        let health = compute_peer_rating(&peer).expect("health rating");

        assert!(health.score < 50, "{health:?}");
    }

    #[test]
    fn peer_without_health_signal_is_skipped() {
        let peer = json!({
            "npub": "npub1unknown",
            "stats": {"packets_sent": 0, "packets_recv": 0},
            "replay_suppressed": 0,
            "consecutive_decrypt_failures": 0
        });

        assert!(compute_peer_rating(&peer).is_none());
    }
}