nntp-proxy 0.5.0

High-performance NNTP proxy server with connection pooling and authentication
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! Article availability tracking across backends
//!
//! Uses bitsets to track which backends have (or don't have) a specific article.
//! This is a self-contained type used by both the cache layer (persistence) and
//! the retry loop (transient tracking).
//!
//! # NNTP Response Semantics (CRITICAL)
//!
//! **430 "No Such Article" is AUTHORITATIVE** - NNTP servers NEVER give false negatives.
//! If a server returns 430, the article is definitively not present on that server.
//!
//! **2xx success responses are UNRELIABLE** - servers CAN give false positives.
//! A server might return 220/222/223 but then provide corrupt or incomplete data.
//!
//! **Therefore: 430 (missing) ALWAYS takes precedence over "has" state.**
//!
//! **BACKEND LIMIT**: Maximum 8 backends supported due to u8 bitset optimization.
//! This limit is enforced at config validation time.

use crate::router::BackendCount;
use crate::types::BackendId;

/// Maximum number of backends supported by `ArticleAvailability` bitset
pub const MAX_BACKENDS: usize = 8;

/// Status of a backend for a specific article
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendStatus {
    /// Backend hasn't been checked yet
    Unknown,
    /// Backend was checked and returned 430 (doesn't have article)
    Missing,
    /// Backend was checked and has the article
    HasArticle,
}

/// Track which backends have (or don't have) a specific article
///
/// Uses two u8 bitsets to track availability across up to 8 backends:
/// - `checked`: Which backends we've queried (attempted to fetch from)
/// - `missing`: Which backends returned 430 (don't have this article)
///
/// # Example with 2 backends
/// - Initial state: `checked=00`, `missing=00` (haven't tried any backends yet)
/// - After backend 0 returns 430: `checked=01`, `missing=01` (backend 0 doesn't have it)
/// - After backend 1 returns 220: `checked=11`, `missing=01` (backend 1 has it)
/// - If both return 430: `checked=11`, `missing=11` (all backends exhausted)
///
/// # Usage Pattern
/// This type serves TWO critical purposes:
///
/// 1. **Cache persistence** - Track availability across requests (long-lived)
///    - Store in cache with article data
///    - Avoid querying backends known to be missing
///    - Updated after every successful/failed fetch
///
/// 2. **430 retry loop** - Track which backends tried during single request (transient)
///    - Create fresh instance for each ARTICLE request
///    - Mark backends as missing when they return 430
///    - Stop when all backends exhausted or one succeeds
///
/// # Thread Safety
/// Wrapped in `Arc<Mutex<>>` when stored in cache entries for concurrent updates.
/// The precheck pattern ensures serial updates to avoid races:
/// 1. Query all backends concurrently
/// 2. Wait for all to complete
/// 3. Update cache serially with all results
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArticleAvailability {
    /// Bitset of backends we've checked (tried to fetch from)
    checked: u8,
    /// Bitset of backends that DON'T have this article (returned 430)
    missing: u8, // u8 supports up to 8 backends (plenty for NNTP)
}

impl ArticleAvailability {
    /// Create empty availability - assume all backends have article until proven otherwise
    #[inline]
    #[must_use]
    pub const fn new() -> Self {
        Self {
            checked: 0,
            missing: 0,
        }
    }

    /// Record that a backend returned 430 (doesn't have the article)
    ///
    /// # NNTP Semantics
    /// **430 is AUTHORITATIVE** - this is a definitive "no". Once marked missing,
    /// the backend should not be retried for this article until cache TTL expires.
    /// See module-level docs for full explanation.
    ///
    /// # Panics (debug builds only)
    /// Panics if `backend_id` >= 8. Config validation enforces max 8 backends.
    #[inline]
    pub fn record_missing(&mut self, backend_id: BackendId) -> &mut Self {
        let mask = backend_id.availability_bit();
        self.checked |= mask; // Mark as checked
        self.missing |= mask; // Mark as missing
        self
    }

