Skip to main content

nntp_proxy/protocol/
request.rs

1//! Typed NNTP request context.
2//!
3//! A `RequestContext` is created at the validated request boundary. It owns the
4//! verb and argument bytes so it can move across backend worker queues without
5//! carrying a redundant serialized command buffer.
6
7use smallvec::SmallVec;
8
9use super::{StatusCode, codes};
10use crate::types::{BackendId, MessageId};
11
12pub const MAX_COMMAND_LINE_OCTETS: usize = 512;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum RequestKind {
16    Article,
17    Body,
18    Head,
19    Stat,
20    Group,
21    ListGroup,
22    Last,
23    Next,
24    List,
25    Date,
26    Help,
27    Capabilities,
28    Mode,
29    Quit,
30    Over,
31    Xover,
32    Hdr,
33    Xhdr,
34    NewGroups,
35    NewNews,
36    Post,
37    Ihave,
38    Check,
39    TakeThis,
40    AuthInfo,
41    StartTls,
42    Unknown,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum RequestRouteClass {
47    ArticleByMessageId,
48    Stateless,
49    Stateful,
50    Local,
51    Reject,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum RequestCacheStatus {
56    Hit,
57    PartialHit,
58    Miss,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
62pub struct RequestWireLen(usize);
63
64impl RequestWireLen {
65    #[must_use]
66    pub const fn new(value: usize) -> Self {
67        Self(value)
68    }
69
70    #[must_use]
71    pub const fn get(self) -> usize {
72        self.0
73    }
74
75    #[must_use]
76    pub const fn as_u64(self) -> u64 {
77        self.0 as u64
78    }
79}
80
81impl From<usize> for RequestWireLen {
82    fn from(value: usize) -> Self {
83        Self::new(value)
84    }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
88pub struct ResponseWireLen(usize);
89
90impl ResponseWireLen {
91    #[must_use]
92    pub const fn new(value: usize) -> Self {
93        Self(value)
94    }
95
96    #[must_use]
97    pub const fn get(self) -> usize {
98        self.0
99    }
100}
101
102impl From<usize> for ResponseWireLen {
103    fn from(value: usize) -> Self {
104        Self::new(value)
105    }
106}
107
108#[cfg(test)]
109#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
110pub struct ResponsePayloadLen(usize);
111
112#[cfg(test)]
113impl ResponsePayloadLen {
114    #[must_use]
115    pub(crate) const fn new(value: usize) -> Self {
116        Self(value)
117    }
118}
119
120#[cfg(test)]
121impl From<usize> for ResponsePayloadLen {
122    fn from(value: usize) -> Self {
123        Self::new(value)
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub struct RequestResponseMetadata {
129    status: StatusCode,
130    wire_len: ResponseWireLen,
131}
132
133impl RequestResponseMetadata {
134    #[must_use]
135    pub(crate) const fn new(status: StatusCode, wire_len: ResponseWireLen) -> Self {
136        Self { status, wire_len }
137    }
138
139    #[must_use]
140    #[allow(dead_code)]
141    pub(crate) const fn status(self) -> StatusCode {
142        self.status
143    }
144
145    #[must_use]
146    pub(crate) const fn wire_len(self) -> ResponseWireLen {
147        self.wire_len
148    }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
152pub struct RequestCacheAvailability {
153    checked: usize,
154    missing: usize,
155}
156
157impl RequestCacheAvailability {
158    #[must_use]
159    pub(crate) const fn from_bits(checked: usize, missing: usize) -> Self {
160        Self { checked, missing }
161    }
162
163    #[must_use]
164    pub(crate) const fn missing_bits(self) -> usize {
165        self.missing
166    }
167
168    #[must_use]
169    pub(crate) fn backend_has_article(self, backend_id: BackendId) -> bool {
170        let mask = backend_id.availability_bit();
171        self.checked & mask != 0 && self.missing & mask == 0
172    }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
176pub struct RequestCacheTier(u8);
177
178impl RequestCacheTier {
179    #[must_use]
180    pub(crate) const fn new(value: u8) -> Self {
181        Self(value)
182    }
183}
184
185impl From<u8> for RequestCacheTier {
186    fn from(value: u8) -> Self {
187        Self::new(value)
188    }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
192pub struct RequestCacheTimestampMillis(u64);
193
194impl RequestCacheTimestampMillis {
195    #[must_use]
196    pub(crate) const fn new(value: u64) -> Self {
197        Self(value)
198    }
199}
200
201impl From<u64> for RequestCacheTimestampMillis {
202    fn from(value: u64) -> Self {
203        Self::new(value)
204    }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
208pub enum RequestCachePayloadKind {
209    Missing,
210    AvailabilityOnly,
211    Article,
212    Head,
213    Body,
214    Stat,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
218pub struct RequestCacheArticleNumber(u64);
219
220impl RequestCacheArticleNumber {
221    #[must_use]
222    pub(crate) const fn new(value: u64) -> Self {
223        Self(value)
224    }
225}
226
227impl From<u64> for RequestCacheArticleNumber {
228    fn from(value: u64) -> Self {
229        Self::new(value)
230    }
231}
232
233#[derive(Debug)]
234pub struct RequestContext {
235    kind: RequestKind,
236    verb: SmallVec<[u8; 16]>,
237    args: SmallVec<[u8; 512]>,
238    message_id: Option<(usize, usize)>,
239    cache_status: Option<RequestCacheStatus>,
240    cache_entry: Option<RequestCacheEntryMetadata>,
241    backend_id: Option<BackendId>,
242    response: Option<RequestResponseMetadata>,
243    response_payload: Option<crate::pool::ChunkedResponse>,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247struct RequestLine<'a> {
248    kind: RequestKind,
249    verb: &'a [u8],
250    args: &'a [u8],
251    message_id: Option<(usize, usize)>,
252}
253
254impl<'a> RequestLine<'a> {
255    #[must_use]
256    pub fn parse(line: &'a [u8]) -> Self {
257        let bytes = trim_line_end(line);
258        let split = memchr::memchr(b' ', bytes).unwrap_or(bytes.len());
259        let verb = &bytes[..split];
260        let args = if split < bytes.len() {
261            &bytes[split + 1..]
262        } else {
263            &[]
264        };
265
266        Self {
267            kind: classify_verb(verb),
268            verb,
269            args,
270            message_id: find_message_id(args),
271        }
272    }
273
274    #[must_use]
275    pub const fn kind(&self) -> RequestKind {
276        self.kind
277    }
278
279    #[must_use]
280    pub const fn verb(&self) -> &'a [u8] {
281        self.verb
282    }
283
284    #[must_use]
285    pub const fn args(&self) -> &'a [u8] {
286        self.args
287    }
288
289    #[must_use]
290    #[cfg(test)]
291    pub fn message_id(&self) -> Option<&str> {
292        let (start, end) = self.message_id?;
293        std::str::from_utf8(&self.args[start..end]).ok()
294    }
295
296    #[must_use]
297    #[cfg(test)]
298    pub fn message_id_value(&self) -> Option<MessageId<'a>> {
299        let (start, end) = self.message_id?;
300        MessageId::from_borrowed(std::str::from_utf8(&self.args[start..end]).ok()?).ok()
301    }
302
303    #[must_use]
304    const fn message_id_span(&self) -> Option<(usize, usize)> {
305        self.message_id
306    }
307
308    #[must_use]
309    #[cfg(test)]
310    pub const fn route_class(&self) -> RequestRouteClass {
311        route_class(self.kind, self.message_id.is_some())
312    }
313}
314
315impl Clone for RequestContext {
316    fn clone(&self) -> Self {
317        debug_assert!(
318            self.response_payload.is_none(),
319            "completed response payloads are not cloned"
320        );
321        Self {
322            kind: self.kind,
323            verb: self.verb.clone(),
324            args: self.args.clone(),
325            message_id: self.message_id,
326            cache_status: self.cache_status,
327            cache_entry: self.cache_entry,
328            backend_id: self.backend_id,
329            response: self.response,
330            response_payload: None,
331        }
332    }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub struct RequestCacheEntryMetadata {
337    status: StatusCode,
338    availability: RequestCacheAvailability,
339    tier: RequestCacheTier,
340    timestamp: RequestCacheTimestampMillis,
341    payload_kind: RequestCachePayloadKind,
342    article_number: Option<RequestCacheArticleNumber>,
343}
344
345impl RequestCacheEntryMetadata {
346    #[must_use]
347    pub(crate) const fn new(
348        status: StatusCode,
349        availability: RequestCacheAvailability,
350        tier: RequestCacheTier,
351        timestamp: RequestCacheTimestampMillis,
352        payload_kind: RequestCachePayloadKind,
353        article_number: Option<RequestCacheArticleNumber>,
354    ) -> Self {
355        Self {
356            status,
357            availability,
358            tier,
359            timestamp,
360            payload_kind,
361            article_number,
362        }
363    }
364
365    #[must_use]
366    pub(crate) const fn status(self) -> StatusCode {
367        self.status
368    }
369
370    #[must_use]
371    pub(crate) const fn availability(self) -> RequestCacheAvailability {
372        self.availability
373    }
374
375    #[cfg(test)]
376    #[must_use]
377    pub(crate) const fn tier(self) -> RequestCacheTier {
378        self.tier
379    }
380
381    #[cfg(test)]
382    #[must_use]
383    pub(crate) const fn timestamp(self) -> RequestCacheTimestampMillis {
384        self.timestamp
385    }
386
387    #[cfg(test)]
388    #[must_use]
389    pub(crate) const fn payload_kind(self) -> RequestCachePayloadKind {
390        self.payload_kind
391    }
392
393    #[cfg(test)]
394    #[must_use]
395    pub(crate) const fn article_number(self) -> Option<RequestCacheArticleNumber> {
396        self.article_number
397    }
398}
399
400impl RequestContext {
401    #[must_use]
402    pub fn parse(line: &[u8]) -> Option<Self> {
403        if line.len() > MAX_COMMAND_LINE_OCTETS {
404            return None;
405        }
406
407        let line = RequestLine::parse(line);
408        (!line.verb().is_empty()).then(|| Self::from_request_line(line))
409    }
410
411    #[must_use]
412    fn from_request_line(line: RequestLine<'_>) -> Self {
413        Self::from_parts(
414            line.kind(),
415            SmallVec::from_slice(line.verb()),
416            SmallVec::from_slice(line.args()),
417            line.message_id_span(),
418        )
419    }
420
421    #[must_use]
422    pub(crate) fn from_verb_args(verb: &[u8], args: &[u8]) -> Self {
423        let verb = SmallVec::from_slice(verb);
424        let args = SmallVec::from_slice(args);
425        let kind = classify_verb(&verb);
426        let message_id = find_message_id(&args);
427
428        Self::from_parts(kind, verb, args, message_id)
429    }
430
431    #[must_use]
432    pub(crate) fn from_verb_arg_slices(verb: &[u8], args: &[&[u8]]) -> Self {
433        let verb = SmallVec::from_slice(verb);
434        let arg_len = args.iter().map(|part| part.len()).sum();
435        let mut joined_args = SmallVec::<[u8; 512]>::with_capacity(arg_len);
436        for part in args {
437            joined_args.extend_from_slice(part);
438        }
439        let kind = classify_verb(&verb);
440        let message_id = find_message_id(&joined_args);
441
442        Self::from_parts(kind, verb, joined_args, message_id)
443    }
444
445    const fn from_parts(
446        kind: RequestKind,
447        verb: SmallVec<[u8; 16]>,
448        args: SmallVec<[u8; 512]>,
449        message_id: Option<(usize, usize)>,
450    ) -> Self {
451        Self {
452            kind,
453            verb,
454            args,
455            message_id,
456            cache_status: None,
457            cache_entry: None,
458            backend_id: None,
459            response: None,
460            response_payload: None,
461        }
462    }
463
464    #[inline]
465    #[must_use]
466    pub const fn kind(&self) -> RequestKind {
467        self.kind
468    }
469
470    #[inline]
471    #[must_use]
472    pub const fn backend_id(&self) -> Option<BackendId> {
473        self.backend_id
474    }
475
476    #[cfg(test)]
477    #[inline]
478    #[must_use]
479    pub(crate) const fn cache_status(&self) -> Option<RequestCacheStatus> {
480        self.cache_status
481    }
482
483    #[inline]
484    #[must_use]
485    pub(crate) const fn cache_availability(&self) -> Option<RequestCacheAvailability> {
486        match self.cache_entry {
487            Some(entry) => Some(entry.availability()),
488            None => None,
489        }
490    }
491
492    #[inline]
493    #[must_use]
494    pub(crate) fn cache_records_backend_has_article(&self, backend_id: BackendId) -> bool {
495        self.cache_availability()
496            .is_some_and(|availability| availability.backend_has_article(backend_id))
497    }
498
499    #[inline]
500    #[must_use]
501    pub const fn cache_entry_status(&self) -> Option<StatusCode> {
502        match self.cache_entry {
503            Some(entry) => Some(entry.status()),
504            None => None,
505        }
506    }
507
508    #[cfg(test)]
509    #[inline]
510    #[must_use]
511    pub(crate) const fn cache_entry_tier(&self) -> Option<RequestCacheTier> {
512        match self.cache_entry {
513            Some(entry) => Some(entry.tier()),
514            None => None,
515        }
516    }
517
518    #[cfg(test)]
519    #[inline]
520    #[must_use]
521    pub(crate) const fn cache_entry_timestamp(&self) -> Option<RequestCacheTimestampMillis> {
522        match self.cache_entry {
523            Some(entry) => Some(entry.timestamp()),
524            None => None,
525        }
526    }
527
528    #[cfg(test)]
529    #[inline]
530    #[must_use]
531    pub(crate) const fn cache_payload_kind(&self) -> Option<RequestCachePayloadKind> {
532        match self.cache_entry {
533            Some(entry) => Some(entry.payload_kind()),
534            None => None,
535        }
536    }
537
538    #[cfg(test)]
539    #[inline]
540    #[must_use]
541    pub(crate) const fn cache_article_number(&self) -> Option<RequestCacheArticleNumber> {
542        match self.cache_entry {
543            Some(entry) => entry.article_number(),
544            None => None,
545        }
546    }
547
548    #[cfg(test)]
549    #[inline]
550    #[must_use]
551    pub(crate) const fn cache_entry_metadata(&self) -> Option<RequestCacheEntryMetadata> {
552        self.cache_entry
553    }
554
555    #[inline]
556    pub(crate) const fn record_cache_status(&mut self, status: RequestCacheStatus) {
557        self.cache_status = Some(status);
558    }
559
560    #[inline]
561    pub(crate) const fn record_cache_entry_metadata(
562        &mut self,
563        metadata: RequestCacheEntryMetadata,
564    ) {
565        self.cache_entry = Some(metadata);
566    }
567
568    #[inline]
569    #[must_use]
570    pub const fn response_status(&self) -> Option<StatusCode> {
571        match self.response {
572            Some(response) => Some(response.status),
573            None => None,
574        }
575    }
576
577    #[inline]
578    #[must_use]
579    pub const fn response_wire_len(&self) -> Option<ResponseWireLen> {
580        match self.response {
581            Some(response) => Some(response.wire_len),
582            None => None,
583        }
584    }
585
586    #[inline]
587    #[must_use]
588    pub(crate) const fn response_metadata(&self) -> Option<RequestResponseMetadata> {
589        self.response
590    }
591
592    #[inline]
593    #[must_use]
594    #[cfg(test)]
595    pub(crate) const fn response_payload_len(&self) -> Option<ResponsePayloadLen> {
596        match &self.response_payload {
597            Some(response) => Some(ResponsePayloadLen::new(response.len())),
598            None => None,
599        }
600    }
601
602    #[inline]
603    #[allow(dead_code)]
604    pub(crate) fn complete_backend_response(
605        &mut self,
606        backend_id: BackendId,
607        status: StatusCode,
608        response: crate::pool::ChunkedResponse,
609    ) {
610        self.backend_id = Some(backend_id);
611        self.response = Some(RequestResponseMetadata::new(status, response.len().into()));
612        self.response_payload = Some(response);
613    }
614
615    #[inline]
616    #[allow(dead_code)]
617    pub(crate) fn take_response_payload(&mut self) -> Option<crate::pool::ChunkedResponse> {
618        self.response_payload.take()
619    }
620
621    #[inline]
622    #[cfg(test)]
623    #[must_use]
624    pub(crate) fn response_payload(&self) -> Option<&crate::pool::ChunkedResponse> {
625        self.response_payload.as_ref()
626    }
627
628    #[inline]
629    #[cfg(test)]
630    #[must_use]
631    pub(crate) fn response_payload_eq(&self, expected: &[u8]) -> Option<bool> {
632        let response = self.response_payload()?;
633        if response.len() != expected.len() {
634            return Some(false);
635        }
636
637        let mut offset = 0;
638        Some(response.iter_chunks().all(|chunk| {
639            let end = offset + chunk.len();
640            let matches = expected
641                .get(offset..end)
642                .is_some_and(|expected_chunk| expected_chunk == chunk);
643            offset = end;
644            matches
645        }))
646    }
647
648    #[inline]
649    pub(crate) const fn record_backend_response(
650        &mut self,
651        backend_id: BackendId,
652        response: RequestResponseMetadata,
653    ) {
654        self.backend_id = Some(backend_id);
655        self.response = Some(response);
656    }
657
658    #[inline]
659    pub(crate) const fn record_cache_response(&mut self, response: RequestResponseMetadata) {
660        self.cache_status = Some(RequestCacheStatus::Hit);
661        self.response = Some(response);
662    }
663
664    #[inline]
665    pub(crate) const fn record_local_response(&mut self, response: RequestResponseMetadata) {
666        self.response = Some(response);
667    }
668
669    #[inline]
670    #[must_use]
671    pub fn verb(&self) -> &[u8] {
672        &self.verb
673    }
674
675    #[inline]
676    #[must_use]
677    pub fn args(&self) -> &[u8] {
678        &self.args
679    }
680
681    #[must_use]
682    pub fn message_id(&self) -> Option<&str> {
683        let (start, end) = self.message_id?;
684        std::str::from_utf8(&self.args[start..end]).ok()
685    }
686
687    #[must_use]
688    pub fn message_id_value(&self) -> Option<MessageId<'_>> {
689        let (start, end) = self.message_id?;
690        MessageId::from_borrowed(std::str::from_utf8(&self.args[start..end]).ok()?).ok()
691    }
692
693    #[must_use]
694    pub const fn has_message_id(&self) -> bool {
695        self.message_id.is_some()
696    }
697
698    #[must_use]
699    pub const fn is_stat(&self) -> bool {
700        matches!(self.kind, RequestKind::Stat)
701    }
702
703    #[must_use]
704    pub const fn is_head(&self) -> bool {
705        matches!(self.kind, RequestKind::Head)
706    }
707
708    #[must_use]
709    pub const fn is_unknown_extension(&self) -> bool {
710        matches!(self.kind, RequestKind::Unknown)
711    }
712
713    #[must_use]
714    pub const fn route_class(&self) -> RequestRouteClass {
715        route_class(self.kind, self.message_id.is_some())
716    }
717
718    #[must_use]
719    pub const fn is_pipelineable(&self) -> bool {
720        matches!(self.route_class(), RequestRouteClass::ArticleByMessageId)
721    }
722
723    #[must_use]
724    pub const fn is_large_transfer(&self) -> bool {
725        matches!(self.kind, RequestKind::Article | RequestKind::Body) && self.message_id.is_some()
726    }
727
728    #[must_use]
729    pub fn request_wire_len(&self) -> RequestWireLen {
730        (self.verb.len() + usize::from(!self.args.is_empty()) + self.args.len() + 2).into()
731    }
732
733    /// Write the typed request as NNTP wire bytes without building a command buffer.
734    ///
735    /// # Errors
736    /// Returns any I/O error from writing the request bytes to `writer`.
737    pub async fn write_wire_to<W>(&self, writer: &mut W) -> std::io::Result<()>
738    where
739        W: tokio::io::AsyncWrite + Unpin,
740    {
741        use std::io::IoSlice;
742
743        if self.args().is_empty() {
744            let mut slices = [IoSlice::new(self.verb()), IoSlice::new(b"\r\n")];
745            crate::io_util::write_all_vectored(writer, &mut slices).await
746        } else {
747            let mut slices = [
748                IoSlice::new(self.verb()),
749                IoSlice::new(b" "),
750                IoSlice::new(self.args()),
751                IoSlice::new(b"\r\n"),
752            ];
753            crate::io_util::write_all_vectored(writer, &mut slices).await
754        }
755    }
756
757    #[must_use]
758    pub fn has_response_body(&self, status: StatusCode) -> bool {
759        request_kind_has_response_body(self.kind, status)
760    }
761}
762
763#[must_use]
764pub(crate) fn request_kind_has_response_body(kind: RequestKind, status: StatusCode) -> bool {
765    let code = status.as_u16();
766    if status.is_error() {
767        return false;
768    }
769
770    matches!(
771        (kind, code),
772        (RequestKind::Article, 220)
773            | (RequestKind::Head, 221)
774            | (RequestKind::Body, 222)
775            | (RequestKind::ListGroup, 211)
776            | (RequestKind::Help, 100)
777            | (RequestKind::Capabilities, 101)
778            | (RequestKind::List, 215)
779            | (RequestKind::Over | RequestKind::Xover, 224)
780            | (RequestKind::Hdr | RequestKind::Xhdr, 225)
781            | (RequestKind::NewNews, 230)
782            | (RequestKind::NewGroups, 231)
783    ) || matches!(kind, RequestKind::Unknown) && status_implies_response_body(code)
784}
785
786fn trim_line_end(mut line: &[u8]) -> &[u8] {
787    while matches!(line.last(), Some(b'\r' | b'\n')) {
788        line = &line[..line.len() - 1];
789    }
790    line
791}
792
793const fn route_class(kind: RequestKind, has_message_id: bool) -> RequestRouteClass {
794    match kind {
795        RequestKind::Capabilities | RequestKind::Quit | RequestKind::AuthInfo => {
796            RequestRouteClass::Local
797        }
798        RequestKind::Post
799        | RequestKind::Ihave
800        | RequestKind::Check
801        | RequestKind::TakeThis
802        | RequestKind::StartTls => RequestRouteClass::Reject,
803        RequestKind::Article | RequestKind::Body | RequestKind::Head | RequestKind::Stat
804            if has_message_id =>
805        {
806            RequestRouteClass::ArticleByMessageId
807        }
808        RequestKind::Article
809        | RequestKind::Body
810        | RequestKind::Head
811        | RequestKind::Stat
812        | RequestKind::Group
813        | RequestKind::ListGroup
814        | RequestKind::Last
815        | RequestKind::Next
816        | RequestKind::Over
817        | RequestKind::Xover
818        | RequestKind::Hdr
819        | RequestKind::Xhdr
820        | RequestKind::Unknown => RequestRouteClass::Stateful,
821        RequestKind::List
822        | RequestKind::Date
823        | RequestKind::Help
824        | RequestKind::Mode
825        | RequestKind::NewGroups
826        | RequestKind::NewNews => RequestRouteClass::Stateless,
827    }
828}
829
830const fn classify_verb(verb: &[u8]) -> RequestKind {
831    macro_rules! classify_verbs {
832        ($verb:expr; $($len:literal => { $($lit:literal => $kind:expr),+ $(,)? }),+ $(,)?) => {{
833            match $verb.len() {
834                $(
835                    $len => {
836                        $(
837                            const _: [(); $len] = [(); $lit.len()];
838                        )+
839                        $(
840                            if eq_ignore_ascii_case_const($verb, $lit) {
841                                $kind
842                            } else
843                        )+
844                        {
845                            RequestKind::Unknown
846                        }
847                    }
848                )+
849                _ => RequestKind::Unknown,
850            }
851        }};
852    }
853
854    classify_verbs!(verb;
855        3 => {
856            b"HDR" => RequestKind::Hdr,
857        },
858        4 => {
859            b"BODY" => RequestKind::Body,
860            b"DATE" => RequestKind::Date,
861            b"HEAD" => RequestKind::Head,
862            b"HELP" => RequestKind::Help,
863            b"LAST" => RequestKind::Last,
864            b"LIST" => RequestKind::List,
865            b"MODE" => RequestKind::Mode,
866            b"NEXT" => RequestKind::Next,
867            b"OVER" => RequestKind::Over,
868            b"POST" => RequestKind::Post,
869            b"QUIT" => RequestKind::Quit,
870            b"STAT" => RequestKind::Stat,
871            b"XHDR" => RequestKind::Xhdr,
872        },
873        5 => {
874            b"CHECK" => RequestKind::Check,
875            b"GROUP" => RequestKind::Group,
876            b"IHAVE" => RequestKind::Ihave,
877            b"XOVER" => RequestKind::Xover,
878        },
879        7 => {
880            b"ARTICLE" => RequestKind::Article,
881            b"NEWNEWS" => RequestKind::NewNews,
882        },
883        8 => {
884            b"AUTHINFO" => RequestKind::AuthInfo,
885            b"STARTTLS" => RequestKind::StartTls,
886            b"TAKETHIS" => RequestKind::TakeThis,
887        },
888        9 => {
889            b"LISTGROUP" => RequestKind::ListGroup,
890            b"NEWGROUPS" => RequestKind::NewGroups,
891        },
892        12 => {
893            b"CAPABILITIES" => RequestKind::Capabilities,
894        },
895    )
896}
897
898const fn eq_ignore_ascii_case_const(left: &[u8], right: &[u8]) -> bool {
899    if left.len() != right.len() {
900        return false;
901    }
902
903    let mut index = 0;
904    while index < left.len() {
905        if ascii_upper(left[index]) != ascii_upper(right[index]) {
906            return false;
907        }
908        index += 1;
909    }
910
911    true
912}
913
914const fn ascii_upper(byte: u8) -> u8 {
915    match byte {
916        b'a'..=b'z' => byte - 32,
917        _ => byte,
918    }
919}
920
921fn find_message_id(args: &[u8]) -> Option<(usize, usize)> {
922    let start = args.iter().position(|byte| !byte.is_ascii_whitespace())?;
923    let end = args.iter().rposition(|byte| !byte.is_ascii_whitespace())? + 1;
924    let trimmed = &args[start..end];
925
926    if !trimmed.starts_with(b"<")
927        || !trimmed.ends_with(b">")
928        || trimmed[1..trimmed.len() - 1]
929            .iter()
930            .any(u8::is_ascii_whitespace)
931    {
932        return None;
933    }
934
935    MessageId::from_borrowed(std::str::from_utf8(trimmed).ok()?).ok()?;
936    Some((start, end))
937}
938
939const fn status_implies_response_body(code: u16) -> bool {
940    matches!(
941        code,
942        codes::HELP_TEXT
943            | codes::CAPABILITY_LIST
944            | codes::INFORMATION_FOLLOWS
945            | codes::ARTICLE_FOLLOWS
946            | codes::HEAD_FOLLOWS
947            | codes::BODY_FOLLOWS
948            | codes::OVERVIEW_FOLLOWS
949            | codes::HEADERS_FOLLOW
950            | codes::NEW_ARTICLES_FOLLOW
951            | codes::NEW_GROUPS_FOLLOW
952            | 282
953            | 288
954    )
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960    use futures::executor::block_on;
961    use std::io::IoSlice;
962    use std::pin::Pin;
963    use std::task::{Context, Poll};
964    use tokio::io::AsyncWrite;
965
966    #[derive(Default)]
967    struct CountingWriter {
968        bytes: Vec<u8>,
969        writes: usize,
970        vectored_writes: usize,
971    }
972
973    impl AsyncWrite for CountingWriter {
974        fn poll_write(
975            mut self: Pin<&mut Self>,
976            _cx: &mut Context<'_>,
977            buf: &[u8],
978        ) -> Poll<std::io::Result<usize>> {
979            self.writes += 1;
980            self.bytes.extend_from_slice(buf);
981            Poll::Ready(Ok(buf.len()))
982        }
983
984        fn poll_write_vectored(
985            mut self: Pin<&mut Self>,
986            _cx: &mut Context<'_>,
987            bufs: &[IoSlice<'_>],
988        ) -> Poll<std::io::Result<usize>> {
989            self.vectored_writes += 1;
990            let len = bufs.iter().map(|buf| buf.len()).sum();
991            for buf in bufs {
992                self.bytes.extend_from_slice(buf);
993            }
994            Poll::Ready(Ok(len))
995        }
996
997        fn is_write_vectored(&self) -> bool {
998            true
999        }
1000
1001        fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1002            Poll::Ready(Ok(()))
1003        }
1004
1005        fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1006            Poll::Ready(Ok(()))
1007        }
1008    }
1009
1010    fn wire(context: &RequestContext) -> Vec<u8> {
1011        let mut out = Vec::with_capacity(context.request_wire_len().get());
1012        block_on(context.write_wire_to(&mut out)).unwrap();
1013        out
1014    }
1015
1016    fn request_context(line: &[u8]) -> RequestContext {
1017        RequestContext::parse(line).expect("valid request line")
1018    }
1019
1020    #[test]
1021    fn typed_request_context_parses_message_id() {
1022        let ctx = request_context(b"ARTICLE <a@b>\r\n");
1023        assert_eq!(ctx.kind(), RequestKind::Article);
1024        assert_eq!(ctx.request_wire_len(), RequestWireLen::new(15));
1025        assert_eq!(ctx.message_id(), Some("<a@b>"));
1026        assert_eq!(ctx.cache_status(), None);
1027        assert_eq!(ctx.cache_availability(), None);
1028        assert_eq!(ctx.cache_entry_status(), None);
1029        assert_eq!(ctx.cache_entry_tier(), None);
1030        assert_eq!(ctx.cache_entry_timestamp(), None);
1031        assert_eq!(ctx.cache_payload_kind(), None);
1032        assert_eq!(ctx.cache_article_number(), None);
1033        assert_eq!(ctx.backend_id(), None);
1034        assert_eq!(ctx.response_status(), None);
1035        assert_eq!(ctx.response_wire_len(), None);
1036        assert!(ctx.is_pipelineable());
1037        assert_eq!(wire(&ctx), b"ARTICLE <a@b>\r\n");
1038    }
1039
1040    #[test]
1041    fn typed_request_context_uses_vectored_write() {
1042        let ctx = request_context(b"ARTICLE <a@b>\r\n");
1043        let mut out = CountingWriter::default();
1044
1045        block_on(ctx.write_wire_to(&mut out)).unwrap();
1046
1047        assert_eq!(out.bytes, b"ARTICLE <a@b>\r\n");
1048        assert_eq!(out.writes, 0);
1049        assert_eq!(out.vectored_writes, 1);
1050    }
1051
1052    #[test]
1053    fn borrowed_request_line_parses_without_owning_bytes() {
1054        let bytes = b"ARTICLE <a@b>\r\n";
1055        let parsed = RequestLine::parse(bytes);
1056
1057        assert_eq!(parsed.kind(), RequestKind::Article);
1058        assert_eq!(parsed.verb(), b"ARTICLE");
1059        assert_eq!(parsed.args(), b"<a@b>");
1060        assert_eq!(parsed.message_id(), Some("<a@b>"));
1061        assert_eq!(
1062            parsed.message_id_value(),
1063            Some(MessageId::from_borrowed("<a@b>").unwrap())
1064        );
1065        assert_eq!(parsed.route_class(), RequestRouteClass::ArticleByMessageId);
1066    }
1067
1068    #[test]
1069    fn borrowed_request_line_requires_exact_message_id_argument() {
1070        let extra_token = RequestLine::parse(b"ARTICLE 123 <a@b>\r\n");
1071        let spaced = RequestLine::parse(b"ARTICLE   <a@b>  \r\n");
1072
1073        assert_eq!(extra_token.message_id(), None);
1074        assert_eq!(extra_token.route_class(), RequestRouteClass::Stateful);
1075
1076        assert_eq!(spaced.message_id(), Some("<a@b>"));
1077        assert_eq!(spaced.route_class(), RequestRouteClass::ArticleByMessageId);
1078    }
1079
1080    #[test]
1081    fn request_context_owns_borrowed_request_line() {
1082        let parsed = RequestLine::parse(b"BODY <a@b>\r\n");
1083        let ctx = RequestContext::from_request_line(parsed);
1084
1085        assert_eq!(ctx.kind(), RequestKind::Body);
1086        assert_eq!(ctx.verb(), b"BODY");
1087        assert_eq!(ctx.args(), b"<a@b>");
1088        assert_eq!(ctx.message_id(), Some("<a@b>"));
1089        assert_eq!(ctx.request_wire_len(), RequestWireLen::new(12));
1090    }
1091
1092    #[test]
1093    fn request_context_parses_wire_line_at_boundary() {
1094        let ctx = RequestContext::parse(b"BODY <a@b>\r\n").expect("valid request line");
1095
1096        assert_eq!(ctx.kind(), RequestKind::Body);
1097        assert_eq!(ctx.verb(), b"BODY");
1098        assert_eq!(ctx.args(), b"<a@b>");
1099        assert_eq!(ctx.message_id(), Some("<a@b>"));
1100        assert_eq!(ctx.request_wire_len(), RequestWireLen::new(12));
1101    }
1102
1103    #[test]
1104    fn request_context_parse_rejects_empty_request_lines() {
1105        assert!(RequestContext::parse(b"").is_none());
1106        assert!(RequestContext::parse(b"\r\n").is_none());
1107        assert!(RequestContext::parse(b"   \r\n").is_none());
1108    }
1109
1110    #[test]
1111    fn request_context_parse_rejects_oversized_request_lines() {
1112        let mut line = b"ARTICLE <".to_vec();
1113        line.extend(std::iter::repeat_n(b'a', 496));
1114        line.extend_from_slice(b"@example.com>\r\n");
1115
1116        assert_eq!(line.len(), 520);
1117        assert!(RequestContext::parse(&line).is_none());
1118    }
1119
1120    #[tokio::test]
1121    async fn request_context_writes_wire_bytes_without_command_buffer() {
1122        let ctx = request_context(b"BODY <a@b>\r\n");
1123        let mut out = Vec::new();
1124
1125        ctx.write_wire_to(&mut out).await.unwrap();
1126
1127        assert_eq!(out, b"BODY <a@b>\r\n");
1128    }
1129
1130    #[test]
1131    fn typed_request_context_parses_request_bytes() {
1132        let ctx = request_context(b"XFOO \xff\r\n");
1133
1134        assert_eq!(ctx.kind(), RequestKind::Unknown);
1135        assert_eq!(ctx.verb(), b"XFOO");
1136        assert_eq!(ctx.args(), b"\xff");
1137        assert_eq!(ctx.route_class(), RequestRouteClass::Stateful);
1138        assert_eq!(wire(&ctx), b"XFOO \xff\r\n");
1139    }
1140
1141    #[test]
1142    fn request_context_records_backend_response_when_known() {
1143        let mut ctx = request_context(b"STAT <a@b>\r\n");
1144        let backend_id = BackendId::from_index(2);
1145        let status = StatusCode::new(223);
1146
1147        ctx.record_backend_response(
1148            backend_id,
1149            RequestResponseMetadata::new(status, ResponseWireLen::new(19)),
1150        );
1151
1152        assert_eq!(ctx.backend_id(), Some(backend_id));
1153        assert_eq!(
1154            ctx.response_metadata(),
1155            Some(RequestResponseMetadata::new(
1156                status,
1157                ResponseWireLen::new(19)
1158            ))
1159        );
1160        assert_eq!(ctx.response_status(), Some(status));
1161        assert_eq!(ctx.response_wire_len(), Some(ResponseWireLen::new(19)));
1162        assert_eq!(wire(&ctx), b"STAT <a@b>\r\n");
1163    }
1164
1165    #[test]
1166    fn request_context_compares_chunked_response_payload_without_flattening() {
1167        let mut ctx = request_context(b"STAT <a@b>\r\n");
1168        let backend_id = BackendId::from_index(2);
1169        let pool = crate::pool::BufferPool::for_tests();
1170        let mut response = crate::pool::ChunkedResponse::default();
1171        response.extend_from_slice(&pool, b"223 0 ");
1172        response.extend_from_slice(&pool, b"<a@b>\r\n");
1173
1174        ctx.complete_backend_response(backend_id, StatusCode::new(223), response);
1175
1176        assert_eq!(
1177            ctx.response_payload_len(),
1178            Some(ResponsePayloadLen::new(13))
1179        );
1180        assert_eq!(ctx.response_payload_eq(b"223 0 <a@b>\r\n"), Some(true));
1181        assert_eq!(ctx.response_payload_eq(b"223 0 <other>\r\n"), Some(false));
1182        assert_eq!(
1183            ctx.response_payload_eq(b"223 0 <a@b>\r\nextra"),
1184            Some(false)
1185        );
1186    }
1187
1188    #[test]
1189    fn request_context_records_cache_status_when_known() {
1190        let mut ctx = request_context(b"BODY <a@b>\r\n");
1191
1192        ctx.record_cache_status(RequestCacheStatus::PartialHit);
1193
1194        assert_eq!(ctx.cache_status(), Some(RequestCacheStatus::PartialHit));
1195        assert_eq!(wire(&ctx), b"BODY <a@b>\r\n");
1196    }
1197
1198    #[test]
1199    fn request_context_records_cache_entry_metadata_together() {
1200        let mut ctx = request_context(b"ARTICLE <a@b>\r\n");
1201        let status = StatusCode::new(430);
1202        let availability = RequestCacheAvailability::from_bits(0b0000_0010, 0b0000_0010);
1203        let tier = RequestCacheTier::new(2);
1204        let timestamp = RequestCacheTimestampMillis::new(123_456);
1205        let payload_kind = RequestCachePayloadKind::Missing;
1206        let article_number = Some(RequestCacheArticleNumber::new(42));
1207
1208        ctx.record_cache_entry_metadata(RequestCacheEntryMetadata::new(
1209            status,
1210            availability,
1211            tier,
1212            timestamp,
1213            payload_kind,
1214            article_number,
1215        ));
1216
1217        assert_eq!(
1218            ctx.cache_entry_metadata(),
1219            Some(RequestCacheEntryMetadata::new(
1220                status,
1221                availability,
1222                tier,
1223                timestamp,
1224                payload_kind,
1225                article_number
1226            ))
1227        );
1228        assert_eq!(ctx.cache_entry_status(), Some(status));
1229        assert_eq!(ctx.cache_availability(), Some(availability));
1230        assert_eq!(ctx.cache_entry_tier(), Some(tier));
1231        assert_eq!(ctx.cache_entry_timestamp(), Some(timestamp));
1232        assert_eq!(ctx.cache_payload_kind(), Some(payload_kind));
1233        assert_eq!(ctx.cache_article_number(), article_number);
1234        assert_eq!(ctx.response_status(), None);
1235        assert_eq!(wire(&ctx), b"ARTICLE <a@b>\r\n");
1236    }
1237
1238    #[test]
1239    fn request_context_detects_cached_backend_has_article() {
1240        let mut ctx = request_context(b"ARTICLE <a@b>\r\n");
1241
1242        ctx.record_cache_entry_metadata(RequestCacheEntryMetadata::new(
1243            StatusCode::new(220),
1244            RequestCacheAvailability::from_bits(0b0000_0110, 0b0000_0010),
1245            RequestCacheTier::new(0),
1246            RequestCacheTimestampMillis::new(1),
1247            RequestCachePayloadKind::AvailabilityOnly,
1248            None,
1249        ));
1250
1251        assert!(!ctx.cache_records_backend_has_article(BackendId::from_index(0)));
1252        assert!(!ctx.cache_records_backend_has_article(BackendId::from_index(1)));
1253        assert!(ctx.cache_records_backend_has_article(BackendId::from_index(2)));
1254    }
1255
1256    #[test]
1257    fn request_cache_availability_detects_backend_eight_bit() {
1258        let availability = RequestCacheAvailability::from_bits(0b1_0000_0000, 0);
1259
1260        assert!(availability.backend_has_article(BackendId::from_index(8)));
1261        assert!(!availability.backend_has_article(BackendId::from_index(7)));
1262    }
1263
1264    #[test]
1265    #[cfg(debug_assertions)]
1266    fn request_cache_availability_cannot_receive_out_of_range_backend() {
1267        assert!(BackendId::try_from_index(usize::BITS as usize).is_none());
1268    }
1269
1270    #[test]
1271    fn request_context_records_cache_response_when_served() {
1272        let mut ctx = request_context(b"HEAD <a@b>\r\n");
1273        let status = StatusCode::new(221);
1274
1275        ctx.record_cache_response(RequestResponseMetadata::new(
1276            status,
1277            ResponseWireLen::new(24),
1278        ));
1279
1280        assert_eq!(ctx.cache_status(), Some(RequestCacheStatus::Hit));
1281        assert_eq!(ctx.backend_id(), None);
1282        assert_eq!(ctx.response_status(), Some(status));
1283        assert_eq!(ctx.response_wire_len(), Some(ResponseWireLen::new(24)));
1284        assert_eq!(wire(&ctx), b"HEAD <a@b>\r\n");
1285    }
1286
1287    #[test]
1288    fn request_context_records_local_response_without_backend() {
1289        let mut ctx = request_context(b"QUIT\r\n");
1290        let status = StatusCode::new(205);
1291
1292        ctx.record_local_response(RequestResponseMetadata::new(
1293            status,
1294            ResponseWireLen::new(24),
1295        ));
1296
1297        assert_eq!(ctx.backend_id(), None);
1298        assert_eq!(ctx.response_status(), Some(status));
1299        assert_eq!(ctx.response_wire_len(), Some(ResponseWireLen::new(24)));
1300        assert_eq!(wire(&ctx), b"QUIT\r\n");
1301    }
1302
1303    #[test]
1304    fn unknown_extensions_are_stateful() {
1305        let ctx = request_context(b"XFOO arg\r\n");
1306        assert_eq!(ctx.kind(), RequestKind::Unknown);
1307        assert_eq!(ctx.route_class(), RequestRouteClass::Stateful);
1308    }
1309
1310    #[test]
1311    fn all_rfc_command_verbs_classify_to_request_kinds() {
1312        let cases = [
1313            ("ARTICLE <a@b>\r\n", RequestKind::Article),
1314            ("BODY <a@b>\r\n", RequestKind::Body),
1315            ("HEAD <a@b>\r\n", RequestKind::Head),
1316            ("STAT <a@b>\r\n", RequestKind::Stat),
1317            ("GROUP alt.test\r\n", RequestKind::Group),
1318            ("LISTGROUP alt.test\r\n", RequestKind::ListGroup),
1319            ("LAST\r\n", RequestKind::Last),
1320            ("NEXT\r\n", RequestKind::Next),
1321            ("LIST\r\n", RequestKind::List),
1322            ("DATE\r\n", RequestKind::Date),
1323            ("HELP\r\n", RequestKind::Help),
1324            ("CAPABILITIES\r\n", RequestKind::Capabilities),
1325            ("MODE READER\r\n", RequestKind::Mode),
1326            ("QUIT\r\n", RequestKind::Quit),
1327            ("OVER 1-10\r\n", RequestKind::Over),
1328            ("XOVER 1-10\r\n", RequestKind::Xover),
1329            ("HDR Subject 1-10\r\n", RequestKind::Hdr),
1330            ("XHDR Subject 1-10\r\n", RequestKind::Xhdr),
1331            ("NEWGROUPS 20260101 000000 GMT\r\n", RequestKind::NewGroups),
1332            ("NEWNEWS * 20260101 000000 GMT\r\n", RequestKind::NewNews),
1333            ("POST\r\n", RequestKind::Post),
1334            ("IHAVE <a@b>\r\n", RequestKind::Ihave),
1335            ("CHECK <a@b>\r\n", RequestKind::Check),
1336            ("TAKETHIS <a@b>\r\n", RequestKind::TakeThis),
1337            ("AUTHINFO USER test\r\n", RequestKind::AuthInfo),
1338            ("STARTTLS\r\n", RequestKind::StartTls),
1339        ];
1340
1341        for (line, expected) in cases {
1342            assert_eq!(
1343                RequestLine::parse(line.as_bytes()).kind(),
1344                expected,
1345                "{line}"
1346            );
1347        }
1348    }
1349
1350    #[test]
1351    fn request_route_classes_match_typed_command_behavior() {
1352        let cases = [
1353            ("ARTICLE <a@b>\r\n", RequestRouteClass::ArticleByMessageId),
1354            ("BODY 123\r\n", RequestRouteClass::Stateful),
1355            ("GROUP alt.test\r\n", RequestRouteClass::Stateful),
1356            ("LIST\r\n", RequestRouteClass::Stateless),
1357            ("MODE READER\r\n", RequestRouteClass::Stateless),
1358            ("QUIT\r\n", RequestRouteClass::Local),
1359            ("AUTHINFO PASS secret\r\n", RequestRouteClass::Local),
1360            ("POST\r\n", RequestRouteClass::Reject),
1361            ("IHAVE <a@b>\r\n", RequestRouteClass::Reject),
1362            ("CHECK <a@b>\r\n", RequestRouteClass::Reject),
1363            ("TAKETHIS <a@b>\r\n", RequestRouteClass::Reject),
1364            ("STARTTLS\r\n", RequestRouteClass::Reject),
1365            ("XFOO arg\r\n", RequestRouteClass::Stateful),
1366        ];
1367
1368        for (line, expected) in cases {
1369            assert_eq!(
1370                RequestLine::parse(line.as_bytes()).route_class(),
1371                expected,
1372                "{line}"
1373            );
1374        }
1375    }
1376
1377    #[test]
1378    fn request_context_derives_response_body_expectation() {
1379        let group = request_context(b"GROUP alt.test\r\n");
1380        let listgroup = request_context(b"LISTGROUP alt.test\r\n");
1381        let unknown = request_context(b"XFEATURE TEST\r\n");
1382        assert!(!group.has_response_body(StatusCode::new(211)));
1383        assert!(listgroup.has_response_body(StatusCode::new(211)));
1384        assert!(!request_context(b"ARTICLE <x@y>\r\n").has_response_body(StatusCode::new(430)));
1385        assert!(unknown.has_response_body(StatusCode::new(282)));
1386        assert!(unknown.has_response_body(StatusCode::new(288)));
1387        assert!(!unknown.has_response_body(StatusCode::new(281)));
1388    }
1389}