    /// Record that a backend returned a success response (2xx) for this article
    ///
    /// # NNTP Semantics Warning
    /// **2xx responses are UNRELIABLE** - servers can give false positives.
    /// This clears the missing bit for fresh `ArticleAvailability` instances,
    /// but when merged via `merge_from()`, existing 430s take precedence.
    /// See module-level docs for full explanation.
    ///
    /// # Panics (debug builds only)
    /// Panics if `backend_id` >= 8. Config validation enforces max 8 backends.
    #[inline]
    pub fn record_has(&mut self, backend_id: BackendId) -> &mut Self {
        let mask = backend_id.availability_bit();
        self.checked |= mask; // Mark as checked
        self.missing &= !mask; // Clear missing bit (has the article)
        self
    }

    /// Merge another availability's state into this one
    ///
    /// Used to sync local availability tracking back to cache.
    /// Takes the union of checked backends and missing backends.
    ///
    /// # NNTP Semantics (CRITICAL)
    ///
    /// **430 responses are AUTHORITATIVE** - NNTP servers NEVER give false negatives.
    /// If a server says "no such article" (430), the article is definitively not there.
    ///
    /// **2xx responses are UNRELIABLE** - servers CAN give false positives.
    /// A server might claim to have an article but return corrupt data, or the
    /// article might be incomplete/unavailable due to propagation delays.
    ///
    /// Therefore: `missing` state ALWAYS wins over `has` state.
    /// We trust 430s absolutely but treat successes with skepticism.
    #[inline]
    pub const fn merge_from(&mut self, other: &Self) {
        // Simple union: trust all 430s from both sources
        // 430 is authoritative, so missing bits should accumulate
        self.checked |= other.checked;
        self.missing |= other.missing;
    }

    /// Check if a backend is known to be missing (returned 430)
    ///
    /// # Panics (debug builds only)
    /// Panics if `backend_id` >= 8. Config validation enforces max 8 backends.
    #[inline]
    #[must_use]
    pub fn is_missing(&self, backend_id: BackendId) -> bool {
        self.missing & backend_id.availability_bit() != 0
    }

    /// Check if we should attempt to fetch from this backend
    ///
    /// Returns `true` if backend might have the article (not yet marked missing).
    ///
    /// # Panics (debug builds only)
    /// Panics if `backend_id` >= 8. Config validation enforces max 8 backends.
    #[inline]
    #[must_use]
    pub fn should_try(&self, backend_id: BackendId) -> bool {
        !self.is_missing(backend_id)
    }

    /// Get the raw missing bitset for debugging
    #[inline]
    #[must_use]
    pub const fn missing_bits(&self) -> u8 {
        self.missing
    }

    /// Get the raw checked bitset for debugging
    #[inline]
    #[must_use]
    pub const fn checked_bits(&self) -> u8 {
        self.checked
    }

    /// Check if all backends in the pool have been tried and returned 430
    ///
    /// Check if all backends have been tried and returned 430
    ///
    /// # Panics (debug builds only)
    /// Panics if `backend_count` > 8. Config validation enforces max 8 backends.
    #[inline]
    #[must_use]
    pub fn all_exhausted(&self, backend_count: BackendCount) -> bool {
        let count = backend_count.get();
        debug_assert!(
            count <= MAX_BACKENDS,
            "Backend count {count} exceeds MAX_BACKENDS ({MAX_BACKENDS})"
        );
        let expected_missing = match count {
            0 => 0,
            MAX_BACKENDS => u8::MAX,
            n => 1u8
                .checked_shl(n as u32)
                .map(|mask| mask - 1)
                .expect("backend count exceeds u8 availability bitset"),
        };
        self.missing & expected_missing == expected_missing
    }

    /// Get an iterator over backends that should still be tried
    ///
    /// Returns backend IDs that haven't been marked missing yet.
    pub fn available_backends(
        &self,
        backend_count: BackendCount,
    ) -> impl Iterator<Item = BackendId> + '_ {
        (0..backend_count.get().min(MAX_BACKENDS))
            .map(BackendId::from_index)
            .filter(move |&backend_id| self.should_try(backend_id))
    }

    /// Get the underlying bitset value (for debugging)
    #[inline]
    #[must_use]
    pub const fn as_u8(&self) -> u8 {
        self.missing
    }

    /// Reconstruct from raw bitset values (used for deserialization)
    ///
    /// # Safety
    /// The caller must ensure the bits represent valid backend states.
    /// This is primarily used when deserializing from disk cache.
    #[inline]
    #[must_use]
    pub const fn from_bits(checked: u8, missing: u8) -> Self {
        Self { checked, missing }
    }

    /// Check if we have any backend availability information
    ///
    /// Returns true if at least one backend has been checked.
    /// If this returns false, we haven't tried any backends yet and shouldn't
    /// serve from cache (should try backends first).
    #[inline]
    #[must_use]
    pub const fn has_availability_info(&self) -> bool {
        self.checked != 0
    }

    /// Check if any backend is known to HAVE the article
    ///
    /// Returns true if at least one backend was checked and did NOT return 430.
    /// This is the inverse check from `all_exhausted` - at least one success.
    #[inline]
    #[must_use]
    pub const fn any_backend_has_article(&self) -> bool {
        // A backend "has" the article if it's checked but not missing
        // checked & !missing gives us the backends that have it
        (self.checked & !self.missing) != 0
    }

    /// Query backend availability status
    ///
    /// # Panics (debug builds only)
    /// Panics if `backend_id` >= 8. Config validation enforces max 8 backends.
    #[inline]
    #[must_use]
    pub fn status(&self, backend_id: BackendId) -> BackendStatus {
        let mask = backend_id.availability_bit();
        if self.checked & mask == 0 {
            BackendStatus::Unknown
        } else if self.missing & mask != 0 {
            BackendStatus::Missing
        } else {
            BackendStatus::HasArticle
        }
    }
}

impl Default for ArticleAvailability {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::router::BackendCount;
    use crate::types::BackendId;

    #[test]
    fn test_backend_availability_basic() {
        let mut avail = ArticleAvailability::new();
        let b0 = BackendId::from_index(0);
        let b1 = BackendId::from_index(1);

        // Default: assume all backends have it
        assert!(avail.should_try(b0));
        assert!(avail.should_try(b1));

        // Record b0 as missing (returned 430)
        avail.record_missing(b0);
        assert!(!avail.should_try(b0)); // Should not try again
        assert!(avail.should_try(b1)); // Still should try

        // Record b1 as missing too
        avail.record_missing(b1);
        assert!(!avail.should_try(b1));
    }

    #[test]
    fn test_any_backend_has_article() {
        let mut avail = ArticleAvailability::new();
        let b0 = BackendId::from_index(0);
        let b1 = BackendId::from_index(1);

        // Empty availability - no backend has it (nothing checked)
        assert!(!avail.any_backend_has_article());

        // Record b0 as missing - still none have it
        avail.record_missing(b0);
        assert!(!avail.any_backend_has_article());

        // Record b1 as having it - now one has it
        avail.record_has(b1);
        assert!(avail.any_backend_has_article());

        // Record b1 as missing (overwrite) - none have it again
        avail.record_missing(b1);
        assert!(!avail.any_backend_has_article());
    }

    #[test]
    fn test_record_has_clears_missing_bit() {
        let mut avail = ArticleAvailability::new();
        let b0 = BackendId::from_index(0);

        // First mark as missing
        avail.record_missing(b0);
        assert!(avail.is_missing(b0));
        assert!(!avail.any_backend_has_article());

        // Now mark as has - should clear missing
        avail.record_has(b0);
        assert!(!avail.is_missing(b0));
        assert!(avail.any_backend_has_article());
    }

    #[test]
    fn test_merge_from_430_overrides_has() {
        // NNTP SEMANTICS:
        // - 430 "No Such Article" is AUTHORITATIVE - servers NEVER give false negatives
        // - 2xx success is UNRELIABLE - servers CAN give false positives
        // Therefore: 430 always wins over previous "has" state
        let mut cache_entry = ArticleAvailability::new();
        let b0 = BackendId::from_index(0);
        let b1 = BackendId::from_index(1);

        // Previous request got success from backend 0 (might be false positive)
        cache_entry.record_has(b0);
        assert!(!cache_entry.is_missing(b0));
        assert!(cache_entry.any_backend_has_article());

        // Later request gets 430 from backend 0 (AUTHORITATIVE)
        let mut new_result = ArticleAvailability::new();
        new_result.record_missing(b0); // 430 is definitive
        new_result.record_missing(b1); // Backend 1 also returned 430

        // Merge new results into cache entry
        cache_entry.merge_from(&new_result);

        // CRITICAL: 430 is authoritative, so it MUST override the previous "has"
        // The earlier success might have been a false positive (corrupt data, etc.)
        assert!(
            cache_entry.is_missing(b0),
            "430 must override previous has - 430 is authoritative"
        );
        assert!(!cache_entry.any_backend_has_article());

        // Backend 1 should also be marked as missing
        assert!(
            cache_entry.is_missing(b1),
            "Backend 1 should be marked missing"
        );
    }

    #[test]
    fn test_merge_from_can_add_new_missing() {
        let mut avail1 = ArticleAvailability::new();
        let mut avail2 = ArticleAvailability::new();

        // avail1 knows backend 0 is missing
        avail1.record_missing(BackendId::from_index(0));

        // avail2 knows backend 1 is missing
        avail2.record_missing(BackendId::from_index(1));

        // Merge avail2 into avail1
        avail1.merge_from(&avail2);

        // avail1 should now know both are missing
        assert!(avail1.is_missing(BackendId::from_index(0)));
        assert!(avail1.is_missing(BackendId::from_index(1)));
    }

    #[test]
    fn test_merge_from_has_does_not_clear_missing() {
        // NNTP SEMANTICS:
        // - 430 is AUTHORITATIVE - if server says "no", article is NOT there
        // - 2xx is UNRELIABLE - server might lie (corrupt data, propagation issues)
        // Therefore: "has" from new fetch should NOT clear existing 430
        let mut cache_state = ArticleAvailability::new();
        let b0 = BackendId::from_index(0);
        let b1 = BackendId::from_index(1);

        // Cache says both backends returned 430 (authoritative)
        cache_state.record_missing(b0);
        cache_state.record_missing(b1);
        assert!(cache_state.is_missing(b0));
        assert!(cache_state.is_missing(b1));
        assert!(!cache_state.any_backend_has_article());

        // New request claims success from backend 0 (but 2xx is unreliable!)
        let mut new_fetch = ArticleAvailability::new();
        new_fetch.record_has(b0);

        // Merge new fetch into cache
        cache_state.merge_from(&new_fetch);

        // Backend 0 should STILL be missing - we trust the 430 over the 2xx
        // because 2xx can be false positive but 430 is never false negative
        assert!(
            cache_state.is_missing(b0),
            "2xx should NOT override 430 - 430 is authoritative"
        );
        assert!(!cache_state.any_backend_has_article());

        // Backend 1 is still missing
        assert!(
            cache_state.is_missing(b1),
            "Backend 1 should still be missing"
        );
    }

    #[test]
    fn test_backend_availability_all_exhausted() {
        let mut avail = ArticleAvailability::new();

        // None missing yet
        assert!(!avail.all_exhausted(BackendCount::new(2)));
        assert!(!avail.all_exhausted(BackendCount::new(3)));

        // Record backends 0 and 1 as missing
        avail.record_missing(BackendId::from_index(0));
        avail.record_missing(BackendId::from_index(1));

        // All 2 backends exhausted
        assert!(avail.all_exhausted(BackendCount::new(2)));

        // But not all 3 backends (backend 2 still untried)
        assert!(!avail.all_exhausted(BackendCount::new(3)));
    }
}