├── .gitattributes ├── tests ├── xover_gzip_header ├── xover_resp_xfeature_compress ├── innd │ ├── root │ │ └── etc │ │ │ ├── pam.d │ │ │ └── nnrpd │ │ │ └── news │ │ │ ├── readers.conf │ │ │ └── inn.conf │ ├── README.md │ └── Dockerfile ├── xover_resp_plain_text ├── text_article ├── mozilla.log └── mozilla.dev.platform_47670 ├── src ├── types │ ├── response │ │ ├── mod.rs │ │ ├── article │ │ │ ├── mod.rs │ │ │ ├── stat.rs │ │ │ ├── iter.rs │ │ │ ├── body.rs │ │ │ ├── text.rs │ │ │ ├── headers.rs │ │ │ ├── binary.rs │ │ │ └── parse.rs │ │ ├── group.rs │ │ ├── util.rs │ │ └── capabilities.rs │ ├── command │ │ ├── xfeature.rs │ │ ├── rfc4643.rs │ │ ├── rfc2980.rs │ │ ├── mod.rs │ │ └── rfc3977.rs │ ├── mod.rs │ └── response_code.rs ├── raw │ ├── mod.rs │ ├── error.rs │ ├── stream.rs │ ├── compression.rs │ ├── response.rs │ ├── parse.rs │ └── connection.rs ├── lib.rs ├── error.rs └── client.rs ├── .gitignore ├── Cargo.toml ├── LICENSE ├── .github └── workflows │ └── rust.yml ├── examples ├── get_article_conn.rs ├── get_article.rs ├── connect_to_group.rs ├── xfeature_compression.rs └── overviews.rs └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests/**/* -crlf 2 | -------------------------------------------------------------------------------- /tests/xover_gzip_header: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamaluik/brokaw/master/tests/xover_gzip_header -------------------------------------------------------------------------------- /tests/xover_resp_xfeature_compress: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamaluik/brokaw/master/tests/xover_resp_xfeature_compress -------------------------------------------------------------------------------- /tests/innd/root/etc/pam.d/nnrpd: -------------------------------------------------------------------------------- 1 | # auth include password-auth 2 | # account include password-auth 3 | auth required pam_unix.so 4 | account required pam_unix.so 5 | -------------------------------------------------------------------------------- /src/types/response/mod.rs: -------------------------------------------------------------------------------- 1 | mod article; 2 | mod capabilities; 3 | mod group; 4 | mod util; 5 | 6 | pub use article::*; 7 | 8 | pub use group::*; 9 | 10 | pub use capabilities::Capabilities; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | /.idea/**/* 13 | -------------------------------------------------------------------------------- /src/types/command/xfeature.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::types::NntpCommand; 4 | 5 | /// Enable Giganews style header compression 6 | #[derive(Clone, Copy, Debug)] 7 | pub struct XFeatureCompress; 8 | 9 | impl fmt::Display for XFeatureCompress { 10 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 11 | write!(f, "XFEATURE COMPRESS GZIP TERMINATOR") 12 | } 13 | } 14 | 15 | impl NntpCommand for XFeatureCompress {} 16 | -------------------------------------------------------------------------------- /src/types/response/article/mod.rs: -------------------------------------------------------------------------------- 1 | /// Article body 2 | mod body; 3 | 4 | /// Binary articles 5 | mod binary; 6 | 7 | /// Article headers 8 | mod headers; 9 | 10 | /// Iterators over article bodies 11 | mod iter; 12 | 13 | /// Parsing logic for for article headers 14 | mod parse; 15 | 16 | /// Article status 17 | mod stat; 18 | 19 | /// Text articles 20 | mod text; 21 | 22 | pub use binary::BinaryArticle; 23 | pub use body::Body; 24 | pub use headers::{Head, Header, Headers}; 25 | pub use stat::Stat; 26 | pub use text::TextArticle; 27 | -------------------------------------------------------------------------------- /src/types/command/rfc4643.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use super::NntpCommand; 4 | 5 | /// Authenticate via `AUTHINFO` as specified in [RFC 4643](https://tools.ietf.org/html/rfc4643) 6 | /// 7 | /// # Limitations 8 | /// 9 | /// * SASL is not currently implemented 10 | #[derive(Clone, Debug)] 11 | pub enum AuthInfo { 12 | /// Username 13 | User(String), 14 | /// Password 15 | Pass(String), 16 | } 17 | 18 | impl fmt::Display for AuthInfo { 19 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 20 | match self { 21 | AuthInfo::User(username) => write!(f, "AUTHINFO USER {}", username), 22 | AuthInfo::Pass(password) => write!(f, "AUTHINFO PASS {}", password), 23 | } 24 | } 25 | } 26 | 27 | impl NntpCommand for AuthInfo {} 28 | -------------------------------------------------------------------------------- /tests/xover_resp_plain_text: -------------------------------------------------------------------------------- 1 | 461197 Re: Weekly Statistics (21.6.2003 - 27.6.2003) Peter J Ross Fri, 4 Jul 2003 06:21:53 +0100 <3efd5451$0$2946$df066bcf@news.sexzilla.net> <3efe1f6d17339@danger.diabolik> <3EFE87C4.EF9DFDDA@WHATISSPAMhotmail.com> <3efed4361863a@danger.diabolik> 5165 109 Xref: intern1.nntp.aus1.giganews.com alt.alien.vampire.flonk.flonk.flonk:736274 misc.misc:169077 misc.test:461197 us.config:29809 2 | . 3 | -------------------------------------------------------------------------------- /src/raw/mod.rs: -------------------------------------------------------------------------------- 1 | /// Raw connection implementation 2 | pub mod connection; 3 | 4 | /// Low level API errors 5 | pub mod error; 6 | 7 | /// Response parsing logic 8 | /// 9 | /// * The parsing is line based 10 | /// * Naming conventions follow those in [`nom`]. 11 | /// * Any function that begins with `parse_` will fail if the provided buffer is not consumed. 12 | pub(crate) mod parse; 13 | 14 | /// Raw NNTP response types 15 | pub mod response; 16 | 17 | /// Raw TCP stream implementation 18 | pub(crate) mod stream; 19 | 20 | #[doc(inline)] 21 | pub use connection::{NntpConnection, TlsConfig}; 22 | #[doc(inline)] 23 | pub use response::{DataBlocks, RawResponse}; 24 | 25 | #[doc(inline)] 26 | pub use stream::NntpStream; 27 | 28 | pub(crate) mod compression; 29 | 30 | pub use compression::Compression; 31 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "brokaw" 3 | version = "0.2.1-alpha.0" 4 | description = "📰 An NNTP client liberary. More at 11! 📰" 5 | authors = ["Samani G. Gikandi "] 6 | edition = "2018" 7 | license = "MIT" 8 | repository = "https://github.com/sgg/brokaw" 9 | documentation = "https://docs.rs/brokaw" 10 | readme = "README.md" 11 | 12 | keywords = ["nntp", "netnews", "usenet"] 13 | categories = [ "network-programming"] 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [dependencies] 18 | flate2 = "1.0.14" 19 | log = "0.4.8" 20 | native-tls = "0.2.4" 21 | nom = "5.1" 22 | num_enum = "0.5.0" 23 | thiserror = "1.0" 24 | 25 | [dev-dependencies] 26 | anyhow = "1.0.31" 27 | env_logger = "0.7.1" 28 | rpassword = "4.0.5" 29 | structopt = "0.3.14" 30 | doc-comment = "0.3.3" 31 | -------------------------------------------------------------------------------- /tests/innd/README.md: -------------------------------------------------------------------------------- 1 | # inndocker 2 | 3 | A containerized [INN](https://www.eyrie.org/~eagle/software/inn/) for integration testing! 4 | 5 | The [`root`](./root) directory contains a filesystem tree that will be copied into the container. 6 | 7 | ## Running 8 | 9 | `innd` is hooked up with PAM and a test user `newsreader` with password `readthenews` is available. 10 | Note that `innd` only seems to adhere to `SIGQUIT` so you must kill it with (CTRL + \) 11 | 12 | 1. Start the server 13 | 14 | ```shell script 15 | ❯ docker build -t sgg/innd . \ 16 | && docker run -it -rm --name innd -p 119:119 sgg/innd 17 | ``` 18 | 2. Interact with it via netcat telnet (or your favorite news client) 19 | 20 | ```shell script 21 | ❯ echo "AUTHINFO USER newsreader 22 | AUTHINFO PASS readthenews 23 | CAPABILITIES 24 | LIST" \ 25 | | nc localhost 119 26 | ``` -------------------------------------------------------------------------------- /src/types/response/article/stat.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | 3 | use crate::error::{Error, Result}; 4 | use crate::types::prelude::*; 5 | use crate::types::response::util::{err_if_not_kind, process_article_first_line}; 6 | 7 | /// Article metadata returned by [`STAT`](https://tools.ietf.org/html/rfc3977#section-6.2.4) 8 | #[derive(Clone, Debug, Eq, PartialEq)] 9 | pub struct Stat { 10 | /// The number of the article unique to a particular newsgroup 11 | pub number: ArticleNumber, 12 | /// The unique message id for the article 13 | pub message_id: String, 14 | } 15 | 16 | impl TryFrom<&RawResponse> for Stat { 17 | type Error = Error; 18 | 19 | fn try_from(resp: &RawResponse) -> Result { 20 | err_if_not_kind(resp, Kind::ArticleExists)?; 21 | 22 | let (number, message_id) = process_article_first_line(resp)?; 23 | 24 | Ok(Self { number, message_id }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/types/response/article/iter.rs: -------------------------------------------------------------------------------- 1 | /// An iterator over the lines of an Article body 2 | /// 3 | /// Created by [`BinaryArticle::lines`] and [`Body::lines`] 4 | #[derive(Clone, Debug)] 5 | pub struct Lines<'a> { 6 | pub(crate) payload: &'a [u8], 7 | pub(crate) inner: std::slice::Iter<'a, (usize, usize)>, 8 | } 9 | 10 | impl<'a> Iterator for Lines<'a> { 11 | type Item = &'a [u8]; 12 | 13 | fn next(&mut self) -> Option { 14 | self.inner 15 | .next() 16 | .map(|(start, end)| &self.payload[*start..*end]) 17 | } 18 | } 19 | 20 | /// An iterator over the unterimnated lines of an Article body 21 | /// 22 | /// Created by [`BinaryArticle::unterminated`] and [`Body::Lines`] 23 | #[derive(Clone, Debug)] 24 | pub struct Unterminated<'a> { 25 | pub(crate) inner: Lines<'a>, 26 | } 27 | 28 | impl<'a> Iterator for Unterminated<'a> { 29 | type Item = &'a [u8]; 30 | 31 | fn next(&mut self) -> Option { 32 | self.inner.next().map(|b| &b[..b.len() - 2]) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Samani G. Gikandi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/innd/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fedora:latest 2 | 3 | RUN dnf makecache 4 | 5 | RUN dnf install -y \ 6 | inn \ 7 | passwd \ 8 | httpd-tools 9 | 10 | # TODO(craft): We should clear the DNF cache 11 | 12 | RUN useradd newsreader \ 13 | && echo "readthenews" | passwd newsreader --stdin 14 | 15 | # TODO(sec): We setuid so the news user can authenticate against the shadow DB. Deffo not for production 16 | RUN chmod u+s /usr/libexec/news/auth/passwd/ckpasswd 17 | 18 | ENV TINI_VERSION v0.19.0 19 | ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini 20 | RUN chmod +x /tini 21 | 22 | # Copy configuration 23 | COPY root / 24 | RUN chown -R news:news /var/lib/news 25 | 26 | USER news 27 | ENV PATH "$PATH:/usr/libexec/news" 28 | 29 | # Create the history database 30 | RUN makedbz -i -o 31 | # Create some test groups 32 | RUN innd \ 33 | && sleep 2 \ 34 | && ctlinnd newgroup test.music \ 35 | && ctlinnd newgroup test.jokes \ 36 | && ctlinnd shutdown "created test groups" 37 | 38 | 39 | # innd only seems to exit with SIGQUIT 40 | STOPSIGNAL SIGQUIT 41 | ENTRYPOINT ["/tini", "--"] 42 | CMD [ "/usr/libexec/news/innd", "-d" ] 43 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | build_and_test: 16 | runs-on: ${{ matrix.os }} 17 | 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest] 21 | rust: [1.44.0] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions-rs/toolchain@v1 26 | name: rust toolchain 27 | with: 28 | profile: minimal 29 | toolchain: ${{ matrix.rust }} 30 | override: true 31 | components: rustfmt, clippy 32 | 33 | - uses: actions-rs/cargo@v1 34 | with: 35 | command: build 36 | args: --all-targets 37 | - uses: actions-rs/cargo@v1 38 | with: 39 | command: test 40 | args: --all-features --all-targets 41 | 42 | - uses: actions-rs/cargo@v1 43 | with: 44 | command: fmt 45 | args: -- --check 46 | 47 | - uses: actions-rs/cargo@v1 48 | with: 49 | command: clippy 50 | args: -- -D warnings 51 | -------------------------------------------------------------------------------- /examples/get_article_conn.rs: -------------------------------------------------------------------------------- 1 | //! This example demonstrates how to use the NntpConnection to retrieve an article 2 | use std::convert::TryFrom; 3 | 4 | use brokaw::types::command as cmd; 5 | use brokaw::types::prelude::*; 6 | use brokaw::{ConnectionConfig, NntpConnection}; 7 | 8 | fn main() -> anyhow::Result<()> { 9 | env_logger::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 10 | 11 | let (mut conn, _resp) = 12 | NntpConnection::connect(("news.mozilla.org", 119), ConnectionConfig::default())?; 13 | 14 | let group_resp = conn.command(&cmd::Group("mozilla.dev.platform".to_string()))?; 15 | let group = Group::try_from(&group_resp)?; 16 | 17 | let raw_article = conn.command(&cmd::Article::Number(group.high))?; 18 | 19 | let article = BinaryArticle::try_from(&raw_article)?; 20 | 21 | println!("Article ID {}", article.message_id()); 22 | println!("Article # {}", article.number()); 23 | println!("Article has {} headers", article.headers().len()); 24 | println!("Article body:\n"); 25 | 26 | let text_article = article.to_text()?; 27 | text_article.lines().for_each(|line| println!("{}", line)); 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /src/raw/error.rs: -------------------------------------------------------------------------------- 1 | use std::net::TcpStream; 2 | 3 | /// Low level API Errors 4 | /// 5 | /// These errors represent (e.g. I/O, deserialization, parsing, etc). 6 | /// For protocol level errors see [`crate::error::Error`] 7 | #[derive(Debug, thiserror::Error)] 8 | #[non_exhaustive] 9 | pub enum Error { 10 | /// The connection encountered some sort of I/O error 11 | #[error("IO {0}")] 12 | Io(#[from] std::io::Error), 13 | /// An error raised by the system's TLS implementation 14 | #[error("TLS Error -- {0}")] 15 | Tls(#[from] native_tls::Error), 16 | /// The TLS Handshake has failed 17 | #[error("TLS Handshake Error -- {0}")] 18 | TlsHandshake(#[from] native_tls::HandshakeError), 19 | /// The server returned data that could not be parsed 20 | /// 21 | /// This likely indicates that either a bug in Brokaw's response parser, 22 | /// data corruption, or an out of spec server. 23 | /// 24 | /// This could also occur if an unsupported compression mechanism is enabled. 25 | #[error("Failed to parse response")] 26 | Parse, 27 | } 28 | 29 | /// A Result returned by the low level API 30 | pub type Result = std::result::Result; 31 | -------------------------------------------------------------------------------- /examples/get_article.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use brokaw::types::command as cmd; 4 | use brokaw::{ClientConfig, ConnectionConfig}; 5 | 6 | fn main() -> anyhow::Result<()> { 7 | env_logger::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 8 | 9 | let mut client = ClientConfig::default() 10 | .connection_config( 11 | ConnectionConfig::default() 12 | .read_timeout(Some(Duration::from_secs(10))) 13 | .to_owned(), 14 | ) 15 | .group(Some("mozilla.dev.platform")) 16 | .connect(("news.mozilla.org", 119))?; 17 | 18 | let highest_article = client.group().unwrap().high; 19 | 20 | let article = client 21 | .article(cmd::Article::Number(highest_article)) 22 | .and_then(|a| a.to_text())?; 23 | 24 | println!("~~~ 📰 `{}` ~~~", article.message_id()); 25 | println!("~~~ Headers ~~~"); 26 | article.headers().iter().for_each(|header| { 27 | println!("Header {} --> {:?}", header.name, header.content); 28 | }); 29 | 30 | println!("~~~ Body ~~~"); 31 | article.body().iter().for_each(|line| println!("{}", line)); 32 | println!("~~~ 👋🏾 ~~~"); 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/raw/stream.rs: -------------------------------------------------------------------------------- 1 | use std::net::TcpStream; 2 | 3 | use native_tls::TlsStream; 4 | use std::io; 5 | use std::io::{Read, Write}; 6 | 7 | /// A raw NNTP session 8 | #[derive(Debug)] 9 | pub enum NntpStream { 10 | /// A stream using TLS 11 | Tls(TlsStream), 12 | /// A plain text stream 13 | Tcp(TcpStream), 14 | } 15 | 16 | impl From> for NntpStream { 17 | fn from(stream: TlsStream) -> Self { 18 | Self::Tls(stream) 19 | } 20 | } 21 | 22 | impl From for NntpStream { 23 | fn from(stream: TcpStream) -> NntpStream { 24 | Self::Tcp(stream) 25 | } 26 | } 27 | 28 | impl Read for NntpStream { 29 | fn read(&mut self, buf: &mut [u8]) -> io::Result { 30 | match self { 31 | NntpStream::Tls(s) => s.read(buf), 32 | NntpStream::Tcp(s) => s.read(buf), 33 | } 34 | } 35 | } 36 | 37 | impl Write for NntpStream { 38 | fn write(&mut self, buf: &[u8]) -> io::Result { 39 | match self { 40 | NntpStream::Tls(s) => s.write(buf), 41 | NntpStream::Tcp(s) => s.write(buf), 42 | } 43 | } 44 | 45 | fn flush(&mut self) -> io::Result<()> { 46 | match self { 47 | NntpStream::Tls(s) => s.flush(), 48 | NntpStream::Tcp(s) => s.flush(), 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/types/mod.rs: -------------------------------------------------------------------------------- 1 | /// Traits and types for NNTP commands 2 | /// 3 | /// The [`NntpCommand`](command::NntpCommand) trait can be used to implement commands not (yet) 4 | /// provided by Brokaw. 5 | /// 6 | /// Brokaw provides implementations for most of the commands 7 | /// in [RFC 3977](https://tools.ietf.org/html/rfc3977). 8 | /// 9 | /// One notable exception is the [`LISTGROUP`](https://tools.ietf.org/html/rfc3977#section-6.1.2) 10 | /// command. This command is left unimplemented as it does not adhere to the response standards 11 | /// defined in the RFC. 12 | pub mod command; 13 | 14 | /// Typed NNTP responses for individual commands 15 | pub mod response; 16 | 17 | /// NNTP response codes 18 | pub mod response_code; 19 | 20 | /// The number of an article relative to a specific Newsgroup 21 | /// 22 | /// Per [RFC 3977](https://tools.ietf.org/html/rfc3977#section-6) article numbers should fit within 23 | /// 31-bits but has been surpassed since. 24 | pub type ArticleNumber = u64; 25 | 26 | /// Re-exports of traits and response types 27 | pub mod prelude { 28 | pub use crate::raw::response::{DataBlocks, RawResponse}; 29 | 30 | pub use super::command::NntpCommand; 31 | pub use super::response::*; 32 | pub use super::response_code::*; 33 | pub use super::ArticleNumber; 34 | } 35 | 36 | #[doc(inline)] 37 | pub use command::NntpCommand; 38 | 39 | #[doc(inline)] 40 | pub use response::*; 41 | 42 | #[doc(inline)] 43 | pub use response_code::*; 44 | -------------------------------------------------------------------------------- /src/types/response/group.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | 3 | use crate::error::{Error, Result}; 4 | use crate::types::prelude::*; 5 | use crate::types::response::util::{err_if_not_kind, parse_field}; 6 | 7 | /// Newsgroup metadata returned by [`GROUP`](https://tools.ietf.org/html/rfc3977#section-6.1.1) 8 | #[derive(Clone, Debug, Eq, PartialEq)] 9 | pub struct Group { 10 | /// The _estimated_ number of articles in the group 11 | pub number: ArticleNumber, 12 | /// The lowest reported article number 13 | pub low: ArticleNumber, 14 | /// The highest reported article number 15 | pub high: ArticleNumber, 16 | /// The name of the group 17 | pub name: String, 18 | } 19 | 20 | impl TryFrom<&RawResponse> for Group { 21 | type Error = Error; 22 | 23 | fn try_from(resp: &RawResponse) -> Result { 24 | err_if_not_kind(resp, Kind::GroupSelected)?; 25 | 26 | let lossy = resp.first_line_to_utf8_lossy(); 27 | let mut iter = lossy.split_whitespace(); 28 | 29 | // pop the response code 30 | iter.next() 31 | .ok_or_else(|| Error::missing_field("response code"))?; 32 | 33 | let number = parse_field(&mut iter, "number")?; 34 | let low = parse_field(&mut iter, "low")?; 35 | let high = parse_field(&mut iter, "high")?; 36 | let name = parse_field(&mut iter, "name")?; 37 | Ok(Self { 38 | number, 39 | low, 40 | high, 41 | name, 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny( 2 | missing_copy_implementations, 3 | missing_debug_implementations, 4 | missing_docs, 5 | rust_2018_idioms, 6 | unconditional_recursion 7 | )] 8 | 9 | //! 🗞 Brokaw is a NNTP (Usenet) library 10 | //! 11 | //! # APIs 12 | //! 13 | //! Brokaw provides two primary APIs for interacting with NNTP servers: 14 | //! 15 | //! 1. The [`NntpClient`] provides a higher-level that provides a a config 16 | //! based builder and automatic deserialization of responses into different types. 17 | //! 2. The [`NntpConnection`] provides a lower-level abstraction that 18 | //! only provides validation that messages adhere to NNTP's wire format. 19 | //! 20 | //! Brokaw additionally provides strongly typed [commands](types::command), 21 | //! [responses](types::response), and the [`NntpCommand`](types::NntpCommand) 22 | //! trait for implementing your own strongly typed commands. 23 | //! 24 | //! --- 25 | //! 26 | //! Please check out the [git repository](https://github.com/sgg/brokaw) examples. 27 | 28 | #[cfg(doctest)] 29 | doc_comment::doctest!("../README.md"); 30 | 31 | /// The high-level client and configuration API 32 | pub mod client; 33 | 34 | /// Error and Result types returned by the Brokaw 35 | pub mod error; 36 | 37 | /// Low level connection/stream APIs 38 | /// 39 | /// These deal with raw NNTP connections and byte responses. 40 | /// Consider using the higher level [`client`] APIs unless you have special requirements 41 | pub mod raw; 42 | 43 | /// Typed commands, responses, and response codes 44 | pub mod types; 45 | 46 | #[doc(inline)] 47 | pub use client::{ClientConfig, NntpClient}; 48 | #[doc(inline)] 49 | pub use raw::connection::{ConnectionConfig, NntpConnection}; 50 | #[doc(inline)] 51 | pub use raw::Compression; 52 | -------------------------------------------------------------------------------- /src/types/response/util.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use log::*; 4 | 5 | use crate::error::{Error, Result}; 6 | use crate::types::prelude::*; 7 | 8 | /// Parse a generic field from the first line of an NNTP Response 9 | /// 10 | /// 1. The provided field name will be used in the error message if parsing fails 11 | /// 2. This will advance the provided iterator 12 | pub(crate) fn parse_field<'a, T: FromStr>( 13 | iter: &mut impl Iterator, 14 | name: impl AsRef, 15 | ) -> Result { 16 | let name = name.as_ref(); 17 | iter.next() 18 | .ok_or_else(|| Error::missing_field(name)) 19 | .and_then(|s| s.parse().map_err(|_| Error::parse_error(name))) 20 | } 21 | 22 | /// Return a deserialization error if the response does match the desired error code 23 | pub(crate) fn err_if_not_kind(resp: &RawResponse, desired: Kind) -> Result<()> { 24 | if resp.code != ResponseCode::Known(desired) { 25 | Err(Error::Deserialization(format!( 26 | "Invalid response code {}", 27 | resp.code() 28 | ))) 29 | } else { 30 | Ok(()) 31 | } 32 | } 33 | 34 | pub(crate) fn process_article_first_line(resp: &RawResponse) -> Result<(ArticleNumber, String)> { 35 | let lossy = resp.first_line_to_utf8_lossy(); 36 | let mut iter = lossy.split_whitespace(); 37 | 38 | iter.next(); // skip response code since we already parsed it 39 | 40 | let number: ArticleNumber = parse_field(&mut iter, "article-number")?; 41 | // https://tools.ietf.org/html/rfc3977#section-9.8 42 | let message_id: String = parse_field(&mut iter, "message-id")?; 43 | 44 | trace!( 45 | "Parsed article-number {} and message-id {} from Article", 46 | number, 47 | message_id 48 | ); 49 | 50 | Ok((number, message_id)) 51 | } 52 | -------------------------------------------------------------------------------- /src/types/command/rfc2980.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::types::prelude::{ArticleNumber, NntpCommand}; 4 | 5 | /// Retrieve a specific header from one or more articles 6 | #[derive(Clone, Debug)] 7 | pub enum XHdr { 8 | /// A single message 9 | MessageId { 10 | /// The name of the header to retrieve 11 | header: String, 12 | /// The message ID of the article 13 | id: String, 14 | }, 15 | /// A range of messages 16 | Range { 17 | /// The name of the header to retrieve 18 | header: String, 19 | /// The low number of the article range 20 | low: ArticleNumber, 21 | /// The high number of the article range 22 | high: ArticleNumber, 23 | }, 24 | } 25 | 26 | impl fmt::Display for XHdr { 27 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 28 | match self { 29 | XHdr::MessageId { header, id } => write!(f, "XHDR {} {}", header, id), 30 | XHdr::Range { header, low, high } => write!(f, "XHDR {} {}-{}", header, low, high), 31 | } 32 | } 33 | } 34 | 35 | /// Get the headers for one or more articles 36 | #[derive(Copy, Clone, Debug)] 37 | pub enum XOver { 38 | /// A range of messages 39 | Range { 40 | /// The low number of the article range 41 | low: ArticleNumber, 42 | /// The high number of the article range 43 | high: ArticleNumber, 44 | }, 45 | /// The current message 46 | Current, 47 | } 48 | 49 | impl fmt::Display for XOver { 50 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 51 | match self { 52 | XOver::Range { low, high } => write!(f, "XOVER {}-{}", low, high), 53 | XOver::Current => write!(f, "XOVER"), 54 | } 55 | } 56 | } 57 | 58 | impl NntpCommand for XOver {} 59 | -------------------------------------------------------------------------------- /examples/connect_to_group.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use log::*; 4 | 5 | use brokaw::client::ClientConfig; 6 | use brokaw::ConnectionConfig; 7 | use structopt::StructOpt; 8 | 9 | /// Connect to a server and get the info for a specified group 10 | /// 11 | /// This example utilizes the high-level client API 12 | #[derive(Debug, StructOpt)] 13 | struct Opt { 14 | #[structopt(long, short)] 15 | address: String, 16 | #[structopt(long, short, default_value = "563")] 17 | port: u16, 18 | #[structopt(long, short)] 19 | group: String, 20 | #[structopt(long)] 21 | no_tls: bool, 22 | #[structopt(long, short)] 23 | username: String, 24 | } 25 | 26 | fn main() -> Result<(), Box> { 27 | env_logger::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 28 | 29 | let Opt { 30 | address, 31 | port, 32 | group, 33 | no_tls: _, 34 | username, 35 | } = Opt::from_args(); 36 | 37 | let password = rpassword::prompt_password_stderr("Password: ")?; 38 | 39 | info!("Creating config..."); 40 | 41 | let config = { 42 | let mut config = ClientConfig::default(); 43 | 44 | config 45 | .authinfo_user_pass(username, password) 46 | .group(Some(group)) 47 | .connection_config( 48 | ConnectionConfig::default() 49 | .read_timeout(Some(Duration::from_secs(5))) 50 | .default_tls(&address)? 51 | .to_owned(), 52 | ); 53 | 54 | config 55 | }; 56 | 57 | info!("Connecting..."); 58 | let mut client = config.connect((address.as_str(), port))?; 59 | 60 | info!("Connected!"); 61 | info!("Capabilities: {:#?}", client.capabilities()); 62 | 63 | info!("Group info: {:?}", client.group()); 64 | 65 | info!("Closing connection..."); 66 | client.close()?; 67 | info!("Closed connection!"); 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /tests/text_article: -------------------------------------------------------------------------------- 1 | 220 47661 2 | X-Received: by 2002:ac8:2aed:: with SMTP id c42mr5587158qta.202.1591290821135; 3 | Thu, 04 Jun 2020 10:13:41 -0700 (PDT) 4 | X-Received: by 2002:a25:c186:: with SMTP id r128mr10257992ybf.92.1591290820872; 5 | Thu, 04 Jun 2020 10:13:40 -0700 (PDT) 6 | Path: buffer1.nntp.dca1.giganews.com!border2.nntp.dca1.giganews.com!nntp.giganews.com!news-out.google.com!nntp.google.com!postnews.google.com!google-groups.googlegroups.com!not-for-mail 7 | Newsgroups: mozilla.dev.platform 8 | Date: Thu, 4 Jun 2020 10:13:40 -0700 (PDT) 9 | Complaints-To: groups-abuse@google.com 10 | Injection-Info: google-groups.googlegroups.com; posting-host=2403:5800:7300:6300:3d06:ae8:c1a4:c55; 11 | posting-account=B5D9HgoAAADisMxwUaQMp2rcoV8ZGukv 12 | NNTP-Posting-Host: 2403:5800:7300:6300:3d06:ae8:c1a4:c55 13 | User-Agent: G2/1.0 14 | MIME-Version: 1.0 15 | Message-ID: 16 | Subject: Intent to deprecate: stretching MathML operators with STIXGeneral fonts 17 | From: dazabani@igalia.com 18 | Injection-Date: Thu, 04 Jun 2020 17:13:41 +0000 19 | Content-Type: text/plain; charset="UTF-8" 20 | Content-Transfer-Encoding: quoted-printable 21 | Bytes: 1972 22 | Lines: 17 23 | Xref: number.nntp.giganews.com mozilla.dev.platform:47661 24 | 25 | In bug 1630935 [1], I intend to deprecate support for drawing 26 | stretched MathML operators using the STIXGeneral fonts with a use 27 | counter, deprecation warning, and a pref to gate the feature (off by 28 | default on nightly). 29 | 30 | These fonts were a stopgap solution to a problem that has since been 31 | addressed by OpenType MATH tables. Now that OpenType MATH fonts are 32 | available, they=E2=80=99ve been deprecated upstream, and we=E2=80=99ve enco= 33 | uraged the 34 | ecosystem to switch since 2014 [2]. 35 | 36 | That support is now a special case in our codebase, causing 37 | performance problems and making it difficult to refactor our MathML 38 | operator stretching code, but we can=E2=80=99t unship without usage data, 39 | because the fonts are still preinstalled on macOS. 40 | 41 | [1] https://bugzilla.mozilla.org/show_bug.cgi?id=3D1630935 42 | [2] https://groups.google.com/d/topic/mozilla.dev.tech.mathml/PlVCil2X598 43 | . 44 | -------------------------------------------------------------------------------- /src/types/response/article/body.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | 3 | use crate::error::{Error, Result}; 4 | use crate::types::prelude::*; 5 | use crate::types::response::article::iter::*; 6 | use crate::types::response::util::{err_if_not_kind, process_article_first_line}; 7 | 8 | /// An article body returned by the [`BODY`](https://tools.ietf.org/html/rfc3977#section-6.2.3) 9 | #[derive(Clone, Debug, Eq, PartialEq)] 10 | pub struct Body { 11 | /// The number of the article unique to a particular newsgroup 12 | pub number: ArticleNumber, 13 | /// The unique message id for the article 14 | pub message_id: String, 15 | pub(crate) payload: Vec, 16 | pub(crate) line_boundaries: Vec<(usize, usize)>, 17 | } 18 | 19 | impl Body { 20 | /// The number of the article relative to the group it was retrieved from 21 | pub fn number(&self) -> ArticleNumber { 22 | self.number 23 | } 24 | 25 | /// The message id of the article 26 | pub fn message_id(&self) -> &str { 27 | &self.message_id 28 | } 29 | 30 | /// The raw contents of the body 31 | pub fn body(&self) -> &[u8] { 32 | &self.payload 33 | } 34 | 35 | /// An iterator over the lines in the body of the article 36 | pub fn lines(&self) -> Lines<'_> { 37 | Lines { 38 | payload: &self.payload, 39 | inner: self.line_boundaries.iter(), 40 | } 41 | } 42 | 43 | /// An iterator over the lines of the body without the CRLF terminators 44 | pub fn unterminated(&self) -> Unterminated<'_> { 45 | Unterminated { 46 | inner: self.lines(), 47 | } 48 | } 49 | } 50 | 51 | impl TryFrom<&RawResponse> for Body { 52 | type Error = Error; 53 | 54 | fn try_from(resp: &RawResponse) -> Result { 55 | err_if_not_kind(resp, Kind::Body)?; 56 | 57 | let (number, message_id) = process_article_first_line(&resp)?; 58 | 59 | let DataBlocks { 60 | payload, 61 | line_boundaries, 62 | } = resp 63 | .data_blocks 64 | .as_ref() 65 | .ok_or_else(Error::missing_data_blocks)? 66 | .clone(); 67 | 68 | Ok(Self { 69 | number, 70 | message_id, 71 | payload, 72 | line_boundaries, 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::str::Utf8Error; 2 | 3 | use crate::types::prelude::*; 4 | 5 | /// All of the ways that a failure can occur within Brokaw 6 | #[derive(Debug, thiserror::Error)] 7 | pub enum Error { 8 | /// This error indicates an application layer failure. 9 | /// 10 | /// For example, asking for a non-existent group will return 11 | /// [`NoSuchNewsGroup`](`crate::types::prelude::Kind::NoSuchNewsgroup`) (code 411), 12 | /// which is not a protocol error. 13 | #[error("Server returned {code:?} -- {msg:?}")] 14 | Failure { 15 | /// The response code 16 | code: ResponseCode, 17 | /// The raw response 18 | resp: RawResponse, 19 | /// An error message associated with the response 20 | msg: Option, 21 | }, 22 | #[error(transparent)] 23 | /// An error raised by the underlying connection 24 | /// 25 | /// This is usually of an I/O error or a TLS error 26 | Connection(#[from] crate::raw::error::Error), 27 | /// An error deserializing a [RawResponse] into a concrete type 28 | #[error("{0}")] 29 | Deserialization(String), 30 | /// An error deserializing bytes as UTF-8 31 | #[error("{0}")] 32 | Utf8(#[from] Utf8Error), 33 | } 34 | 35 | impl Error { 36 | pub(crate) fn failure(resp: RawResponse) -> Self { 37 | Error::Failure { 38 | code: resp.code(), 39 | resp, 40 | msg: None, 41 | } 42 | } 43 | 44 | pub(crate) fn de(msg: impl AsRef) -> Self { 45 | Error::Deserialization(msg.as_ref().to_string()) 46 | } 47 | 48 | pub(crate) fn missing_field(name: impl AsRef) -> Self { 49 | Error::Deserialization(format!("Missing field `{}`", name.as_ref())) 50 | } 51 | 52 | pub(crate) fn parse_error(name: impl AsRef) -> Self { 53 | Error::Deserialization(format!("Could not parse field `{}`", name.as_ref())) 54 | } 55 | 56 | pub(crate) fn missing_data_blocks() -> Self { 57 | Error::Deserialization("Response is missing multi-line data blocks".to_string()) 58 | } 59 | 60 | pub(crate) fn invalid_data_blocks(msg: impl AsRef) -> Self { 61 | Error::Deserialization(format!("Invalid data-block section -- {}", msg.as_ref())) 62 | } 63 | } 64 | 65 | /// A result type returned by the library 66 | pub type Result = std::result::Result; 67 | -------------------------------------------------------------------------------- /tests/mozilla.log: -------------------------------------------------------------------------------- 1 | 200 news.mozilla.org 2 | 211 47660 2 47661 mozilla.dev.platform 3 | 220 47661 4 | X-Received: by 2002:ac8:2aed:: with SMTP id c42mr5587158qta.202.1591290821135; 5 | Thu, 04 Jun 2020 10:13:41 -0700 (PDT) 6 | X-Received: by 2002:a25:c186:: with SMTP id r128mr10257992ybf.92.1591290820872; 7 | Thu, 04 Jun 2020 10:13:40 -0700 (PDT) 8 | Path: buffer1.nntp.dca1.giganews.com!border2.nntp.dca1.giganews.com!nntp.giganews.com!news-out.google.com!nntp.google.com!postnews.google.com!google-groups.googlegroups.com!not-for-mail 9 | Newsgroups: mozilla.dev.platform 10 | Date: Thu, 4 Jun 2020 10:13:40 -0700 (PDT) 11 | Complaints-To: groups-abuse@google.com 12 | Injection-Info: google-groups.googlegroups.com; posting-host=2403:5800:7300:6300:3d06:ae8:c1a4:c55; 13 | posting-account=B5D9HgoAAADisMxwUaQMp2rcoV8ZGukv 14 | NNTP-Posting-Host: 2403:5800:7300:6300:3d06:ae8:c1a4:c55 15 | User-Agent: G2/1.0 16 | MIME-Version: 1.0 17 | Message-ID: 18 | Subject: Intent to deprecate: stretching MathML operators with STIXGeneral fonts 19 | From: dazabani@igalia.com 20 | Injection-Date: Thu, 04 Jun 2020 17:13:41 +0000 21 | Content-Type: text/plain; charset="UTF-8" 22 | Content-Transfer-Encoding: quoted-printable 23 | Bytes: 1972 24 | Lines: 17 25 | Xref: number.nntp.giganews.com mozilla.dev.platform:47661 26 | 27 | In bug 1630935 [1], I intend to deprecate support for drawing 28 | stretched MathML operators using the STIXGeneral fonts with a use 29 | counter, deprecation warning, and a pref to gate the feature (off by 30 | default on nightly). 31 | 32 | These fonts were a stopgap solution to a problem that has since been 33 | addressed by OpenType MATH tables. Now that OpenType MATH fonts are 34 | available, they=E2=80=99ve been deprecated upstream, and we=E2=80=99ve enco= 35 | uraged the 36 | ecosystem to switch since 2014 [2]. 37 | 38 | That support is now a special case in our codebase, causing 39 | performance problems and making it difficult to refactor our MathML 40 | operator stretching code, but we can=E2=80=99t unship without usage data, 41 | because the fonts are still preinstalled on macOS. 42 | 43 | [1] https://bugzilla.mozilla.org/show_bug.cgi?id=3D1630935 44 | [2] https://groups.google.com/d/topic/mozilla.dev.tech.mathml/PlVCil2X598 45 | . 46 | 205 goodbye 47 | -------------------------------------------------------------------------------- /src/types/response/article/text.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::types::prelude::*; 3 | 4 | /// A text Netnews article returned by the [`ARTICLE`](https://tools.ietf.org/html/rfc3977#section-6.2.1) command 5 | /// 6 | /// Unlike [`BinaryArticle`] a `TextArticle`**MUST** have a UTF-8 body. 7 | /// 8 | /// The following methods can be used to create a `TextArticle` from a [`BinaryArticle`]: 9 | /// 10 | /// * [`from_binary`](`Self::from_binary`) is fallible as it performs UTF-8 checks 11 | /// * [`from_binary_lossy`](Self::from_binary_lossy) is infallible but will replace 12 | /// non UTF-8 characters with placeholders. Please see [`String::from_utf8_lossy`] for more info. 13 | #[derive(Clone, Debug, Eq, PartialEq)] 14 | pub struct TextArticle { 15 | pub(crate) number: ArticleNumber, 16 | pub(crate) message_id: String, 17 | pub(crate) headers: Headers, 18 | pub(crate) body: Vec, 19 | } 20 | 21 | impl TextArticle { 22 | /// The number of the article relative to the group it was retrieved from 23 | pub fn number(&self) -> ArticleNumber { 24 | self.number 25 | } 26 | 27 | /// The message id of the article 28 | pub fn message_id(&self) -> &str { 29 | &self.message_id 30 | } 31 | 32 | /// The headers on the article 33 | pub fn headers(&self) -> &Headers { 34 | &self.headers 35 | } 36 | 37 | /// Return the body of the article 38 | pub fn body(&self) -> &[String] { 39 | self.body.as_slice() 40 | } 41 | 42 | /// Create a text article from a 43 | pub fn from_binary(b: &BinaryArticle) -> Result { 44 | b.to_text() 45 | } 46 | 47 | /// Create a text article from a binary one, 48 | /// replacing invalid UTF-8 characters with placeholders 49 | pub fn from_binary_lossy(b: &BinaryArticle) -> Self { 50 | b.to_text_lossy() 51 | } 52 | 53 | /// An iterator over the lines in the body of the article 54 | /// 55 | /// Each line _will not_ include the CRLF terminator 56 | pub fn lines(&self) -> Lines<'_> { 57 | Lines(self.body.iter()) 58 | } 59 | } 60 | 61 | /// Created with [`TextArticle::lines`] 62 | #[derive(Clone, Debug)] 63 | pub struct Lines<'a>(std::slice::Iter<'a, String>); 64 | 65 | impl<'a> Iterator for Lines<'a> { 66 | type Item = &'a String; 67 | 68 | fn next(&mut self) -> Option { 69 | self.0.next() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/types/command/mod.rs: -------------------------------------------------------------------------------- 1 | /// A data-structure that represents an NNTP command 2 | /// 3 | /// All `NntpCommands` must implement [`Encode`] such that 4 | /// [`encode`](Encode::encode) returns the bytes that should be sent over the wire. 5 | /// 6 | /// If the command can be represented using UTF-8, one can simply implement [`ToString`] 7 | /// (directly or via [`fmt::Display`](std::fmt::Display) as [`Encode`] is automatically implemented for 8 | /// types that implement [`ToString`]. 9 | /// 10 | /// # Example: Implementing LISTGROUP 11 | /// ``` 12 | /// use std::fmt; 13 | /// use brokaw::types::command::NntpCommand; 14 | /// 15 | /// #[derive(Clone, Debug)] 16 | /// pub struct ListGroup { 17 | /// group: Option, 18 | /// range: Option<(u32, u32)>, 19 | /// } 20 | /// 21 | /// impl fmt::Display for ListGroup { 22 | /// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 23 | /// write!(f, "LISTGROUP")?; 24 | /// 25 | /// if let Some(group) = &self.group { 26 | /// write!(f, " {}", &group)?; 27 | /// } 28 | /// 29 | /// if let Some((low, high)) = &self.range { 30 | /// write!(f, " {}-{}", low, high)?; 31 | /// } 32 | /// Ok(()) 33 | /// } 34 | /// } 35 | /// 36 | /// impl NntpCommand for ListGroup {} 37 | /// 38 | /// let cmd = ListGroup { 39 | /// group: Some("misc.test".to_string()), 40 | /// range: Some((10, 20)) 41 | /// }; 42 | /// 43 | /// assert_eq!(cmd.to_string(), "LISTGROUP misc.test 10-20") 44 | /// ``` 45 | pub trait NntpCommand: Encode {} 46 | 47 | /// A type that can be serialized for transmission 48 | /// 49 | /// A blanket implementation is provided for types implementing [`ToString`]. 50 | pub trait Encode { 51 | /// Return a vector of bytes that can be sent to an NNTP server 52 | fn encode(&self) -> Vec; 53 | } 54 | 55 | impl Encode for T { 56 | fn encode(&self) -> Vec { 57 | self.to_string().into() 58 | } 59 | } 60 | 61 | /// Commands specified in [RFC 3977](https://tools.ietf.org/html/rfc3977#appendix-B) 62 | mod rfc3977; 63 | 64 | #[doc(inline)] 65 | pub use rfc3977::*; 66 | 67 | /// Commands specified in [RFC 2980](https://tools.ietf.org/html/rfc2980) 68 | mod rfc2980; 69 | 70 | #[doc(inline)] 71 | pub use rfc2980::*; 72 | 73 | /// AUTHINFO commands specified in [RFC 4643](https://tools.ietf.org/html/rfc4643) 74 | mod rfc4643; 75 | 76 | #[doc(inline)] 77 | pub use rfc4643::*; 78 | 79 | mod xfeature; 80 | 81 | #[doc(inline)] 82 | pub use xfeature::*; 83 | -------------------------------------------------------------------------------- /examples/xfeature_compression.rs: -------------------------------------------------------------------------------- 1 | use brokaw::types::command::{XFeatureCompress, XOver}; 2 | 3 | use brokaw::types::ArticleNumber; 4 | use brokaw::*; 5 | use log::*; 6 | use structopt::StructOpt; 7 | 8 | /// A program for getting compressed headers 9 | #[derive(Debug, StructOpt)] 10 | struct Opt { 11 | #[structopt(long, short)] 12 | address: String, 13 | #[structopt(long, short, default_value = "563")] 14 | port: u16, 15 | /// The group to read the headers from 16 | #[structopt(long, short)] 17 | group: String, 18 | /// The number of headers to retrieve 19 | #[structopt(long, short)] 20 | num_headers: ArticleNumber, 21 | #[structopt(long)] 22 | username: String, 23 | #[structopt(long)] 24 | password: Option, 25 | } 26 | 27 | fn main() -> anyhow::Result<()> { 28 | env_logger::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 29 | 30 | let Opt { 31 | address, 32 | port, 33 | group, 34 | num_headers, 35 | username, 36 | password, 37 | } = Opt::from_args(); 38 | 39 | let password = if let Some(pw) = password { 40 | pw 41 | } else { 42 | rpassword::prompt_password_stderr("Password: ")? 43 | }; 44 | 45 | info!("Creating client..."); 46 | 47 | let mut client = ClientConfig::default() 48 | .group(Some(group.clone())) 49 | .authinfo_user_pass(username, password) 50 | .connection_config( 51 | ConnectionConfig::new() 52 | .compression(Some(Compression::XFeature)) 53 | .default_tls(&address)? 54 | .to_owned(), 55 | ) 56 | .connect((address.as_str(), port))?; 57 | 58 | let group = client.group().unwrap().to_owned(); 59 | 60 | info!( 61 | "Group {name} has a {number} headers ranging from {low} to {high}", 62 | name = group.name, 63 | low = group.low, 64 | high = group.high, 65 | number = group.number 66 | ); 67 | 68 | info!("Enabling header compression"); 69 | client.command(XFeatureCompress)?.fail_unless(290)?; 70 | 71 | let high = group.high; 72 | let low = high - num_headers; 73 | info!("Retrieving headers {} through {}", low, high); 74 | let resp = client.conn().command(&XOver::Range { low, high })?; 75 | resp.data_blocks().unwrap().lines().for_each(|header| { 76 | let s = String::from_utf8_lossy(header).to_string(); 77 | println!("{}", s); 78 | }); 79 | 80 | Ok(()) 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Brokaw

2 |
3 | 📰 A Usenet/NNTP library. More at 11! 📰 4 |
5 | 6 |
7 | 8 |
9 | 10 | 11 | GitHub Actions 13 | 14 | 15 | 16 | Crates.io version 18 | 19 | 20 | 21 | docs.rs docs 23 | 24 |
25 | 26 | 27 | Brokaw is a typed Usenet library for the dozens of people still reading Netnews. It is very much in development and provides **no guarantees about stability**. 28 | 29 | Brokaw (mostly) implements [RFC 3977](https://tools.ietf.org/html/rfc3977) and several popular extensions. 30 | 31 | ## Getting Started 32 | 33 | ```toml 34 | [dependencies] 35 | brokaw = "*" 36 | ``` 37 | 38 | ```rust 39 | use brokaw::client::ClientConfig; 40 | 41 | fn main() -> Result<(), Box> { 42 | let client = ClientConfig::default().connect(("news.mozilla.org", 119))?; 43 | 44 | client.capabilities().iter() 45 | .for_each(|c| println!("{}", c)); 46 | 47 | Ok(()) 48 | } 49 | ``` 50 | 51 | Check out in the repo [the examples](./examples) as well! 52 | 53 | ## Features 54 | 55 | * TLS (aka `NNTPS`) courtesy of [`native-tls`](https://crates.io/crates/native-tls) 56 | * A high-level client API (`NntpClient`) for simple interactions with news servers 57 | * A low-level connection API (`NntpConnection`) for more specialized use cases 58 | * `AUTHINFO USER/PASS` Authentication ([RFC 4643] 59 | * Typed commands and responses 60 | * ~All~ Most commands in [RFC 3977] (`POST`, `NEWGROUP`, `NEWNEWS`, and `LISTGROUP` have yet to be implemented) 61 | 62 | ## Missing Features 63 | 64 | * Compression (RFC 8054, Astraweb, Giganews, etc) 65 | * STARTTLS ([RFC 4642](https://tools.ietf.org/html/rfc4642)) 66 | * SASL Authentication ([RFC 4643]) 67 | * Most of [RFC 2980]. `XHDR` and `XOVER` are supported 68 | * Connection pools, fine grained connection tuning 69 | * Async connection/client 70 | * Article posting 71 | 72 | [RFC 2980]: (https://tools.ietf.org/html/rfc4643) 73 | [RFC 3977]: https://tools.ietf.org/html/rfc3977 74 | [RFC 4642]: https://tools.ietf.org/html/rfc4642 75 | [RFC 4643]: (https://tools.ietf.org/html/rfc4643) 76 | -------------------------------------------------------------------------------- /src/types/response/article/headers.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{hash_map, HashMap}; 2 | use std::convert::TryFrom; 3 | 4 | use crate::error::{Error, Result}; 5 | use crate::raw::response::RawResponse; 6 | use crate::types::prelude::*; 7 | use crate::types::response::article::parse::take_headers; 8 | use crate::types::response::util::{err_if_not_kind, process_article_first_line}; 9 | 10 | /// Netnews article headers 11 | /// 12 | /// Note that per [RFC 5322](https://tools.ietf.org/html/rfc5322#section-3.6) headers 13 | /// may be repeated (a common example is X-Received for emails mirrored onto Newsgroups) 14 | #[derive(Clone, Debug, Eq, PartialEq)] 15 | pub struct Headers { 16 | pub(crate) inner: HashMap, 17 | pub(crate) len: u32, 18 | } 19 | 20 | /// An individual header within a [`Headers`] collection 21 | #[derive(Clone, Debug, Eq, PartialEq)] 22 | pub struct Header { 23 | /// The name of the header 24 | pub name: String, 25 | /// One-or-more content values for the header 26 | pub content: Vec, 27 | } 28 | 29 | impl Headers { 30 | /// The total number of headers 31 | /// 32 | /// Note that this may be _more than_ the number of keys as headers may be repeated 33 | pub fn len(&self) -> usize { 34 | self.len as _ 35 | } 36 | 37 | /// Returns true if there are no headers 38 | pub fn is_empty(&self) -> bool { 39 | self.inner.is_empty() 40 | } 41 | 42 | /// Get a header by name 43 | pub fn get(&self, key: impl AsRef) -> Option<&Header> { 44 | self.inner.get(key.as_ref()) 45 | } 46 | 47 | /// An iterator over the headers 48 | pub fn iter(&self) -> Iter<'_> { 49 | Iter { 50 | inner: self.inner.values(), 51 | } 52 | } 53 | } 54 | 55 | #[derive(Clone, Debug)] 56 | pub struct Iter<'a> { 57 | inner: hash_map::Values<'a, String, Header>, 58 | } 59 | 60 | impl<'a> Iterator for Iter<'a> { 61 | type Item = &'a Header; 62 | 63 | fn next(&mut self) -> Option { 64 | self.inner.next() 65 | } 66 | } 67 | 68 | /// Article headers returned by [`HEAD`](https://tools.ietf.org/html/rfc3977#section-6.2.2) 69 | #[derive(Clone, Debug, Eq, PartialEq)] 70 | pub struct Head { 71 | /// The number of the article unique to a particular newsgroup 72 | pub number: ArticleNumber, 73 | /// The unique message id for the article 74 | pub message_id: String, 75 | /// The headers for the article 76 | pub headers: Headers, 77 | } 78 | 79 | impl TryFrom<&RawResponse> for Head { 80 | type Error = Error; 81 | 82 | fn try_from(resp: &RawResponse) -> Result { 83 | err_if_not_kind(resp, Kind::Head)?; 84 | 85 | let (number, message_id) = process_article_first_line(&resp)?; 86 | 87 | let data_blocks = resp 88 | .data_blocks 89 | .as_ref() 90 | .ok_or_else(Error::missing_data_blocks)?; 91 | 92 | let (_, headers) = take_headers(&data_blocks.payload()) 93 | .map_err(|e| Error::invalid_data_blocks(format!("{}", e)))?; 94 | 95 | Ok(Self { 96 | number, 97 | message_id, 98 | headers, 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/types/response/capabilities.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{hash_map, HashMap, HashSet}; 2 | use std::convert::TryFrom; 3 | use std::fmt; 4 | 5 | use crate::error::{Error, Result}; 6 | use crate::types::prelude::*; 7 | use crate::types::response::util::err_if_not_kind; 8 | 9 | /// Server capabilities 10 | #[derive(Clone, Debug, Eq, PartialEq)] 11 | pub struct Capabilities(HashMap); 12 | 13 | /// A capability advertised by the server 14 | #[derive(Clone, Debug, Eq, PartialEq)] 15 | pub struct Capability { 16 | pub name: String, 17 | pub args: Option>, 18 | } 19 | 20 | impl Capabilities { 21 | /// An iterator over the capabilities 22 | pub fn iter(&self) -> Iter<'_> { 23 | Iter { 24 | inner: self.0.values(), 25 | } 26 | } 27 | 28 | /// Retrieve a capability if it exists 29 | pub fn get(&self, key: impl AsRef) -> Option<&Capability> { 30 | self.0.get(key.as_ref()) 31 | } 32 | } 33 | 34 | impl fmt::Display for Capability { 35 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 36 | write!(f, "{}", self.name)?; 37 | if let Some(args) = self.args.as_ref() { 38 | args.iter() 39 | .map(|arg| { 40 | write!(f, " {}", arg)?; 41 | Ok::<_, fmt::Error>(()) 42 | }) 43 | .for_each(drop); 44 | } 45 | 46 | Ok(()) 47 | } 48 | } 49 | 50 | /// Created by [`Capabilities::iter`] 51 | #[derive(Clone, Debug)] 52 | pub struct Iter<'a> { 53 | inner: hash_map::Values<'a, String, Capability>, 54 | } 55 | 56 | impl<'a> Iterator for Iter<'a> { 57 | type Item = &'a Capability; 58 | 59 | fn next(&mut self) -> Option { 60 | self.inner.next() 61 | } 62 | } 63 | 64 | impl TryFrom<&RawResponse> for Capabilities { 65 | type Error = Error; 66 | 67 | /// Parse capabilities from a response 68 | /// 69 | /// The specific format is taken from [RFC 3977](https://tools.ietf.org/html/rfc3977#section-9.5) 70 | fn try_from(resp: &RawResponse) -> Result { 71 | err_if_not_kind(resp, Kind::Capabilities)?; 72 | 73 | let db_iter = resp 74 | .data_blocks 75 | .as_ref() 76 | .ok_or_else(|| Error::de("Missing data blocks.")) 77 | .map(DataBlocks::unterminated)?; 78 | 79 | let capabilities: HashMap = db_iter 80 | .map(String::from_utf8_lossy) 81 | .map(|entry| { 82 | let mut entry_iter = entry.split_whitespace().peekable(); 83 | let label = entry_iter 84 | .next() 85 | .map(ToString::to_string) 86 | .ok_or_else(|| Error::de("Entry does not have a label"))?; 87 | 88 | let args = if entry_iter.peek().is_some() { 89 | Some(entry_iter.map(ToString::to_string).collect::>()) 90 | } else { 91 | None 92 | }; 93 | 94 | let cap = Capability { 95 | name: label.clone(), 96 | args, 97 | }; 98 | 99 | Ok((label, cap)) 100 | }) 101 | .collect::>()?; 102 | 103 | Ok(Self(capabilities)) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/raw/compression.rs: -------------------------------------------------------------------------------- 1 | use std::io::{BufRead, BufReader, Read}; 2 | 3 | use flate2::bufread::ZlibDecoder; 4 | use std::io; 5 | 6 | /// A type of compression enabled on the server 7 | #[derive(Copy, Clone, Debug)] 8 | pub enum Compression { 9 | /// Giganews style compression 10 | XFeature, 11 | } 12 | 13 | /// An codec that can unpack compressed data streams 14 | #[derive(Debug)] 15 | pub(crate) enum Decoder { 16 | XFeature(BufReader>), 17 | Passthrough(S), 18 | } 19 | 20 | impl Compression { 21 | pub(crate) fn use_decoder(&self, first_line: impl AsRef<[u8]>) -> bool { 22 | match self { 23 | Self::XFeature => first_line.as_ref().ends_with(b"[COMPRESS=GZIP]\r\n"), 24 | } 25 | } 26 | 27 | pub(crate) fn decoder(&self, stream: S) -> Decoder { 28 | match self { 29 | Self::XFeature => Decoder::XFeature(BufReader::new(ZlibDecoder::new(stream))), 30 | } 31 | } 32 | } 33 | 34 | impl Read for Decoder { 35 | fn read(&mut self, buf: &mut [u8]) -> io::Result { 36 | match self { 37 | Decoder::XFeature(d) => d.read(buf), 38 | Decoder::Passthrough(s) => s.read(buf), 39 | } 40 | } 41 | } 42 | 43 | impl BufRead for Decoder { 44 | fn fill_buf(&mut self) -> io::Result<&[u8]> { 45 | match self { 46 | Decoder::XFeature(d) => d.fill_buf(), 47 | Decoder::Passthrough(s) => s.fill_buf(), 48 | } 49 | } 50 | 51 | fn consume(&mut self, amt: usize) { 52 | match self { 53 | Decoder::XFeature(d) => d.consume(amt), 54 | Decoder::Passthrough(s) => s.consume(amt), 55 | } 56 | } 57 | } 58 | 59 | /* 60 | In theory if we wanted to implement extensible compression we could replace Decoder and 61 | Compression objects w/ traits. That said it didn't seem necessary given the slow moving 62 | nature of the NNTP standard. If users ask for this we can always revisit it. 63 | */ 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use super::*; 68 | 69 | #[test] 70 | fn test_use_decoder() { 71 | assert!( 72 | Compression::XFeature.use_decoder("224 xover information follows [COMPRESS=GZIP]\r\n") 73 | ); 74 | assert!(!Compression::XFeature.use_decoder("224 xover information follows [COMPRESS=GZIP]")) 75 | } 76 | 77 | #[test] 78 | fn test_compressed() { 79 | let compressed_resp = include_bytes!(concat!( 80 | env!("CARGO_MANIFEST_DIR"), 81 | "/tests/xover_resp_xfeature_compress" 82 | )); 83 | let plain_resp = include_bytes!(concat!( 84 | env!("CARGO_MANIFEST_DIR"), 85 | "/tests/xover_resp_plain_text" 86 | )); 87 | 88 | let line_boundary = compressed_resp 89 | .iter() 90 | .enumerate() 91 | .find(|(_i, &byte)| byte == b'\n') 92 | .map(|(i, _)| i) 93 | .unwrap(); 94 | 95 | let (first_line, data_blocks) = ( 96 | &compressed_resp[..line_boundary + 1], 97 | &compressed_resp[line_boundary + 1..], 98 | ); 99 | 100 | assert!(Compression::XFeature.use_decoder(first_line)); 101 | 102 | let mut decoder = Compression::XFeature.decoder(&data_blocks[..]); 103 | let mut buf = String::new(); 104 | decoder.read_to_string(&mut buf).unwrap(); 105 | assert_eq!(buf, String::from_utf8(plain_resp.to_vec()).unwrap()) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /examples/overviews.rs: -------------------------------------------------------------------------------- 1 | // FIXME(examples) fix this example 2 | 3 | /* 4 | use std::fs::read_to_string; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use anyhow::Result; 8 | use brokaw::raw::connection::{NntpConnection, TlsConfig}; 9 | use brokaw::raw::response::RawResponse; 10 | use brokaw::types::command::*; 11 | use log::*; 12 | use native_tls::TlsConnector; 13 | use std::time::Duration; 14 | use structopt::StructOpt; 15 | 16 | #[derive(Debug, StructOpt)] 17 | struct Opt { 18 | #[structopt(long, short)] 19 | address: String, 20 | #[structopt(long, short, default_value = "563")] 21 | port: u16, 22 | #[structopt(long, short)] 23 | group: String, 24 | #[structopt(long, parse(from_os_str))] 25 | auth_file: PathBuf, 26 | #[structopt(long)] 27 | no_tls: bool, 28 | #[structopt(subcommand)] 29 | cmd: Cmd, 30 | } 31 | 32 | #[derive(Clone, Debug, StructOpt)] 33 | enum Cmd { 34 | Xover { 35 | #[structopt(short, long)] 36 | low: u64, 37 | #[structopt(short, long)] 38 | high: u64, 39 | #[structopt(short, long, parse(from_os_str))] 40 | out: Option, 41 | }, 42 | Group, 43 | } 44 | 45 | fn read_auth_file(path: impl AsRef) -> Result<(String, String)> { 46 | let auth = read_to_string(path)?; 47 | let auth = auth.split(":").collect::>(); 48 | 49 | let username = auth[0]; 50 | let password = auth[1]; 51 | 52 | Ok((username.to_owned(), password.to_owned())) 53 | } 54 | 55 | fn run_cmd( 56 | conn: &mut NntpConnection, 57 | command: impl NntpCommand, 58 | print_resp: bool, 59 | ) -> Result { 60 | warn!("Sending -- {}", command); 61 | let resp = conn.command(&command)?; 62 | 63 | if resp.code().is_error() || resp.code().is_failure() { 64 | panic!("Failure -- {:?}", resp.code()) 65 | } 66 | 67 | if print_resp { 68 | info!("{}", resp.first_line_as_utf8()?); 69 | } 70 | Ok(resp) 71 | } 72 | 73 | fn login(conn: &mut NntpConnection, username: &str, password: &str) -> Result<()> { 74 | run_cmd(conn, AuthInfo::User(username.to_string()), true)?; 75 | run_cmd(conn, AuthInfo::Pass(password.to_string()), true)?; 76 | Ok(()) 77 | } 78 | 79 | fn main() -> Result<(), Box> { 80 | env_logger::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 81 | 82 | let opt = Opt::from_args(); 83 | let Opt { 84 | address, 85 | port, 86 | group, 87 | auth_file, 88 | no_tls, 89 | cmd, 90 | } = &opt; 91 | 92 | let tls_config = if !no_tls { 93 | debug!("Creating TLS configuration"); 94 | Some(TlsConfig::new(address.clone(), TlsConnector::new()?)) 95 | } else { 96 | warn!("TLS is disabled! Your creds will be sent in the clear!"); 97 | None 98 | }; 99 | 100 | debug!("Connecting to {} on port {}", address, port); 101 | let (mut conn, _) = NntpConnection::connect( 102 | (address.as_str(), *port), 103 | tls_config, 104 | Duration::from_secs(5).into(), 105 | )?; 106 | debug!("Connected!"); 107 | 108 | let (username, password) = read_auth_file(&auth_file)?; 109 | 110 | login(&mut conn, &username, &password); 111 | 112 | match cmd.clone() { 113 | Cmd::Xover { low, high, out } => { 114 | run_cmd(&mut conn, Group(group.clone()), true); 115 | let _overview = run_cmd(&mut conn, XOver::Range { low, high }, false)?; 116 | info!("XOVER COMPLETE"); 117 | if let Some(path) = out { 118 | info!("Writing overviews to file `{}`", path.display()); 119 | unimplemented!() 120 | } 121 | } 122 | Cmd::Group => { 123 | run_cmd(&mut conn, Group(group.clone()), true); 124 | } 125 | } 126 | Ok(()) 127 | } 128 | */ 129 | fn main() { 130 | unimplemented!() 131 | } 132 | -------------------------------------------------------------------------------- /tests/innd/root/etc/news/readers.conf: -------------------------------------------------------------------------------- 1 | ## $Id: readers.conf 7828 2008-05-07 07:58:22Z iulius $ 2 | ## 3 | ## readers.conf - Access control and configuration for nnrpd 4 | ## 5 | ## Format: 6 | ## auth "" { 7 | ## hosts: "" 8 | ## auth: "" 9 | ## res: "" 10 | ## default: "" 11 | ## default-domain: "" 12 | ## } 13 | ## access "" { 14 | ## users: "" 15 | ## newsgroups: "" 16 | ## read: "" 17 | ## post: "" 18 | ## access: "" 19 | ## } 20 | ## 21 | ## Other parameters are possible. See readers.conf(5) for all the 22 | ## details. Only one of newsgroups or read/post may be used in a single 23 | ## access group. 24 | ## 25 | ## If the connecting host is not matched by any hosts: parameter of any 26 | ## auth group, it will be denied access. auth groups assign an identity 27 | ## string to connections, access groups grant privileges to identity 28 | ## strings matched by their users: parameters. 29 | ## 30 | ## In all cases, the last match found is used, so put defaults first. 31 | ## 32 | ## For a news server that allows connections from anyone within a 33 | ## particular domain or IP address range, just uncomment the "local" auth 34 | ## group and the "local" access group below and adjust the hosts: and 35 | ## default: parameters of the auth group and the users: parameter of the 36 | ## access group for your local network and domain name. That's all there 37 | ## is to it. 38 | ## 39 | ## For more complicated configurations, read the comments on the examples 40 | ## and also see the examples and explanations in readers.conf(5). The 41 | ## examples in readers.conf(5) include setups that require the user to 42 | ## log in with a username and password (the example in this file only 43 | ## uses simple host-based authentication). 44 | ## 45 | ## NOTE: Unlike in previous versions of INN, nnrpd will now refuse any 46 | ## post from anyone to a moderated newsgroup that contains an Approved: 47 | ## header unless their access block has an access: key containing the 48 | ## "A" flag. This is to prevent abuse of moderated groups, but it means 49 | ## that if you support any newsgroup moderators, you need to make sure 50 | ## to add such a line to the access group that affects them. See the 51 | ## access group for localhost below for an example. 52 | 53 | # The only groups enabled by default (the rest of this file is 54 | # commented-out examples). This assigns the identity of to 55 | # the local machine 56 | 57 | auth "localhost" { 58 | hosts: "localhost, 127.0.0.1, ::1, stdin" 59 | default: "" 60 | } 61 | 62 | # Grant that specific identity access to read and post to any newsgroup 63 | # and allow it to post articles with Approved: headers to moderated 64 | # groups. 65 | 66 | access "localhost" { 67 | users: "" 68 | newsgroups: "*" 69 | access: RPA 70 | } 71 | 72 | 73 | # This auth group matches all connections from example.com or machines in 74 | # the example.com domain and gives them the identity @example.com. 75 | # Instead of using wildmat patterns to match machine names, you could also 76 | # put a wildmat pattern matching IP addresses or an IP range specified 77 | # using CIDR notation (like 10.10.10.0/24) here. 78 | 79 | #auth "local" { 80 | # hosts: "*.example.com, example.com" 81 | # default: "@example.com" 82 | #} 83 | 84 | # This auth group matches a subset of machines and assigns connections 85 | # from there an identity of "@example.com"; these systems should 86 | # only have read access, no posting privileges. 87 | 88 | #auth "read-only" { 89 | # hosts: "*.newuser.example.com" 90 | # default: "@example.com" 91 | #} 92 | 93 | # This auth group matches the systems at a guest institution that should 94 | # be allowed to read the example.events.* hierarchy but nothing else. 95 | 96 | #auth "events-only" { 97 | # hosts: "*.example.org" 98 | # default: "@example.org" 99 | #} 100 | 101 | # Finally, this auth group matches some particular systems which have been 102 | # abusing the server. Note that it doesn't assign them an identity at 103 | # all; the "empty" identity created in this fashion won't match any users: 104 | # parameters. Note also that it's last, so anything matching this entry 105 | # will take precedent over everything above it. 106 | 107 | #auth "abusers" { 108 | # hosts: "badguy-dsl.example.com, kiosk.public-access.example.com" 109 | #} 110 | 111 | 112 | # Now for the access groups. All of our access groups should have users: 113 | # parameters so there are no access groups that match connections without 114 | # an identity (such as are generated by the "abusers" entry above). 115 | # First, the default case of local users, who get to read and post to 116 | # everything. 117 | 118 | #access "local" { 119 | # users: "@example.com" 120 | # newsgroups: "*" 121 | #} 122 | 123 | # Now, the read-only folks, who only get to read everything. 124 | 125 | #access "read-only" { 126 | # users: "@example.com" 127 | # read: "*" 128 | #} 129 | 130 | # Finally, the events-only people who get to read and post but only to a 131 | # specific hierarchy. 132 | 133 | #access "events-only" { 134 | # users: "@example.org" 135 | # newsgroups: "example.events.*" 136 | #} 137 | 138 | auth all { 139 | auth: "ckpasswd" 140 | } 141 | 142 | access all { 143 | newsgroups: * 144 | } -------------------------------------------------------------------------------- /tests/mozilla.dev.platform_47670: -------------------------------------------------------------------------------- 1 | 200 news.mozilla.org 2 | 211 47669 2 47670 mozilla.dev.platform 3 | 220 47670 4 | Path: buffer1.nntp.dca1.giganews.com!buffer2.nntp.dca1.giganews.com!nntp.mozilla.org!news.mozilla.org.POSTED!not-for-mail 5 | NNTP-Posting-Date: Tue, 09 Jun 2020 20:18:09 -0500 6 | Return-Path: 7 | X-Original-To: dev-platform@lists.mozilla.org 8 | Delivered-To: dev-platform@lists.mozilla.org 9 | X-Virus-Scanned: amavisd-new at mozilla.org 10 | X-Spam-Flag: NO 11 | X-Spam-Score: -0.04 12 | X-Spam-Level: 13 | X-Spam-Status: No, score=-0.04 tagged_above=-999 required=5 14 | tests=[BAYES_00=-0.05, KAM_DMARC_STATUS=0.01] 15 | autolearn=ham autolearn_force=no 16 | Authentication-Results: mailman2.mail.mdc2.mozilla.com (amavisd-new); 17 | dkim=pass (1024-bit key) header.d=mozilla.com 18 | Received-SPF: pass (mozilla.com: Sender is authorized to use 19 | 'mthomson@mozilla.com' in 'mfrom' identity (mechanism 20 | 'include:%{i}._ip.%{h}._ehlo.%{d}._spf.vali.email' matched)) 21 | receiver=mailman2.mail.mdc2.mozilla.com; identity=mailfrom; 22 | envelope-from="mthomson@mozilla.com"; helo=mail-pg1-f173.google.com; 23 | client-ip=209.85.215.173 24 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=mozilla.com; s=google; 25 | h=mime-version:references:in-reply-to:from:date:message-id:subject:to 26 | :cc; bh=/1euRQ4OZ9mOhdDNQqCFp/7fHeqGNUu3jR29//YQzgA=; 27 | b=Lp5Ij7spQDLa6Um4eKXnIYT0UGMVWzJTJyJHtbm0s1OgEZjxXrbomZxA8XBdSxwBxa 28 | pgTPC/so8gWTbnIlWSbc7oifVknKqndKWBqJmzvxjAQcp8C+tIFoToiy0A+V83YTh8Ys 29 | xur8bXSzS5EOt8NGWvZO10OZybTOxin2IPFBQ= 30 | X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 31 | d=1e100.net; s=20161025; 32 | h=x-gm-message-state:mime-version:references:in-reply-to:from:date 33 | :message-id:subject:to:cc; 34 | bh=/1euRQ4OZ9mOhdDNQqCFp/7fHeqGNUu3jR29//YQzgA=; 35 | b=g8GK65aSudTKTCjoueUUz7LhMVEHe5NcDrFywXL2dF9bmXtfyKQ2kXaEk9qir6TFkJ 36 | VHdGa95DxNRpwiE738FFViZrLqFFHAEq58uBXQXjopNglyY7dc95B1jnVbVP5PxJZygC 37 | xSd9ap47r4ewSGqMu5+WXeOGHWMUs1XRAkKVt2fyEAgoqxPgEOmK88ibSICHDjikPm8Y 38 | Y8EOaZ+fq17xMuWPfPYOlu2KLuRQ6tlBcCvD/YiBIoYch30BtU0ALReU6LlQQeld90V0 39 | uHwZd4YJXrhXZm43Fl75YRX+t3YmOd6nP2cTfAhUdCtA8ND3mz4oVDZEkn5BNoyDlcDC 40 | lo7w== 41 | X-Gm-Message-State: AOAM533zxLL7knEUWLTP7J2bQswb+/ArbGnNCRhOW8sWcwisa/q5reZP 42 | 8plXrJgbRfryzxX+qzFe15vXLi3RnwNOMneYC4M4/yBUP9U= 43 | X-Google-Smtp-Source: ABdhPJy09t6yEwayDMKxycGi597sJbfbJ0hMR8tch5sVwgYmA5PC74cZdhCc7XJqeE8oKNKqAAbCqay/0KrXeY1NadY= 44 | X-Received: by 2002:a65:508c:: with SMTP id r12mr626047pgp.233.1591751885013; 45 | Tue, 09 Jun 2020 18:18:05 -0700 (PDT) 46 | MIME-Version: 1.0 47 | In-Reply-To: <20200609220107.GA31475@pescadero.dbaron.org> 48 | From: Martin Thomson 49 | Date: Wed, 10 Jun 2020 11:17:56 +1000 50 | Subject: Re: Intent To Ship: backdrop-filter 51 | To: "L. David Baron" 52 | Cc: Erik Nordin , 53 | dev-platform 54 | Content-Type: text/plain; charset="UTF-8" 55 | X-BeenThere: dev-platform@lists.mozilla.org 56 | X-Mailman-Version: 2.1.26 57 | Precedence: list 58 | List-Id: "The Mozilla platform: \"gecko\"" 59 | List-Unsubscribe: , 60 | 61 | List-Archive: 62 | List-Post: 63 | List-Help: 64 | List-Subscribe: , 65 | 66 | Approved: dev-platform@lists.mozilla.org 67 | Newsgroups: mozilla.dev.platform 68 | Message-ID: 69 | X-Mailman-Original-Message-ID: 70 | X-Mailman-Original-References: 71 | <20200609220107.GA31475@pescadero.dbaron.org> 72 | References: 73 | <20200609220107.GA31475@pescadero.dbaron.org> 74 | 75 | Lines: 11 76 | X-Usenet-Provider: http://www.giganews.com 77 | NNTP-Posting-Host: 63.245.210.105 78 | X-AuthenticatedUsername: NoAuthUser 79 | X-Trace: sv3-9neqMQmPdmf54PIvp37HW/8ilVvMd66VNMevlzyJrM0zciTxcNim26xDe3Hl6U43oyrglqdbY4svvSP!oGz8o9TyqILSQMzikU/P+eQwHsGS2r2s0UchGXMuIAOGW/WxqAL9yjbFbsf7m93/nWNmEZvyvcKe!EB0HDLAzMApk5hNZs2SXb1t12C2gdIY= 80 | X-Complaints-To: abuse@mozilla.org 81 | X-DMCA-Complaints-To: abuse@mozilla.org 82 | X-Abuse-and-DMCA-Info: Please be sure to forward a copy of ALL headers 83 | X-Abuse-and-DMCA-Info: Otherwise we will be unable to process your complaint properly 84 | X-Postfilter: 1.3.40 85 | Bytes: 5156 86 | Xref: number.nntp.giganews.com mozilla.dev.platform:47670 87 | 88 | On Wed, Jun 10, 2020 at 8:01 AM L. David Baron wrote: 89 | > It's also something that I think we shouldn't be doing, at least not 90 | > without a clear and relatively short timeline for having the feature 91 | > available across all graphics backends (whether by implementing it 92 | > for more backends or by no longer shipping those backends). 93 | 94 | I agree with David's reasoning here about this being potentially 95 | harmful, but I do recognize the value of prototyping or experimenting. 96 | This doesn't seem to be either of those though. 97 | 98 | To that end, is there a plan for making this capability uniformly available? 99 | . 100 | 205 goodbye 101 | -------------------------------------------------------------------------------- /src/raw/response.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::str::{from_utf8, from_utf8_unchecked}; 3 | 4 | use crate::error::Error; 5 | 6 | use crate::types::response_code::ResponseCode; 7 | 8 | /// A response returned by the low-level [`NntpConnection`](super::connection::NntpConnection) 9 | /// 10 | /// 1. The contents are guaranteed to be represent a syntactically valid NNTP response 11 | /// 2. The contents ARE NOT guaranteed to be UTF-8 as the NNTP does not require contents be UTF-8. 12 | #[derive(Clone, Debug)] 13 | pub struct RawResponse { 14 | pub(crate) code: ResponseCode, 15 | pub(crate) first_line: Vec, 16 | pub(crate) data_blocks: Option, 17 | } 18 | 19 | impl RawResponse { 20 | /// The response code 21 | pub fn code(&self) -> ResponseCode { 22 | self.code 23 | } 24 | 25 | /// Return true if this response is a multi-line response and contains a data block section 26 | pub fn has_data_blocks(&self) -> bool { 27 | matches!(self.data_blocks, Some(_)) 28 | } 29 | 30 | /// Return multi-line data blocks 31 | pub fn data_blocks(&self) -> Option<&DataBlocks> { 32 | self.data_blocks.as_ref() 33 | } 34 | 35 | /// Return the first line of the response 36 | pub fn first_line(&self) -> &[u8] { 37 | &self.first_line 38 | } 39 | 40 | /// Return the first line of the response without the response code 41 | pub fn first_line_without_code(&self) -> &[u8] { 42 | // n.b. this should be infallible barring bugs in the response parsing layer 43 | &self.first_line[4..] 44 | } 45 | 46 | /// Converts a response into an error if it does not match the provided status 47 | pub fn fail_unless(self, desired: impl Into) -> Result { 48 | if self.code() != desired.into() { 49 | Err(Error::failure(self)) 50 | } else { 51 | Ok(self) 52 | } 53 | } 54 | 55 | /// Lossily convert the first line to UTF-8 56 | pub fn first_line_to_utf8_lossy(&self) -> Cow<'_, str> { 57 | String::from_utf8_lossy(&self.first_line) 58 | } 59 | 60 | /// Convert the initial response payload into UTF-8 without checking 61 | /// 62 | /// # Safety 63 | /// 64 | /// This function is unsafe because NNTP responses are NOT required to be UTF-8. 65 | /// This call simply calls [`from_utf8_unchecked`] under the hood. 66 | /// 67 | pub unsafe fn first_line_as_utf8_unchecked(&self) -> &str { 68 | from_utf8_unchecked(&self.first_line) 69 | } 70 | } 71 | 72 | /// The [Multi-line Data Blocks](https://tools.ietf.org/html/rfc3977#section-3.1.1) 73 | /// portion of an NNTP response 74 | /// 75 | /// 76 | /// # Usage 77 | /// 78 | /// [`DataBlocks::payload`](Self::payload) returns the raw bytes in the payload 79 | /// * [`DataBlocks::lines`](Self::lines) returns an iterator over the lines within the block 80 | /// * [`DataBlocks::unterminated`](Self::unterminated) returns an iterator over the lines with the 81 | /// CRLF terminator and the final `.` line of the response stripped 82 | #[derive(Clone, Debug)] 83 | pub struct DataBlocks { 84 | pub(crate) payload: Vec, 85 | pub(crate) line_boundaries: Vec<(usize, usize)>, 86 | } 87 | 88 | impl DataBlocks { 89 | /// Return the raw contained by the payload of the Datablocks 90 | pub fn payload(&self) -> &[u8] { 91 | &self.payload 92 | } 93 | 94 | /// A convenience function that simply calls [`from_utf8`] 95 | pub fn payload_as_utf8(&self) -> Result<&str, std::str::Utf8Error> { 96 | from_utf8(&self.payload) 97 | } 98 | 99 | /// An iterator over the lines within the data block 100 | pub fn lines(&self) -> Lines<'_> { 101 | Lines { 102 | data_blocks: self, 103 | inner: self.line_boundaries.iter(), 104 | } 105 | } 106 | 107 | /// An iterator over the unterminated data block 108 | /// 109 | /// 1. Lines yielded by this iterator WILL NOT include the CRLF terminator 110 | /// 2. The final line of the message containing only `.` will not be returend 111 | pub fn unterminated(&self) -> Unterminated<'_> { 112 | Unterminated { 113 | inner: self.lines(), 114 | } 115 | } 116 | 117 | /// The number of lines 118 | pub fn lines_len(&self) -> usize { 119 | self.line_boundaries.len() 120 | } 121 | 122 | /// The number of bytes in the data block 123 | pub fn payload_len(&self) -> usize { 124 | self.payload.len() 125 | } 126 | 127 | /// Returns true if there are no lines 128 | pub fn is_empty(&self) -> bool { 129 | self.line_boundaries.is_empty() 130 | } 131 | } 132 | 133 | /// An iterator over the data blocks within a response 134 | #[derive(Clone, Debug)] 135 | pub struct Lines<'a> { 136 | data_blocks: &'a DataBlocks, 137 | inner: std::slice::Iter<'a, (usize, usize)>, 138 | } 139 | 140 | impl<'a> Iterator for Lines<'a> { 141 | type Item = &'a [u8]; 142 | 143 | fn next(&mut self) -> Option { 144 | if let Some((start, end)) = self.inner.next() { 145 | Some(&self.data_blocks.payload[*start..*end]) 146 | } else { 147 | None 148 | } 149 | } 150 | } 151 | 152 | /// An iterator created by [`DataBlocks::unterminated`] 153 | #[derive(Clone, Debug)] 154 | pub struct Unterminated<'a> { 155 | inner: Lines<'a>, 156 | } 157 | 158 | impl<'a> Iterator for Unterminated<'a> { 159 | type Item = &'a [u8]; 160 | 161 | fn next(&mut self) -> Option { 162 | match self.inner.next() { 163 | Some(line) if line == b".\r\n" => None, 164 | Some(line) => Some(&line[..line.len() - 2]), 165 | None => None, 166 | } 167 | //let foo: ()= self.data_blocks.lines().take_while(|line| line != b".\r\n"); 168 | //unimplemented!() 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/raw/parse.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryInto; 2 | 3 | use nom::bytes::complete::take_until; 4 | use nom::character::complete::{crlf, one_of}; 5 | use nom::combinator::all_consuming; 6 | use nom::sequence::{terminated, tuple}; 7 | use nom::IResult; 8 | 9 | /// The first line of an NNTP response 10 | /// 11 | /// This struct contains data borrowed from a read buffer 12 | #[derive(Clone, Copy, Debug, PartialEq)] 13 | pub(crate) struct InitialResponseLine<'a> { 14 | /// The response code 15 | pub code: &'a [u8; 3], 16 | /// The data within the response NOT including leading whitespace and terminator characters 17 | pub data: &'a [u8], 18 | /// The entire response including the response code and termination characters 19 | pub buffer: &'a [u8], 20 | } 21 | 22 | /// Return true if the first character is a digit 23 | fn one_of_digit(b: &[u8]) -> IResult<&[u8], char> { 24 | one_of("0123456789")(b) 25 | } 26 | 27 | /// Takes a line from the input buffer 28 | /// 29 | /// A "line" is a sequence of bytes terminated by a CRLF (`\r\n`) sequence. 30 | fn take_line(b: &[u8]) -> IResult<&[u8], &[u8]> { 31 | let (rest, line) = terminated(take_until("\r\n"), crlf)(b)?; 32 | 33 | Ok((rest, line)) 34 | } 35 | 36 | /// Takes a response code from the buffer 37 | /// 38 | /// A valid response code is three ASCII digits where the first digit is between 1 and 5 39 | fn take_response_code(b: &[u8]) -> IResult<&[u8], &[u8]> { 40 | let res: IResult<_, (char, char, char)> = 41 | tuple((one_of("12345"), one_of_digit, one_of_digit))(b); 42 | let (rest, _) = res?; 43 | 44 | Ok((rest, &b[0..3])) 45 | } 46 | 47 | /// Returns true if the buffer only contains a `.` 48 | pub(crate) fn is_end_of_datablock(b: &[u8]) -> bool { 49 | b == b"." 50 | } 51 | 52 | /// Parse an first line of an NNTP response 53 | /// 54 | /// Per [RFC 3977](https://tools.ietf.org/html/rfc3977#section-3.2), the first line of an 55 | /// NNTP response consists of a three-digit response code, a single space, and then 56 | /// some text terminated with a CRLF. 57 | pub(crate) fn parse_first_line(b: &[u8]) -> IResult<&[u8], InitialResponseLine<'_>> { 58 | let res = all_consuming(tuple(( 59 | take_response_code, 60 | nom::character::complete::char(' '), 61 | take_until("\r\n"), 62 | crlf, 63 | )))(b)?; 64 | 65 | let (rest, (code, _, data, _crlf)) = res; 66 | let code = code 67 | .try_into() 68 | .expect("Code should be three bytes, there is likely a bug in the parser."); 69 | 70 | Ok(( 71 | rest, 72 | InitialResponseLine { 73 | code, 74 | data, 75 | buffer: b, 76 | }, 77 | )) 78 | } 79 | 80 | /// Parse a data block line from the buffer 81 | pub(crate) fn parse_data_block_line(b: &[u8]) -> IResult<&[u8], &[u8]> { 82 | all_consuming(take_line)(b) 83 | } 84 | 85 | #[cfg(test)] 86 | mod tests { 87 | use super::*; 88 | 89 | use nom::error::ErrorKind; 90 | use nom::Err; 91 | 92 | const MOTD: &[u8] = 93 | b"200 news.example.com InterNetNews server INN 2.5.5 ready (transit mode)\r\n"; 94 | const MOTD_NO_CRLF: &[u8] = 95 | b"200 news.example.com InterNetNews server INN 2.5.5 ready (transit mode)"; 96 | 97 | mod test_parse_initial_response { 98 | use super::*; 99 | 100 | #[test] 101 | fn happy_path() { 102 | let (_remainder, raw_response) = parse_first_line(MOTD).unwrap(); 103 | let expected_resp = InitialResponseLine { 104 | code: b"200", 105 | data: &b"news.example.com InterNetNews server INN 2.5.5 ready (transit mode)"[..], 106 | buffer: &MOTD, 107 | }; 108 | assert_eq!(raw_response, expected_resp) 109 | } 110 | 111 | #[test] 112 | fn test_remaining_data() { 113 | let data = [MOTD, &b"SOME MORE DATA\r\n"[..]].concat(); 114 | 115 | assert!(parse_first_line(&data).is_err()); 116 | } 117 | } 118 | 119 | mod test_take_line { 120 | use super::*; 121 | 122 | #[test] 123 | fn happy_path() { 124 | assert_eq!(take_line(MOTD), Ok((&b""[..], MOTD_NO_CRLF))); 125 | } 126 | 127 | #[test] 128 | fn test_gzip() { 129 | let header = include_bytes!(concat!( 130 | env!("CARGO_MANIFEST_DIR"), 131 | "/tests/xover_gzip_header" 132 | )); 133 | 134 | let (rest, data) = take_line(header).unwrap(); 135 | assert_eq!(rest.len(), 0); 136 | assert_eq!(data, &header[..header.len() - 2]); 137 | } 138 | } 139 | 140 | mod test_parse_data_block { 141 | use super::*; 142 | 143 | #[test] 144 | fn happy_path() { 145 | let msg = b"101 Capability list:\r\n"; 146 | let (_remainder, block) = parse_data_block_line(msg).unwrap(); 147 | assert_eq!(block, b"101 Capability list:") 148 | } 149 | } 150 | 151 | mod test_parse_response_code { 152 | use super::*; 153 | 154 | #[test] 155 | fn happy_path() { 156 | [ 157 | &b"200"[..], 158 | &b"200 "[..], 159 | &b"2000"[..], 160 | &b"200000"[..], 161 | &b"200123"[..], 162 | &b"200abc"[..], 163 | ] 164 | .iter() 165 | .for_each(|input| { 166 | let res = take_response_code(input); 167 | assert!(res.is_ok()); 168 | let (_rest, code) = res.unwrap(); 169 | assert_eq!(code, b"200") 170 | }); 171 | } 172 | 173 | #[test] 174 | fn too_short() { 175 | println!("Testing {:?}", b"5"); 176 | assert_eq!( 177 | take_response_code(b"5"), 178 | Err(Err::Error((&b""[..], ErrorKind::OneOf))) 179 | ) 180 | } 181 | 182 | #[test] 183 | fn not_enough_digits() { 184 | assert_eq!( 185 | take_response_code(b"5ab500"), 186 | Err(Err::Error((&b"ab500"[..], ErrorKind::OneOf))) 187 | ) 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/types/response/article/binary.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::fmt; 3 | use std::result::Result as StdResult; 4 | use std::str::from_utf8; 5 | 6 | use log::*; 7 | 8 | use crate::error::{Error, Result}; 9 | use crate::types::prelude::*; 10 | use crate::types::response::article::iter::{Lines, Unterminated}; 11 | use crate::types::response::article::parse::take_headers; 12 | use crate::types::response::util::{err_if_not_kind, process_article_first_line}; 13 | 14 | /// A binary Netnews article 15 | /// 16 | /// A `BinaryArticle` is created by calling `try_from` with a [`RawResponse`]. 17 | /// 18 | /// For text articles, consider converting this article into a [`TextArticle`]. 19 | /// 20 | /// # Implementation Notes 21 | /// 22 | /// Articles aim to follow [RFC 3977](https://tools.ietf.org/html/rfc3977#section-3.6) 23 | /// as closely as possible while remaining ergonomic. 24 | /// 25 | /// 1. Response parsing will fail if the header names are not UTF-8 26 | /// 2. The header contents will be lossily converted to UTF-8 27 | /// 3. There are no formatting constraints on the body 28 | #[derive(Clone, Debug, Eq, PartialEq)] 29 | pub struct BinaryArticle { 30 | pub(crate) number: ArticleNumber, 31 | pub(crate) message_id: String, 32 | pub(crate) headers: Headers, 33 | pub(crate) body: Vec, 34 | pub(crate) line_boundaries: Vec<(usize, usize)>, 35 | } 36 | 37 | impl BinaryArticle { 38 | /// The number of the article relative to the group it was retrieved from 39 | pub fn number(&self) -> ArticleNumber { 40 | self.number 41 | } 42 | 43 | /// The message id of the article 44 | pub fn message_id(&self) -> &str { 45 | &self.message_id 46 | } 47 | 48 | /// The headers on the article 49 | pub fn headers(&self) -> &Headers { 50 | &self.headers 51 | } 52 | 53 | /// The raw contents of the body 54 | pub fn body(&self) -> &[u8] { 55 | &self.body 56 | } 57 | 58 | /// The number of lines in the body 59 | pub fn lines_len(&self) -> usize { 60 | self.line_boundaries.len() 61 | } 62 | 63 | /// An iterator over the lines in the body of the article 64 | pub fn lines(&self) -> Lines<'_> { 65 | Lines { 66 | payload: &self.body, 67 | inner: self.line_boundaries.iter(), 68 | } 69 | } 70 | 71 | /// An iterator over the lines of the body without the CRLF terminators 72 | pub fn unterminated(&self) -> Unterminated<'_> { 73 | Unterminated { 74 | inner: self.lines(), 75 | } 76 | } 77 | 78 | /// Convert the article into a [`TextArticle`] 79 | /// 80 | /// This will return an error if the body is not valid UTF-8 81 | pub fn to_text(&self) -> Result { 82 | let headers = self.headers.clone(); 83 | 84 | let body: Vec = self 85 | .unterminated() 86 | .map(|l| from_utf8(l).map(ToString::to_string)) 87 | .collect::>()?; 88 | 89 | let number = self.number; 90 | let message_id = self.message_id.clone(); 91 | Ok(TextArticle { 92 | number, 93 | message_id, 94 | headers, 95 | body, 96 | }) 97 | } 98 | 99 | /// Convert the article into a [`TextArticle`] including invalid characters. 100 | /// 101 | /// This function is analogous to calling is [`String::from_utf8_lossy`] on every line in the body 102 | pub fn to_text_lossy(&self) -> TextArticle { 103 | let headers = self.headers.clone(); 104 | 105 | let body = self 106 | .unterminated() 107 | .map(String::from_utf8_lossy) 108 | .map(|cow| cow.to_string()) 109 | .collect::>(); 110 | 111 | let number = self.number; 112 | let message_id = self.message_id.clone(); 113 | TextArticle { 114 | number, 115 | message_id, 116 | headers, 117 | body, 118 | } 119 | } 120 | } 121 | 122 | impl fmt::Display for BinaryArticle { 123 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 124 | let num_headers = self.headers.len(); 125 | let body_size = self.body.len(); 126 | let lines = self.lines_len(); 127 | 128 | write!( 129 | f, 130 | "BinaryArticle({} headers, {}B body, {} lines)", 131 | num_headers, body_size, lines 132 | ) 133 | } 134 | } 135 | 136 | impl TryFrom<&RawResponse> for BinaryArticle { 137 | type Error = Error; 138 | /// Convert a raw response into an article 139 | /// 140 | /// For the specification see RFC 3977 sections: 141 | /// 142 | /// * [response-220-content](https://tools.ietf.org/html/rfc3977#section-9.4.2) 143 | /// * [article](https://tools.ietf.org/html/rfc3977#section-9.7) 144 | fn try_from(resp: &RawResponse) -> Result { 145 | err_if_not_kind(resp, Kind::Article)?; 146 | let (number, message_id) = process_article_first_line(&resp)?; 147 | 148 | let data_blocks = resp 149 | .data_blocks 150 | .as_ref() 151 | .ok_or_else(Error::missing_data_blocks)?; 152 | 153 | let (body, headers) = take_headers(&data_blocks.payload()).map_err(|e| match e { 154 | nom::Err::Incomplete(n) => Error::Deserialization(format!("{:?}", n)), 155 | nom::Err::Error((_, kind)) | nom::Err::Failure((_, kind)) => { 156 | Error::invalid_data_blocks(format!("{:?}", kind)) 157 | } 158 | })?; 159 | 160 | let bytes_read = data_blocks.payload.len() - body.len(); 161 | trace!("Read {} bytes as headers", bytes_read); 162 | 163 | let mut line_boundaries = data_blocks 164 | .line_boundaries 165 | .iter() 166 | .skip_while(|(start, _end)| start < &bytes_read) 167 | .map(|(start, end)| (start - bytes_read, end - bytes_read)) 168 | .collect::>(); 169 | line_boundaries.pop(); 170 | 171 | Ok(Self { 172 | number, 173 | message_id, 174 | headers, 175 | body: body.to_vec(), 176 | line_boundaries, 177 | }) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/types/response_code.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::fmt; 3 | 4 | /// An NNTP [Response Code](https://tools.ietf.org/html/rfc3977#section-3.2) 5 | /// 6 | /// 7 | /// This library supports all codes specified in [RFC 3977](https://tools.ietf.org/html/rfc3977#appendix-C). 8 | /// 9 | /// Because proprietary NNTP extensions may define their own codes, there is no way for this library 10 | /// to know about all of the codes that exist. Unknown codes will be stored as `u16`s. 11 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 12 | pub enum ResponseCode { 13 | /// A response code implemented by the library 14 | Known(Kind), 15 | /// A response code not known to the library 16 | /// 17 | /// For example, a code specified by an NNTP extension might not return a known code. 18 | Unknown(u16), 19 | } 20 | 21 | impl ResponseCode { 22 | /// The response is a 1xx 23 | pub fn is_info(&self) -> bool { 24 | let code = u16::from(*self); 25 | code >= 100 && code < 200 26 | } 27 | 28 | /// The response is a 2xx 29 | pub fn is_success(&self) -> bool { 30 | let code = u16::from(*self); 31 | code >= 200 && code < 300 32 | } 33 | 34 | /// The response is a 3xx 35 | pub fn is_success_so_far(&self) -> bool { 36 | let code = u16::from(*self); 37 | code >= 300 && code < 400 38 | } 39 | 40 | /// The response is a 4xx 41 | pub fn is_failure(&self) -> bool { 42 | let code = u16::from(*self); 43 | code >= 400 && code < 500 44 | } 45 | 46 | /// The response is a 5xx 47 | pub fn is_error(&self) -> bool { 48 | let code = u16::from(*self); 49 | code >= 500 && code < 600 50 | } 51 | 52 | /// Returns true if the response is a Known multiline response 53 | /// 54 | /// Unknown responses are always false 55 | pub fn is_multiline(&self) -> bool { 56 | match self { 57 | ResponseCode::Known(k) => k.is_multiline(), 58 | ResponseCode::Unknown(_) => false, 59 | } 60 | } 61 | } 62 | 63 | impl From for ResponseCode { 64 | fn from(code: u16) -> Self { 65 | Kind::try_from(code).map_or_else(|_e| Self::Unknown(code), Self::Known) 66 | } 67 | } 68 | 69 | impl From for u16 { 70 | fn from(code: ResponseCode) -> Self { 71 | match code { 72 | ResponseCode::Known(kind) => kind as u16, 73 | ResponseCode::Unknown(code) => code, 74 | } 75 | } 76 | } 77 | 78 | impl From<&ResponseCode> for u16 { 79 | fn from(code: &ResponseCode) -> Self { 80 | match *code { 81 | ResponseCode::Known(kind) => kind as u16, 82 | ResponseCode::Unknown(code) => code, 83 | } 84 | } 85 | } 86 | 87 | impl fmt::Display for ResponseCode { 88 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 89 | let code: u16 = self.into(); 90 | write!(f, "{}", code) 91 | } 92 | } 93 | 94 | /// NNTP response code types 95 | /// 96 | /// ## References 97 | /// 98 | /// * [RFC 3977 Section 3.2](https://tools.ietf.org/html/rfc3977#section-3.2) 99 | /// * [RFC 3977 Appendix C](https://tools.ietf.org/html/rfc3977#appendix-C) 100 | #[repr(u16)] 101 | #[derive(Copy, Clone, Debug, Eq, PartialEq, num_enum::TryFromPrimitive)] 102 | #[allow(missing_docs)] 103 | pub enum Kind { 104 | Help = 100, 105 | Capabilities = 101, 106 | Date = 111, 107 | 108 | PostingAllowed = 200, 109 | PostingProhibited = 201, 110 | ConnectionClosing = 205, 111 | /// This is generated by `GROUP` and `LISTGROUP` and the bodies are different depending 112 | /// on which command is used. Buyer beware! 113 | GroupSelected = 211, 114 | List = 215, 115 | Article = 220, 116 | 117 | Head = 221, 118 | Body = 222, 119 | ArticleExists = 223, 120 | Overview = 224, 121 | 122 | ArticleTransferredOk = 235, 123 | 124 | IHaveSendArticle = 335, 125 | PostSendArticle = 340, 126 | 127 | TemporarilyUnavailable = 400, 128 | WrongMode = 401, 129 | InternalError = 403, 130 | NoSuchNewsgroup = 411, 131 | NoNewsgroupSelected = 412, 132 | InvalidCurrentArticleNumber = 420, 133 | NoNextArticle = 421, 134 | NoPreviousArticle = 422, 135 | NoArticleWithNumber = 423, 136 | NoArticleWithMessageId = 430, 137 | ArticleNotWanted = 435, 138 | TransferFailed = 436, 139 | TransferRejected = 437, 140 | PostingNotPermitted = 440, 141 | PostingFailed = 441, 142 | AuthenticationRequired = 480, 143 | SecureConnectionRequired = 483, 144 | 145 | UnknownCommand = 500, 146 | SyntaxError = 501, 147 | PermanentlyUnavailable = 502, 148 | FeatureNotSupported = 503, 149 | Base64Error = 504, 150 | 151 | // Authentication https://tools.ietf.org/html/rfc4643 152 | AuthenticationAccepted = 281, 153 | PasswordRequired = 381, 154 | AuthenticationFailed = 481, 155 | AuthenticationOutOfSequence = 482, 156 | } 157 | 158 | impl Kind { 159 | /// Whether or not the code corresponds to a multiline response 160 | /// 161 | /// NNTP requires that extension makers specify whether a response code 162 | /// corresponds to a single-line or multi-line response. 163 | /// 164 | /// With the exception of code `211`, this means that the response code reliably tells you 165 | /// whether or not there is a data block section for a response. 166 | /// 167 | /// ## Response Code 211 168 | /// 169 | /// Per [RFC 3977](https://tools.ietf.org/html/rfc3977#section-3.2), due to "historical reasons", 170 | /// Response Code 211 corresponds to a multi-line response when returned from `LISTGROUP` 171 | /// and a single-line response when returned by the `GROUP` command. 172 | /// 173 | /// Rather than making the entire library more complex for this singular exception, the library 174 | /// assumes that code 211 DOES NOT correspond to a multi-line response. 175 | pub fn is_multiline(&self) -> bool { 176 | matches!(*self as u16, 100..=101 | 215 | 220..=222 | 224..=225 | 230..=231) 177 | } 178 | } 179 | 180 | impl From for u16 { 181 | fn from(code: Kind) -> Self { 182 | code as u16 183 | } 184 | } 185 | 186 | impl From for ResponseCode { 187 | fn from(kind: Kind) -> Self { 188 | ResponseCode::Known(kind) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /tests/innd/root/etc/news/inn.conf: -------------------------------------------------------------------------------- 1 | ## $Id: inn.conf.in 10301 2018-11-11 14:42:17Z iulius $ 2 | ## 3 | ## inn.conf -- INN configuration data 4 | ## 5 | ## Format: 6 | ## : 7 | ## 8 | ## Blank values are allowed for certain parameters. 9 | ## 10 | ## See the inn.conf(5) man page for a full description of each of these 11 | ## options. This sample file is divided into two sections; first, there 12 | ## are the parameters that must be set (or should be set in nearly all 13 | ## cases), and then all parameters are given with their defaults for 14 | ## reference in the same order and with the same organization as the 15 | ## inn.conf(5) documentation. 16 | 17 | # The following parameters are most likely to need setting, although the 18 | # defaults generated by configure may be reasonable. 19 | 20 | mta: "/usr/sbin/sendmail -oi -oem %s" 21 | organization: "A poorly-installed InterNetNews site" 22 | ovmethod: tradindexed 23 | hismethod: hisv6 24 | #pathhost: localhost 25 | pathnews: /usr 26 | 27 | #runasuser: 28 | #runasgroup: 29 | 30 | # General Settings 31 | 32 | domain: eyrie.org 33 | #innflags: 34 | mailcmd: /usr/libexec/news/innmail 35 | #server: 36 | #syntaxchecks: [ no-laxmid ] 37 | 38 | # Feed Configuration 39 | 40 | artcutoff: 10 41 | #bindaddress: 42 | #bindaddress6: 43 | dontrejectfiltered: false 44 | hiscachesize: 256 45 | ignorenewsgroups: false 46 | immediatecancel: false 47 | linecountfuzz: 0 48 | maxartsize: 1000000 49 | maxconnections: 50 50 | #pathalias: 51 | #pathcluster: 52 | pgpverify: true 53 | port: 119 54 | refusecybercancels: false 55 | remembertrash: true 56 | #sourceaddress: 57 | #sourceaddress6: 58 | verifycancels: false 59 | verifygroups: false 60 | wanttrash: false 61 | wipcheck: 5 62 | wipexpire: 10 63 | 64 | # Article Storage 65 | 66 | cnfscheckfudgesize: 0 67 | enableoverview: true 68 | extraoverviewadvertised: [ ] 69 | extraoverviewhidden: [ ] 70 | groupbaseexpiry: true 71 | mergetogroups: false 72 | nfswriter: false 73 | overcachesize: 128 74 | #ovgrouppat: 75 | storeonxref: true 76 | useoverchan: false 77 | wireformat: true 78 | xrefslave: false 79 | 80 | # Reading 81 | 82 | allownewnews: true 83 | articlemmap: true 84 | clienttimeout: 1800 85 | initialtimeout: 60 86 | msgidcachesize: 64000 87 | nfsreader: false 88 | nfsreaderdelay: 60 89 | nnrpdcheckart: true 90 | nnrpdflags: "" 91 | nnrpdloadlimit: 16 92 | noreader: false 93 | readerswhenstopped: false 94 | readertrack: false 95 | tradindexedmmap: true 96 | 97 | # Reading -- Keyword Support 98 | # 99 | # You should add "keywords" to extraoverviewadvertised or extraoverviewhidden 100 | # if you enable this feature. You must have compiled this support in too 101 | # with --enable-keywords at configure time. 102 | 103 | keywords: false 104 | keyartlimit: 100000 105 | keylimit: 512 106 | keymaxwords: 250 107 | 108 | # Posting 109 | 110 | addinjectiondate: true 111 | addinjectionpostingaccount: false 112 | addinjectionpostinghost: true 113 | checkincludedtext: false 114 | #complaints: 115 | #fromhost: 116 | localmaxartsize: 1000000 117 | #moderatormailer: 118 | nnrpdauthsender: false 119 | #nnrpdposthost: 120 | nnrpdpostport: 119 121 | spoolfirst: false 122 | strippostcc: false 123 | 124 | # Posting -- Exponential Backoff 125 | 126 | backoffauth: false 127 | #backoffdb: 128 | backoffk: 1 129 | backoffpostfast: 0 130 | backoffpostslow: 1 131 | backofftrigger: 10000 132 | 133 | # Reading and Posting -- TLS/SSL Support 134 | # 135 | # The OpenSSL SSL and crypto libraries must have been found 136 | # at configure time to have this support, or you must have 137 | # compiled this support in with --with-openssl at configure time. 138 | 139 | #tlscafile: 140 | #tlscapath: /etc/news 141 | #tlscertfile: /etc/news/cert.pem 142 | #tlskeyfile: /etc/news/key.pem 143 | #tlsciphers: 144 | #tlsciphers13: 145 | #tlscompression: false 146 | #tlseccurve: 147 | #tlspreferserverciphers: true 148 | #tlsprotocols: [ TLSv1 TLSv1.1 TLSv1.2 TLSv1.3 ] 149 | 150 | # Monitoring 151 | 152 | doinnwatch: false 153 | innwatchbatchspace: 4000 154 | innwatchlibspace: 25000 155 | innwatchloload: 1000 156 | innwatchhiload: 2000 157 | innwatchpauseload: 1500 158 | innwatchsleeptime: 600 159 | innwatchspoolnodes: 200 160 | innwatchspoolspace: 25000 161 | 162 | # Logging 163 | 164 | docnfsstat: false 165 | htmlstatus: true 166 | incominglogfrequency: 200 167 | logartsize: true 168 | logcancelcomm: false 169 | logcycles: 3 170 | logipaddr: true 171 | logsitename: true 172 | logstatus: true 173 | logtrash: true 174 | nnrpdoverstats: true 175 | nntplinklog: false 176 | #stathist: 177 | status: 600 178 | timer: 600 179 | 180 | # System Tuning 181 | 182 | badiocount: 5 183 | blockbackoff: 120 184 | chaninacttime: 600 185 | chanretrytime: 300 186 | datamovethreshold: 16384 187 | icdsynccount: 10 188 | keepmmappedthreshold: 1024 189 | #maxcmdreadsize: 190 | maxforks: 10 191 | nicekids: 0 192 | nicenewnews: 0 193 | nicennrpd: 0 194 | pauseretrytime: 300 195 | peertimeout: 3600 196 | rlimitnofile: -1 197 | 198 | # Paths 199 | 200 | patharchive: /var/spool/news/archive 201 | patharticles: /var/spool/news/articles 202 | pathbin: /usr/libexec/news 203 | pathcontrol: /usr/libexec/news/control 204 | pathdb: /var/lib/news 205 | pathetc: /etc/news 206 | pathfilter: /usr/libexec/news/filter 207 | pathhttp: /var/lib/news/http 208 | pathincoming: /var/spool/news/incoming 209 | pathlog: /var/log/news 210 | pathoutgoing: /var/spool/news/outgoing 211 | pathoverview: /var/spool/news/overview 212 | pathrun: /run/news 213 | pathspool: /var/spool/news 214 | pathtmp: /var/lib/news/tmp 215 | -------------------------------------------------------------------------------- /src/types/command/rfc3977.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::types::prelude::{ArticleNumber, NntpCommand}; 4 | 5 | /// Retrieve an article's header and body 6 | #[derive(Clone, Debug)] 7 | pub enum Article { 8 | /// Globally unique message ID 9 | MessageId(String), 10 | /// Article number relative to the current group 11 | Number(ArticleNumber), 12 | /// Currently selected article 13 | Current, 14 | } 15 | 16 | impl fmt::Display for Article { 17 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 18 | match self { 19 | Article::MessageId(id) => write!(f, "ARTICLE {}", id), 20 | Article::Number(num) => write!(f, "ARTICLE {}", num), 21 | Article::Current => write!(f, "ARTICLE"), 22 | } 23 | } 24 | } 25 | 26 | impl NntpCommand for Article {} 27 | 28 | /// Retrieve the body for an Article 29 | #[derive(Clone, Debug)] 30 | pub enum Body { 31 | /// Globally unique message ID 32 | MessageId(String), 33 | /// Article number relative to the current group 34 | Number(ArticleNumber), 35 | /// Currently selected article 36 | Current, 37 | } 38 | 39 | impl fmt::Display for Body { 40 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 41 | match self { 42 | Body::MessageId(id) => write!(f, "BODY {}", id), 43 | Body::Number(num) => write!(f, "BODY {}", num), 44 | Body::Current => write!(f, "BODY"), 45 | } 46 | } 47 | } 48 | 49 | impl NntpCommand for Body {} 50 | 51 | /// Get the capabilities provided by the server 52 | #[derive(Clone, Copy, Debug)] 53 | pub struct Capabilities; 54 | 55 | impl fmt::Display for Capabilities { 56 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 57 | write!(f, "CAPABILITIES") 58 | } 59 | } 60 | 61 | impl NntpCommand for Capabilities {} 62 | 63 | /// Get the server time 64 | #[derive(Clone, Copy, Debug)] 65 | struct Date; 66 | 67 | impl fmt::Display for Date { 68 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 69 | write!(f, "DATE") 70 | } 71 | } 72 | 73 | impl NntpCommand for Date {} 74 | 75 | /// Select a group 76 | #[derive(Clone, Debug)] 77 | pub struct Group(pub String); 78 | 79 | impl fmt::Display for Group { 80 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 81 | write!(f, "GROUP {}", self.0) 82 | } 83 | } 84 | 85 | impl NntpCommand for Group {} 86 | 87 | /// Retrieve a specific header from one or more articles 88 | #[derive(Clone, Debug)] 89 | pub enum Hdr { 90 | /// A single article by message ID 91 | MessageId { 92 | /// The name of the header 93 | field: String, 94 | /// The unique message id of the article 95 | id: String, 96 | }, 97 | /// A range of articles 98 | Range { 99 | /// The name of the header 100 | field: String, 101 | /// The low number of the article range 102 | low: ArticleNumber, 103 | /// The high number of the article range 104 | high: ArticleNumber, 105 | }, 106 | /// The current article 107 | Current { 108 | /// The name of the header 109 | field: String, 110 | }, 111 | } 112 | 113 | impl fmt::Display for Hdr { 114 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 115 | match self { 116 | Hdr::MessageId { field, id } => write!(f, "HDR {} {}", field, id), 117 | Hdr::Range { field, low, high } => write!(f, "HDR {} {}-{}", field, low, high), 118 | Hdr::Current { field } => write!(f, "HDR {}", field), 119 | } 120 | } 121 | } 122 | 123 | impl NntpCommand for Hdr {} 124 | 125 | /// Retrieve the headers for an article 126 | #[derive(Clone, Debug)] 127 | pub enum Head { 128 | /// Globally unique message ID 129 | MessageId(String), 130 | /// Article number relative to the current group 131 | Number(ArticleNumber), 132 | /// Currently selected article 133 | Current, 134 | } 135 | 136 | impl fmt::Display for Head { 137 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 138 | match self { 139 | Head::MessageId(id) => write!(f, "HEAD {}", id), 140 | Head::Number(num) => write!(f, "HEAD {}", num), 141 | Head::Current => write!(f, "HEAD"), 142 | } 143 | } 144 | } 145 | 146 | impl NntpCommand for Head {} 147 | 148 | /// Retrieve help text about the servers capabilities 149 | struct Help; 150 | 151 | impl fmt::Display for Help { 152 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 153 | write!(f, "HELP") 154 | } 155 | } 156 | 157 | impl NntpCommand for Help {} 158 | 159 | /// Inform the server that you have an article for upload 160 | struct IHave(String); 161 | 162 | impl fmt::Display for IHave { 163 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 164 | write!(f, "IHAVE {}", self.0) 165 | } 166 | } 167 | 168 | impl NntpCommand for IHave {} 169 | 170 | /// Attempt to set the current article to the previous article number 171 | struct Last; 172 | 173 | impl fmt::Display for Last { 174 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 175 | write!(f, "LAST") 176 | } 177 | } 178 | 179 | impl NntpCommand for Last {} 180 | 181 | /// Retrieve a list of information from the server 182 | /// 183 | /// Not all LIST keywords are supported by all servers. 184 | /// 185 | /// ## Usage Note 186 | /// 187 | /// If you want to send LIST without any keywords simply send [`List::Active`] as they are equivalent. 188 | #[derive(Clone, Debug)] 189 | #[allow(missing_docs)] 190 | pub enum List { 191 | /// Return a list of active newsgroups 192 | /// 193 | /// [RFC 3977 7.6.3](https://tools.ietf.org/html/rfc3977#section-7.6.3) 194 | Active { wildmat: Option }, 195 | /// Return information about when news groups were created 196 | /// 197 | /// [RFC 3977 7.6.4](https://tools.ietf.org/html/rfc3977#section-7.6.4) 198 | ActiveTimes { wildmat: Option }, 199 | /// List descriptions of newsgroups available on the server 200 | /// 201 | /// [RFC 3977 7.6.6](https://tools.ietf.org/html/rfc3977#section-7.6.6) 202 | Newsgroups { wildmat: Option }, 203 | /// Retrieve information about the Distribution header for news articles 204 | /// 205 | /// [RFC 3977 7.6.5](https://tools.ietf.org/html/rfc3977#section-7.6.5) 206 | DistribPats, 207 | /// Return field descriptors for headers returned by OVER/XOVER 208 | /// 209 | /// [RFC 3977 8.4](https://tools.ietf.org/html/rfc3977#section-8.4) 210 | OverviewFmt, 211 | } 212 | 213 | impl fmt::Display for List { 214 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 215 | fn print_wildmat(f: &mut fmt::Formatter<'_>, wildmat: Option<&String>) -> fmt::Result { 216 | if let Some(w) = wildmat.as_ref() { 217 | write!(f, " {}", w) 218 | } else { 219 | Ok(()) 220 | } 221 | } 222 | 223 | write!(f, "LIST")?; 224 | match self { 225 | List::Active { wildmat } => { 226 | write!(f, " ACTIVE")?; 227 | print_wildmat(f, wildmat.as_ref()) 228 | } 229 | List::OverviewFmt => write!(f, " OVERVIEW.FMT"), 230 | List::ActiveTimes { wildmat } => { 231 | write!(f, " ACTIVE TIMES")?; 232 | print_wildmat(f, wildmat.as_ref()) 233 | } 234 | List::Newsgroups { wildmat } => { 235 | write!(f, " ACTIVE TIMES")?; 236 | print_wildmat(f, wildmat.as_ref()) 237 | } 238 | List::DistribPats => write!(f, " DISTRIB.PATS"), 239 | } 240 | } 241 | } 242 | 243 | impl NntpCommand for List {} 244 | 245 | /// Enable reader mode on a mode switching server 246 | #[derive(Clone, Copy, Debug)] 247 | pub struct ModeReader; 248 | 249 | impl fmt::Display for ModeReader { 250 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 251 | write!(f, "MODE READER") 252 | } 253 | } 254 | 255 | impl NntpCommand for ModeReader {} 256 | 257 | // TODO(commands) implement NEWGROUPS 258 | 259 | // TODO(commands) implement NEWNEWS 260 | 261 | /// Attempt to set the current article to the next article number 262 | #[derive(Clone, Copy, Debug)] 263 | pub struct Next; 264 | 265 | impl fmt::Display for Next { 266 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 267 | write!(f, "NEXT") 268 | } 269 | } 270 | 271 | impl NntpCommand for Next {} 272 | 273 | /// Retrieve all of the fields (e.g. headers/metadata) for one or more articles 274 | #[derive(Clone, Debug)] 275 | pub enum Over { 276 | /// A single article by message ID 277 | MessageId(String), 278 | /// A range of articles 279 | Range { 280 | /// The low number of the article 281 | low: ArticleNumber, 282 | /// The high number of the article 283 | high: ArticleNumber, 284 | }, 285 | /// The current article 286 | Current, 287 | } 288 | 289 | impl fmt::Display for Over { 290 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 291 | match self { 292 | Over::MessageId(id) => write!(f, "OVER {}", id), 293 | Over::Range { low, high } => write!(f, "OVER {}-{}", low, high), 294 | Over::Current => write!(f, "OVER"), 295 | } 296 | } 297 | } 298 | 299 | impl NntpCommand for Over {} 300 | 301 | // TODO(commands) complete POST implementation 302 | /* 303 | /// Post an article to the news server 304 | /// 305 | /// POSTING is a two part exchange. The [`Initial`](Post::Initial) variant is used 306 | /// to determine if the server will accept the article while the [`Article`](Post::Article) is 307 | /// used to send the data. 308 | /// 309 | /// 310 | /// For more information see [RFC 3977 6.3.1](https://tools.ietf.org/html/rfc3977#section-6.3.1) 311 | #[derive(Clone, Debug)] 312 | enum Post<'a> { 313 | Initial, 314 | /// The article body NOT including the terminating sequence 315 | Article(&'a [u8]) 316 | } 317 | 318 | impl NntpCommand for Post<'_> {} 319 | 320 | impl fmt::Display for Post<'_> { 321 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 322 | match self { 323 | Post::Initial => write!(f, "POST"), 324 | Post::Article(body) => { 325 | unimplemented!() 326 | }, 327 | } 328 | } 329 | } 330 | */ 331 | 332 | /// Close the connection 333 | #[derive(Clone, Copy, Debug)] 334 | pub struct Quit; 335 | 336 | impl fmt::Display for Quit { 337 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 338 | write!(f, "QUIT") 339 | } 340 | } 341 | 342 | impl NntpCommand for Quit {} 343 | 344 | /// Check if an article exists in the newsgroup 345 | #[derive(Clone, Debug)] 346 | pub enum Stat { 347 | /// Globally unique message ID 348 | MessageId(String), 349 | /// Article number relative to the current group 350 | Number(ArticleNumber), 351 | /// Currently selected article 352 | Current, 353 | } 354 | 355 | impl fmt::Display for Stat { 356 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 357 | match self { 358 | Stat::MessageId(id) => write!(f, "STAT {}", id), 359 | Stat::Number(num) => write!(f, "STAT {}", num), 360 | Stat::Current => write!(f, "STAT"), 361 | } 362 | } 363 | } 364 | 365 | impl NntpCommand for Stat {} 366 | -------------------------------------------------------------------------------- /src/types/response/article/parse.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use log::*; 4 | use nom::branch::alt; 5 | use nom::bytes::complete::{take, take_while1}; 6 | use nom::character::complete::{char, crlf, space0, space1}; 7 | use nom::combinator::{opt, verify}; 8 | use nom::lib::std::str::from_utf8; 9 | use nom::multi::{fold_many1, many0}; 10 | use nom::sequence::{terminated, tuple}; 11 | use nom::IResult; 12 | 13 | use crate::types::prelude::{Header, Headers}; 14 | 15 | /// Returns true if the character is any ASCII non-control character other than a colon 16 | /// 17 | /// [A-NOTCOLON](https://tools.ietf.org/html/rfc3977#section-9.8) 18 | fn is_a_notcolon(chr: u8) -> bool { 19 | (chr >= 0x21 && chr <= 0x39) || (chr >= 0x3b && chr <= 0x7e) 20 | } 21 | 22 | /// Returns true if the slice is UTF-8 and contains no ascii characters 23 | /// 24 | /// [UTF8-non-ascii](https://tools.ietf.org/html/rfc3977#section-9.8) 25 | fn is_utf8_non_ascii(b: &[u8]) -> bool { 26 | if let Ok(s) = from_utf8(b) { 27 | // if any bytes are ascii this fails the test 28 | !s.bytes().any(|u| u.is_ascii()) 29 | } else { 30 | false 31 | } 32 | } 33 | 34 | /// Returns true if the char is any ASCII character from `!` through `~` 35 | /// 36 | /// [`A-CHAR`](https://tools.ietf.org/html/rfc3977#section-9.8) 37 | fn is_a_char(chr: u8) -> bool { 38 | chr >= 0x21 && chr <= 0x7e 39 | } 40 | 41 | /// Returns true if the byte slice is a *single* non ASCII non-control char 42 | /// 43 | /// [`A-CHAR`](https://tools.ietf.org/html/rfc3977#section-9.8) 44 | fn is_a_char_bytes(b: &[u8]) -> bool { 45 | if b.len() > 1 { 46 | false 47 | } else { 48 | is_a_char(b[0]) 49 | } 50 | } 51 | 52 | /// Take an A-CHAR from the slice 53 | fn take_a_char(b: &[u8]) -> IResult<&[u8], &[u8]> { 54 | verify(take_ascii_byte, is_a_char_bytes)(b) 55 | } 56 | 57 | /// Take a single non-ascii UTF-8 character from the slice 58 | /// 59 | /// nom 5 lacks combinators to distinguish between ASCII and UTF-8 so we have to implement this 60 | /// manually 61 | /// 62 | /// 63 | /// [`UTF8-non-ascii`](https://tools.ietf.org/html/rfc3977#section-9.8) 64 | fn take_utf8_non_ascii(b: &[u8]) -> IResult<&[u8], &[u8]> { 65 | alt(( 66 | verify(take(1u8), is_utf8_non_ascii), 67 | verify(take(2u8), is_utf8_non_ascii), 68 | verify(take(3u8), is_utf8_non_ascii), 69 | verify(take(4u8), is_utf8_non_ascii), 70 | ))(b) 71 | } 72 | 73 | /// Take a single `A-CHAR` or `UTF8-non-ascii` from the slice 74 | /// ```abnf 75 | /// P-CHAR = A-CHAR / UTF8-non-ascii 76 | /// A-CHAR = %x21-7E 77 | /// ``` 78 | fn take_p_char(b: &[u8]) -> IResult<&[u8], &[u8]> { 79 | alt((take_a_char, take_utf8_non_ascii))(b) 80 | } 81 | 82 | /// Take the header-name from a slice 83 | /// 84 | /// The header-name is defined as 1 or more `A-NOTCOLON` characters 85 | /// 86 | /// [header-name](https://tools.ietf.org/html/rfc3977#section-9.8) 87 | fn take_header_name(b: &[u8]) -> IResult<&[u8], &[u8]> { 88 | take_while1(is_a_notcolon)(b) 89 | } 90 | 91 | /// A token is one or more `P-CHAR` characters 92 | /// 93 | /// [token](https://tools.ietf.org/html/rfc3977#section-9.8) 94 | fn take_token(b: &[u8]) -> IResult<&[u8], &[u8]> { 95 | let (rest, token_len) = fold_many1(take_p_char, 0, |mut acc, slice| { 96 | acc += slice.len(); 97 | acc 98 | })(b)?; 99 | 100 | let token = &b[..token_len]; 101 | Ok((rest, token)) 102 | } 103 | 104 | /// Take a single byte 105 | /// 106 | /// This combinator simply returns a single byte if it is ASCII 107 | fn take_ascii_byte(b: &[u8]) -> IResult<&[u8], &[u8]> { 108 | verify(take(1u8), |uint: &[u8]| uint.is_ascii())(b) 109 | } 110 | 111 | /// The content of an Article Header 112 | /// 113 | /// Headers may be split across multiple lines (aka folded) 114 | /// 115 | /// [RFC 3977 Appendix 1](https://tools.ietf.org/html/rfc3977#appendix-A.1) 116 | /// 117 | /// ```abnf 118 | /// header-content = [WS] token *( [CRLF] WS token ) 119 | /// ``` 120 | /// 121 | /// # Non-Compliant Whitespace 122 | /// 123 | /// * All of the header RFCs I've come indicate there is no whitespace allowed between tokens and 124 | /// CLRF characters. Thankfully mail servers don't follow RFCs and violate this anyways so we 125 | /// do allow this *non-compliant* behavior to ease user suffering 126 | fn take_header_content(b: &[u8]) -> IResult<&[u8], &[u8]> { 127 | let (rest, (_ws, _token, _more_tokens)) = tuple(( 128 | space0, 129 | take_token, 130 | many0(tuple(( 131 | opt(tuple((space0, crlf))), // Per RFC this *should* be opt(crlf), see non-compliant whitespace note 132 | space1, 133 | take_token, 134 | ))), 135 | ))(b)?; 136 | let bytes_read = b.len() - rest.len(); 137 | Ok((rest, &b[..bytes_read])) 138 | } 139 | 140 | /// https://tools.ietf.org/html/rfc3977#appendix-A.1 141 | /// 142 | /// ```abnf 143 | /// header = header-name ":" SP [header-content] CRLF 144 | /// header-content = [WS] token *( [CRLF] WS token ) 145 | /// ``` 146 | fn take_header(b: &[u8]) -> IResult<&[u8], (&[u8], &[u8])> { 147 | // he 148 | let (rest, (header_name, _, _, header_content)) = terminated( 149 | tuple(( 150 | take_header_name, 151 | char(':'), 152 | char(' '), 153 | opt(take_header_content), 154 | )), 155 | crlf, 156 | )(b)?; 157 | Ok((rest, (header_name, header_content.unwrap_or_default()))) 158 | } 159 | 160 | pub(crate) fn take_headers(b: &[u8]) -> IResult<&[u8], Headers> { 161 | // n.b. assuming there are no parsing bugs (big if there), it should be sound to use 162 | // from_utf8_unchecked on header names since we already did utf8 checks while parsing. 163 | 164 | let fold_headers = fold_many1( 165 | take_header, 166 | (HashMap::new(), 0), 167 | |(mut map, mut len), (name, content)| { 168 | let name = String::from_utf8_lossy(name).to_string(); 169 | let content = String::from_utf8_lossy(content).to_string(); 170 | trace!("Found header name `{}` -- `{}`", name, content); 171 | 172 | let header = map.entry(name.clone()).or_insert(Header { 173 | name, 174 | content: vec![], 175 | }); 176 | header.content.push(content); 177 | 178 | len += 1; 179 | 180 | (map, len) 181 | }, 182 | ); 183 | 184 | let (rest, (inner, len)) = terminated(fold_headers, crlf)(b)?; 185 | 186 | let headers = Headers { inner, len }; 187 | 188 | Ok((rest, headers)) 189 | } 190 | 191 | #[cfg(test)] 192 | mod tests { 193 | use super::*; 194 | const TEXT_ARTICLE: &str = 195 | include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/text_article")); 196 | 197 | const FOLDED_HEADER: &[u8; 127] = 198 | b"X-Received: by 2002:ac8:2aed:: with SMTP id c42mr5587158qta.202.1591290821135;\r\n \ 199 | Thu, 05 Jun 2020 10:13:41 -0700 (PDT)\r\n"; 200 | 201 | mod is_utf8_non_ascii { 202 | use super::*; 203 | 204 | #[test] 205 | fn happy_path() { 206 | ["🤘".as_bytes(), "¥".as_bytes(), "🚃".as_bytes()] 207 | .iter() 208 | .for_each(|b| { 209 | println!("Testing `{}` -- {:?}", from_utf8(b).unwrap(), b); 210 | assert_eq!(is_utf8_non_ascii(b), true) 211 | }); 212 | } 213 | 214 | #[test] 215 | fn fail_ascii() { 216 | assert_eq!(is_utf8_non_ascii(b"1"), false) 217 | } 218 | } 219 | 220 | mod take_utf8_non_ascii { 221 | use super::*; 222 | 223 | #[test] 224 | fn happy_path() { 225 | ["🤘", "¢", "ぎ", "🚃5"].iter().for_each(|s| { 226 | println!("Testing `{}` -- {:?}", s, s.as_bytes()); 227 | let (rest, byte) = take_utf8_non_ascii(s.as_bytes()).unwrap(); 228 | 229 | assert_eq!(from_utf8(byte).unwrap().chars().next(), s.chars().next()); 230 | assert_eq!(rest.is_empty(), s.chars().count() == 1); 231 | }) 232 | } 233 | } 234 | 235 | #[test] 236 | fn test_token() { 237 | let (rest, token) = take_token("📯1🤘 some words 🐒 ".as_bytes()).unwrap(); 238 | dbg!(from_utf8(rest).unwrap()); 239 | dbg!(from_utf8(token).unwrap()); 240 | 241 | assert_eq!(token, "📯1🤘".as_bytes()); 242 | assert_eq!(rest, " some words 🐒 ".as_bytes()) 243 | } 244 | 245 | mod take_ascii_byte { 246 | use super::*; 247 | #[test] 248 | fn happy_path() { 249 | let (_rest, _char) = take_ascii_byte(b"5").unwrap(); 250 | } 251 | #[test] 252 | fn fail_on_unicode() { 253 | assert!(take_ascii_byte("🤘 ".as_bytes()).is_err()); 254 | } 255 | } 256 | 257 | #[test] 258 | fn test_take_header_name() { 259 | let (rest, header_name) = take_header_name(FOLDED_HEADER).unwrap(); 260 | assert_eq!(header_name, b"X-Received"); 261 | assert_ne!(rest.len(), 0); 262 | } 263 | 264 | #[test] 265 | fn test_header_content() { 266 | let content = 267 | b"by 2002:ac8:2aed:: with SMTP id c42mr5587158qta.202.1591290821135;\r\n \ 268 | Thu, 05 Jun 2020 10:13:41 -0700 (PDT)\r\n"; 269 | 270 | let (_rest, parsed_header) = take_header_content(&content[..]).unwrap(); 271 | 272 | // header-content does include the final CRLF, that's part of the header 273 | assert_eq!(&content[..content.len() - 2], parsed_header) 274 | } 275 | 276 | mod test_take_header { 277 | use super::*; 278 | 279 | #[test] 280 | fn test_folded() { 281 | let content = 282 | b"by 2002:ac8:2aed:: with SMTP id c42mr5587158qta.202.1591290821135;\r\n \ 283 | Thu, 05 Jun 2020 10:13:41 -0700 (PDT)\r\n"; 284 | 285 | let (rest, (header_name, parsed_content)) = take_header(FOLDED_HEADER).unwrap(); 286 | dbg!(from_utf8(&header_name).unwrap()); 287 | dbg!(from_utf8(&rest).unwrap()); 288 | assert_eq!(rest.len(), 0); 289 | assert_eq!(header_name, &b"X-Received"[..]); 290 | assert_eq!(parsed_content, &content[..content.len() - 2]) 291 | } 292 | 293 | #[test] 294 | fn test_simple() { 295 | let header = "Xref: number.nntp.giganews.com mozilla.dev.platform:47661\r\n"; 296 | 297 | let (rest, (name, content)) = take_header(header.as_bytes()).unwrap(); 298 | 299 | assert_eq!(rest.len(), 0); 300 | assert_eq!(name, header.split(':').next().unwrap().as_bytes()); 301 | assert_eq!( 302 | from_utf8(content).unwrap(), 303 | header.splitn(2, ':').nth(1).map(|s| s.trim()).unwrap() 304 | ) 305 | } 306 | 307 | #[test] 308 | fn test_empty_contents() { 309 | let header = b"X-Spam-Level: \r\n"; 310 | let (_rest, (name, content)) = take_header(header).unwrap(); 311 | assert_eq!(name, b"X-Spam-Level"); 312 | assert_eq!(content, b""); 313 | } 314 | 315 | #[test] 316 | fn test_non_compliant_whitespace() { 317 | let header = b"X-Received: by 2002:a65:508c:: with SMTP id r12mr626047pgp.233.1591751885013; \r\n Tue, 09 Jun 2020 18:18:05 -0700 (PDT)\r\n"; 318 | 319 | let (_rest, (name, content)) = take_header(header).unwrap(); 320 | assert_eq!(name, b"X-Received"); 321 | assert_eq!( 322 | content, 323 | &b"by 2002:a65:508c:: with SMTP id r12mr626047pgp.233.1591751885013; \r\n Tue, 09 Jun 2020 18:18:05 -0700 (PDT)"[..]); 324 | } 325 | } 326 | 327 | #[test] 328 | fn test_take_headers() { 329 | // strip the initial response line 330 | let article = TEXT_ARTICLE.splitn(2, '\n').nth(1).unwrap(); 331 | let (rest, headers) = take_headers(article.as_bytes()).unwrap(); 332 | 333 | println!("{:#?}", headers); 334 | 335 | assert!(rest.starts_with(b"In bug 1630935 [1], I intend to deprecate support for drawing")); 336 | assert!(headers.inner.contains_key("X-Received")); 337 | assert_eq!(headers.get("X-Received").unwrap().content.len(), 2); 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Borrow; 2 | use std::convert::{TryFrom, TryInto}; 3 | use std::net::ToSocketAddrs; 4 | 5 | use log::*; 6 | 7 | use crate::error::{Error, Result}; 8 | 9 | use crate::raw::connection::{ConnectionConfig, NntpConnection}; 10 | use crate::raw::response::RawResponse; 11 | use crate::types::command as cmd; 12 | use crate::types::prelude::*; 13 | 14 | /// A client that returns typed responses and provides state management 15 | /// 16 | /// `NntpClient` is built on top of [`NntpConnection`] and offers several niceties: 17 | /// 18 | /// 1. Responses from the server are typed and semantically validated 19 | /// 2. Management of the connection state (e.g. current group, known capabilities) 20 | /// 21 | /// In exchange for these niceties, `NntpClient` does not provide the low-allocation guarantees 22 | /// that `NntpConnection` does. If you are really concerned about memory management, 23 | /// you may want to use the [`NntpConnection`]. 24 | #[derive(Debug)] 25 | pub struct NntpClient { 26 | conn: NntpConnection, 27 | config: ClientConfig, 28 | capabilities: Capabilities, 29 | group: Option, 30 | } 31 | 32 | impl NntpClient { 33 | /// Get the raw [`NntpConnection`] for the client 34 | /// 35 | /// # Usage 36 | /// 37 | /// NNTP is a **STATEFUL PROTOCOL** and misusing the underlying connection may mess up the 38 | /// state in the client that owns the connection. 39 | /// 40 | /// For example, manually sending a `GROUP` command would leave change the group of 41 | /// the connection but will not update the NntpClient's internal record. 42 | /// 43 | /// Caveat emptor! 44 | pub fn conn(&mut self) -> &mut NntpConnection { 45 | &mut self.conn 46 | } 47 | 48 | /// Send a command 49 | /// 50 | /// This is useful if you want to use a command you have implemented or one that is not 51 | /// provided by a client method 52 | /// 53 | /// # Example 54 | /// 55 | /// Say we have a server that uses mode switching for whatever reason. Brokaw implements 56 | /// a [`ModeReader`](cmd::ModeReader) command but it does not provide a return type. 57 | /// We implement one in the following example 58 | ///
MOTD 59 | /// 60 | /// ```no_run 61 | /// use std::convert::{TryFrom, TryInto}; 62 | /// use brokaw::types::prelude::*; 63 | /// use brokaw::types::command as cmd; 64 | /// 65 | /// struct Motd { 66 | /// posting_allowed: bool, 67 | /// motd: String, 68 | /// } 69 | /// 70 | /// impl TryFrom for Motd { 71 | /// type Error = String; 72 | /// 73 | /// fn try_from(resp: RawResponse) -> Result { 74 | /// let posting_allowed = match resp.code() { 75 | /// ResponseCode::Known(Kind::PostingAllowed) => true, 76 | /// ResponseCode::Known(Kind::PostingNotPermitted) => false, 77 | /// ResponseCode::Known(Kind::PermanentlyUnavailable) => { 78 | /// return Err("Server is gone forever".to_string()); 79 | /// } 80 | /// ResponseCode::Known(Kind::TemporarilyUnavailable) => { 81 | /// return Err("Server is down?".to_string()); 82 | /// } 83 | /// code => return Err(format!("Unexpected {:?}", code)) 84 | /// }; 85 | /// let mut motd = String::from_utf8_lossy(resp.first_line_without_code()) 86 | /// .to_string(); 87 | /// 88 | /// Ok(Motd { posting_allowed, motd }) 89 | /// } 90 | /// } 91 | /// 92 | /// fn main() -> Result<(), Box> { 93 | /// use brokaw::client::{NntpClient, ClientConfig}; 94 | /// let mut client = ClientConfig::default() 95 | /// .connect(("news.modeswitching.notreal", 119))?; 96 | /// 97 | /// let resp: Motd = client.command(cmd::ModeReader)?.try_into()?; 98 | /// println!("Motd: {}", resp.motd); 99 | /// Ok(()) 100 | /// } 101 | /// ``` 102 | ///
103 | pub fn command(&mut self, c: impl NntpCommand) -> Result { 104 | let resp = self.conn.command(&c)?; 105 | Ok(resp) 106 | } 107 | 108 | /// Get the currently selected group 109 | pub fn config(&self) -> &ClientConfig { 110 | &self.config 111 | } 112 | 113 | /// Get the last selected group 114 | pub fn group(&self) -> Option<&Group> { 115 | self.group.as_ref() 116 | } 117 | 118 | /// Select a newsgroup 119 | pub fn select_group(&mut self, name: impl AsRef) -> Result { 120 | let resp = self.conn.command(&cmd::Group(name.as_ref().to_string()))?; 121 | 122 | match resp.code() { 123 | ResponseCode::Known(Kind::GroupSelected) => { 124 | let group = Group::try_from(&resp)?; 125 | self.group = Some(group.clone()); 126 | Ok(group) 127 | } 128 | ResponseCode::Known(Kind::NoSuchNewsgroup) => Err(Error::failure(resp)), 129 | code => Err(Error::Failure { 130 | code, 131 | msg: Some(format!("{}", resp.first_line_to_utf8_lossy())), 132 | resp, 133 | }), 134 | } 135 | } 136 | 137 | /// The capabilities cached in the client 138 | pub fn capabilities(&self) -> &Capabilities { 139 | &self.capabilities 140 | } 141 | 142 | /// Retrieve updated capabilities from the server 143 | pub fn update_capabilities(&mut self) -> Result<&Capabilities> { 144 | let resp = self 145 | .conn 146 | .command(&cmd::Capabilities)? 147 | .fail_unless(Kind::Capabilities)?; 148 | 149 | let capabilities = Capabilities::try_from(&resp)?; 150 | 151 | self.capabilities = capabilities; 152 | 153 | Ok(&self.capabilities) 154 | } 155 | 156 | /// Retrieve an article from the server 157 | /// 158 | /// 159 | /// # Text Articles 160 | /// 161 | /// Binary articles can be converted to text using the [`to_text`](BinaryArticle::to_text) 162 | /// and [`to_text_lossy`](BinaryArticle::to_text) methods. Note that the former is fallible 163 | /// as it will validate that the body of the article is UTF-8. 164 | /// 165 | /// ``` 166 | /// use brokaw::client::NntpClient; 167 | /// use brokaw::error::Result; 168 | /// use brokaw::types::prelude::*; 169 | /// use brokaw::types::command::Article; 170 | /// 171 | /// fn checked_conversion(client: &mut NntpClient) -> Result { 172 | /// client.article(Article::Number(42)) 173 | /// .and_then(|b| b.to_text()) 174 | /// } 175 | /// 176 | /// fn lossy_conversion(client: &mut NntpClient) -> Result { 177 | /// client.article(Article::Number(42)) 178 | /// .map(|b| b.to_text_lossy()) 179 | /// } 180 | /// 181 | /// ``` 182 | pub fn article(&mut self, article: cmd::Article) -> Result { 183 | let resp = self.conn.command(&article)?.fail_unless(Kind::Article)?; 184 | 185 | resp.borrow().try_into() 186 | } 187 | 188 | /// Retrieve the body for an article 189 | pub fn body(&mut self, body: cmd::Body) -> Result { 190 | let resp = self.conn.command(&body)?.fail_unless(Kind::Head)?; 191 | resp.borrow().try_into() 192 | } 193 | 194 | /// Retrieve the headers for an article 195 | pub fn head(&mut self, head: cmd::Head) -> Result { 196 | let resp = self.conn.command(&head)?.fail_unless(Kind::Head)?; 197 | resp.borrow().try_into() 198 | } 199 | 200 | /// Retrieve the status of an article 201 | pub fn stat(&mut self, stat: cmd::Stat) -> Result> { 202 | let resp = self.conn.command(&stat)?; 203 | match resp.code() { 204 | ResponseCode::Known(Kind::ArticleExists) => resp.borrow().try_into().map(Some), 205 | ResponseCode::Known(Kind::NoArticleWithMessageId) 206 | | ResponseCode::Known(Kind::InvalidCurrentArticleNumber) 207 | | ResponseCode::Known(Kind::NoArticleWithNumber) => Ok(None), 208 | _ => Err(Error::failure(resp)), 209 | } 210 | } 211 | 212 | /// Close the connection to the server 213 | pub fn close(&mut self) -> Result { 214 | let resp = self 215 | .conn 216 | .command(&cmd::Quit)? 217 | .fail_unless(Kind::ConnectionClosing)?; 218 | 219 | Ok(resp) 220 | } 221 | } 222 | 223 | /// Configuration for an [`NntpClient`] 224 | #[derive(Clone, Debug, Default)] 225 | pub struct ClientConfig { 226 | authinfo: Option<(String, String)>, 227 | group: Option, 228 | conn_config: ConnectionConfig, 229 | } 230 | 231 | impl ClientConfig { 232 | /// Perform an AUTHINFO USER/PASS authentication after connecting to the server 233 | /// 234 | /// https://tools.ietf.org/html/rfc4643#section-2.3 235 | pub fn authinfo_user_pass( 236 | &mut self, 237 | username: impl AsRef, 238 | password: impl AsRef, 239 | ) -> &mut Self { 240 | self.authinfo = Some((username.as_ref().to_string(), password.as_ref().to_string())); 241 | self 242 | } 243 | 244 | /// Join a group upon connection 245 | /// 246 | /// If this is set to None then no `GROUP` command will be sent when the client is initialized 247 | pub fn group(&mut self, name: Option>) -> &mut Self { 248 | self.group = name.map(|s| s.as_ref().to_string()); 249 | self 250 | } 251 | 252 | /// Set the configuration of the underlying [`NntpConnection`] 253 | pub fn connection_config(&mut self, config: ConnectionConfig) -> &mut Self { 254 | self.conn_config = config; 255 | self 256 | } 257 | 258 | /// Resolves the configuration into a client 259 | pub fn connect(&self, addr: impl ToSocketAddrs) -> Result { 260 | let (mut conn, conn_response) = NntpConnection::connect(addr, self.conn_config.clone())?; 261 | 262 | debug!( 263 | "Connected. Server returned `{}`", 264 | conn_response.first_line_to_utf8_lossy() 265 | ); 266 | 267 | // FIXME(ux) check capabilities before attempting auth info 268 | if let Some((username, password)) = &self.authinfo { 269 | if self.conn_config.tls_config.is_none() { 270 | warn!("TLS is not enabled, credentials will be sent in the clear!"); 271 | } 272 | debug!("Authenticating with AUTHINFO USER/PASS"); 273 | authenticate(&mut conn, username, password)?; 274 | } 275 | 276 | debug!("Retrieving capabilities..."); 277 | let capabilities = get_capabilities(&mut conn)?; 278 | 279 | let group = if let Some(name) = &self.group { 280 | debug!("Connecting to group {}...", name); 281 | select_group(&mut conn, name)?.into() 282 | } else { 283 | debug!("No initial group specified"); 284 | None 285 | }; 286 | 287 | Ok(NntpClient { 288 | conn, 289 | config: self.clone(), 290 | capabilities, 291 | group, 292 | }) 293 | } 294 | } 295 | 296 | impl RawResponse {} 297 | 298 | /// Perform an AUTHINFO USER/PASS exchange 299 | fn authenticate( 300 | conn: &mut NntpConnection, 301 | username: impl AsRef, 302 | password: impl AsRef, 303 | ) -> Result<()> { 304 | debug!("Sending AUTHINFO USER"); 305 | let user_resp = conn.command(&cmd::AuthInfo::User(username.as_ref().to_string()))?; 306 | 307 | if user_resp.code != ResponseCode::from(381) { 308 | return Err(Error::Failure { 309 | code: user_resp.code, 310 | resp: user_resp, 311 | msg: Some("AUTHINFO USER failed".to_string()), 312 | }); 313 | } 314 | 315 | debug!("Sending AUTHINFO PASS"); 316 | let pass_resp = conn.command(&cmd::AuthInfo::Pass(password.as_ref().to_string()))?; 317 | 318 | if pass_resp.code() != ResponseCode::Known(Kind::AuthenticationAccepted) { 319 | return Err(Error::Failure { 320 | code: pass_resp.code, 321 | resp: pass_resp, 322 | msg: Some("AUTHINFO PASS failed".to_string()), 323 | }); 324 | } 325 | debug!("Successfully authenticated"); 326 | 327 | Ok(()) 328 | } 329 | 330 | fn get_capabilities(conn: &mut NntpConnection) -> Result { 331 | let resp = conn.command(&cmd::Capabilities)?; 332 | 333 | if resp.code() != ResponseCode::Known(Kind::Capabilities) { 334 | Err(Error::failure(resp)) 335 | } else { 336 | Capabilities::try_from(&resp) 337 | } 338 | } 339 | 340 | fn select_group(conn: &mut NntpConnection, group: impl AsRef) -> Result { 341 | let resp = conn.command(&cmd::Group(group.as_ref().to_string()))?; 342 | 343 | match resp.code() { 344 | ResponseCode::Known(Kind::GroupSelected) => Group::try_from(&resp), 345 | ResponseCode::Known(Kind::NoSuchNewsgroup) => Err(Error::failure(resp)), 346 | code => Err(Error::Failure { 347 | code, 348 | msg: Some(format!("{}", resp.first_line_to_utf8_lossy())), 349 | resp, 350 | }), 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /src/raw/connection.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::io; 3 | use std::io::{ErrorKind, Write}; 4 | use std::net::{TcpStream, ToSocketAddrs}; 5 | use std::str::FromStr; 6 | use std::time::Duration; 7 | 8 | use log::*; 9 | use native_tls::TlsConnector; 10 | 11 | use crate::raw::compression::{Compression, Decoder}; 12 | use crate::raw::error::Result; 13 | use crate::raw::parse::{is_end_of_datablock, parse_data_block_line, parse_first_line}; 14 | use crate::raw::response::{DataBlocks, RawResponse}; 15 | use crate::raw::stream::NntpStream; 16 | use crate::types::command::NntpCommand; 17 | use crate::types::prelude::*; 18 | 19 | /// TLS configuration for an [`NntpConnection`] 20 | #[derive(Clone)] 21 | pub struct TlsConfig { 22 | connector: TlsConnector, 23 | domain: String, 24 | } 25 | 26 | impl TlsConfig { 27 | /// Create a `TlsConfig` for use with [`NntpConnections`](NntpConnection) 28 | /// 29 | /// The `domain` will be passed to [`TlsConnector::connect`] for certificate validation 30 | /// during any TLS handshakes. 31 | pub fn new(domain: String, connector: TlsConnector) -> Self { 32 | Self { connector, domain } 33 | } 34 | 35 | /// Create a `TlsConfig` with the system default TLS settings 36 | /// 37 | /// The `domain` will be used to validate server certs during any TLS handshakes. 38 | pub fn default_connector(domain: impl AsRef) -> Result { 39 | let connector = TlsConnector::new()?; 40 | Ok(Self { 41 | connector, 42 | domain: domain.as_ref().to_string(), 43 | }) 44 | } 45 | 46 | /// The [`TlsConnector`] associated with the config 47 | pub fn connector(&self) -> &TlsConnector { 48 | &self.connector 49 | } 50 | } 51 | 52 | impl fmt::Debug for TlsConfig { 53 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 54 | f.debug_struct("TlsConfig") 55 | .field("domain", &self.domain) 56 | .finish() 57 | } 58 | } 59 | 60 | /// A raw connection to an NNTP Server 61 | /// 62 | /// `NntpConnection` essentially wraps a stream. It is responsible for serializing commands 63 | /// and deserializing and parsing responses from the server. 64 | /// 65 | /// `NntpConnection` DOES... 66 | /// 67 | /// * Work very hard not to allocate while reading/parsing response 68 | /// * Provide facilities for you to manage your own read buffers 69 | /// * Guarantee that Nntp responses are *framed* properly (though not that they are semantically valid) 70 | /// 71 | /// `NntpConnection` DOES NOT... 72 | /// 73 | /// * Manage any of the stateful details of the connection such as server capabilities, 74 | /// selected group, or selected articles. 75 | /// * Perform detailed parsing of responses. 76 | /// 77 | /// For a more ergonomic client please see the [`NntpClient`](crate::client::NntpClient). 78 | /// 79 | /// ## Usage 80 | /// 81 | /// Please note that NNTP is a STATEFUL protocol and the Connection DOES NOT maintain any information 82 | /// about this state. 83 | /// 84 | /// The [`command`](NntpConnection::command) method can be used perform basic command/receive operations 85 | /// 86 | /// For finer grained control over I/O please see the following: 87 | /// 88 | /// * [`send`](Self::send) & [`send_bytes`](Self::send_bytes) for writing commands 89 | /// * [`read_response`](Self::read_response) & [`read_response_auto`](Self::read_response_auto) 90 | /// for reading responses 91 | /// ## Buffer Management 92 | /// 93 | /// The connection maintains several internal buffers for reading responses. 94 | /// These buffers may grow when reading large responses. 95 | /// 96 | /// The buffer sizes can be tuned via [`ConnectionConfig`], and they can be reset to their 97 | /// preconfigured size by calling [`NntpConnection::reset_buffers`]. 98 | /// 99 | /// ## Example: Getting Capabilities 100 | /// 101 | /// ```no_run 102 | /// use std::time::Duration; 103 | /// use brokaw::ConnectionConfig; 104 | /// use brokaw::types::prelude::*; 105 | /// use brokaw::types::command as cmd; 106 | /// use brokaw::raw::connection::NntpConnection; 107 | /// 108 | /// fn main() -> Result<(), Box::> { 109 | /// let (mut conn, init_resp) = NntpConnection::connect( 110 | /// ("news.mozilla.org", 119), 111 | /// ConnectionConfig::default() 112 | /// .read_timeout(Some(Duration::from_secs(5))) 113 | /// .to_owned(), 114 | /// )?; 115 | /// assert_eq!(init_resp.code(), ResponseCode::Known(Kind::PostingAllowed)); 116 | /// let resp = conn.command(&cmd::Capabilities)?; 117 | /// let data_blocks = resp.data_blocks().unwrap(); 118 | /// assert_eq!(&resp.code(), &ResponseCode::Known(Kind::Capabilities)); 119 | /// 120 | /// let contains_version = data_blocks 121 | /// .lines() 122 | /// .map(|line| std::str::from_utf8(line).unwrap()) 123 | /// .any(|l| l.contains("VERSION")); 124 | /// 125 | /// assert!(contains_version); 126 | /// Ok(()) 127 | /// } 128 | #[derive(Debug)] 129 | pub struct NntpConnection { 130 | stream: BufNntpStream, 131 | first_line_buf: Vec, 132 | data_blocks_buf: Vec, 133 | config: ConnectionConfig, 134 | } 135 | 136 | impl NntpConnection { 137 | /// Connect to an NNTP server 138 | pub fn connect( 139 | addr: impl ToSocketAddrs, 140 | config: ConnectionConfig, 141 | ) -> Result<(Self, RawResponse)> { 142 | let ConnectionConfig { 143 | compression: _, 144 | tls_config, 145 | read_timeout, 146 | write_timeout: _, 147 | first_line_buf_size, 148 | data_blocks_buf_size, 149 | } = config.clone(); 150 | 151 | trace!("Opening TcpStream..."); 152 | let tcp_stream = TcpStream::connect(&addr)?; 153 | 154 | tcp_stream.set_read_timeout(read_timeout)?; 155 | 156 | let nntp_stream = if let Some(TlsConfig { connector, domain }) = tls_config.as_ref() { 157 | trace!("Wrapping TcpStream w/ TlsConnector"); 158 | connector.connect(domain, tcp_stream)?.into() 159 | } else { 160 | trace!("No TLS config providing, continuing with plain text"); 161 | tcp_stream.into() 162 | }; 163 | 164 | let first_line_buf = Vec::with_capacity(first_line_buf_size); 165 | let data_blocks_buf = Vec::with_capacity(data_blocks_buf_size); 166 | 167 | let mut conn = Self { 168 | stream: io::BufReader::new(nntp_stream), 169 | first_line_buf, 170 | data_blocks_buf, 171 | config, 172 | }; 173 | 174 | let initial_resp = conn.read_response_auto()?; 175 | 176 | Ok((conn, initial_resp)) 177 | } 178 | 179 | /// Create an NntpConnection with the default configuration 180 | pub fn with_defaults(addr: impl ToSocketAddrs) -> Result<(Self, RawResponse)> { 181 | Self::connect(addr, Default::default()) 182 | } 183 | 184 | /// Send a command to the server and read the response 185 | /// 186 | /// This function will: 187 | /// 1. Block while reading the response 188 | /// 2. Parse the response 189 | /// 2. This function *may* allocate depending on the size of the response 190 | pub fn command(&mut self, command: &C) -> Result { 191 | self.send(command)?; 192 | let resp = self.read_response_auto()?; 193 | Ok(resp) 194 | } 195 | 196 | /// Send a command and specify whether the response is multiline 197 | pub fn command_multiline( 198 | &mut self, 199 | command: &C, 200 | is_multiline: bool, 201 | ) -> Result { 202 | self.send(command)?; 203 | let resp = self.read_response(Some(is_multiline))?; 204 | Ok(resp) 205 | } 206 | 207 | /// Send a command to the server, returning the number of bytes written 208 | /// 209 | /// The caller is responsible for reading the response 210 | pub fn send(&mut self, command: &C) -> Result { 211 | let bytes = self.send_bytes(command.encode())?; 212 | Ok(bytes) 213 | } 214 | 215 | /// Send a command to the server, returning the number of bytes written 216 | /// 217 | /// This function can be used for commands not implemented/supported by the library 218 | /// (e.g. `LISTGROUP misc.test 3000238-3000248`) 219 | /// 220 | /// * The caller is responsible for reading the response 221 | /// * The command SHOULD NOT include the CRLF terminator 222 | pub fn send_bytes(&mut self, command: impl AsRef<[u8]>) -> Result { 223 | let writer = self.stream.get_mut(); 224 | // Write the command and terminal char 225 | let bytes = writer.write(command.as_ref())? + writer.write(b"\r\n")?; 226 | // Flush the buffer 227 | writer.flush()?; 228 | Ok(bytes) 229 | } 230 | 231 | /// Read any data from the stream into a RawResponse 232 | /// 233 | /// This function attempts to automatically determine if the response is muliti-line based 234 | /// on the response code. 235 | /// 236 | /// Note that this *will not* work for response codes that are not supported by [`Kind`]. 237 | /// If you are using extensions/commands not implemented by Brokaw, please use 238 | /// [`NntpConnection::read_response`] to configure multiline support manually. 239 | pub fn read_response_auto(&mut self) -> Result { 240 | self.read_response(None) 241 | } 242 | 243 | /// Read an NNTP response from the connection 244 | /// 245 | /// # Multiline Responses 246 | /// 247 | /// If `is_multiline` is set to None then the connection use [`ResponseCode::is_multiline`] 248 | /// to determine if it should expect a multiline response. 249 | /// This behavior can be overridden by manually specifying `Some(true)` or `Some(false)` 250 | pub fn read_response(&mut self, is_multiline: Option) -> Result { 251 | self.first_line_buf.truncate(0); 252 | self.data_blocks_buf.truncate(0); 253 | let resp_code = read_initial_response(&mut self.stream, &mut self.first_line_buf)?; 254 | 255 | let data_blocks = match (is_multiline, resp_code.is_multiline()) { 256 | // Check for data blocks if the caller tells us to OR the kind is multiline 257 | (Some(true), _) | (_, true) => { 258 | trace!("Parsing data blocks for response {}", u16::from(resp_code)); 259 | 260 | // FIXME(ops): Consider pre-allocating this buffer 261 | let mut line_boundaries = Vec::with_capacity(10); 262 | 263 | let mut stream = match self.config.compression { 264 | Some(c) if c.use_decoder(&self.first_line_buf) => { 265 | trace!("Compression enabled, wrapping stream with decoder"); 266 | c.decoder(&mut self.stream) 267 | } 268 | _ => { 269 | trace!("Using passthrough decoder"); 270 | Decoder::Passthrough(&mut self.stream) 271 | } 272 | }; 273 | 274 | read_data_blocks(&mut stream, &mut self.data_blocks_buf, &mut line_boundaries)?; 275 | 276 | Some(DataBlocks { 277 | payload: self.data_blocks_buf.clone(), 278 | line_boundaries, 279 | }) 280 | } 281 | (Some(false), _) => None, // The caller says not to look for data blocks 282 | _ => None, 283 | }; 284 | 285 | let resp = RawResponse { 286 | code: resp_code, 287 | first_line: self.first_line_buf.clone(), 288 | data_blocks, 289 | }; 290 | 291 | self.reset_buffers(); 292 | 293 | Ok(resp) 294 | } 295 | 296 | /// Reset the connection's buffers to their initial size 297 | /// 298 | /// This should be run after reading responses to prevent the buffers from growing unbounded 299 | fn reset_buffers(&mut self) { 300 | // Honestly we should probably just use the bytes create, it seems better suited to 301 | // what we want in this layer 302 | self.first_line_buf 303 | .truncate(self.config.first_line_buf_size); 304 | self.first_line_buf.shrink_to_fit(); 305 | 306 | self.data_blocks_buf 307 | .truncate(self.config.data_blocks_buf_size); 308 | self.data_blocks_buf.shrink_to_fit(); 309 | } 310 | 311 | /// Get a ref to the underlying NntpStream 312 | pub fn stream(&self) -> &io::BufReader { 313 | &self.stream 314 | } 315 | 316 | /// Get a mutable ref to the underlying NntpStream 317 | /// 318 | /// This can be useful if you want to handle response parsing and/or control buffering 319 | pub fn stream_mut(&mut self) -> &mut io::BufReader { 320 | &mut self.stream 321 | } 322 | 323 | /// Get the configuration of the connection 324 | pub fn config(&self) -> &ConnectionConfig { 325 | &self.config 326 | } 327 | } 328 | 329 | /// A buffered NntpStream 330 | pub type BufNntpStream = io::BufReader; 331 | 332 | /// A builder for [`NntpConnection`] 333 | #[derive(Clone, Debug)] 334 | pub struct ConnectionConfig { 335 | pub(crate) compression: Option, 336 | pub(crate) tls_config: Option, 337 | pub(crate) read_timeout: Option, 338 | pub(crate) write_timeout: Option, 339 | pub(crate) first_line_buf_size: usize, 340 | pub(crate) data_blocks_buf_size: usize, 341 | } 342 | 343 | impl Default for ConnectionConfig { 344 | fn default() -> Self { 345 | ConnectionConfig { 346 | compression: None, 347 | tls_config: None, 348 | read_timeout: None, 349 | write_timeout: None, 350 | first_line_buf_size: 128, 351 | data_blocks_buf_size: 16 * 1024, 352 | } 353 | } 354 | } 355 | 356 | impl ConnectionConfig { 357 | /// Create a new connection builder 358 | pub fn new() -> ConnectionConfig { 359 | Default::default() 360 | } 361 | 362 | /// Set the compression type on the connection 363 | pub fn compression(&mut self, compression: Option) -> &mut Self { 364 | self.compression = compression; 365 | self 366 | } 367 | 368 | /// Configure TLS on the connection 369 | pub fn tls_config(&mut self, config: Option) -> &mut Self { 370 | self.tls_config = config; 371 | self 372 | } 373 | 374 | /// Use the default TLS implementation 375 | pub fn default_tls(&mut self, domain: impl AsRef) -> Result<&mut Self> { 376 | let domain = domain.as_ref().to_string(); 377 | let tls_config = TlsConfig::default_connector(domain)?; 378 | self.tls_config = Some(tls_config); 379 | 380 | Ok(self) 381 | } 382 | 383 | /// Set the read timeout on the socket 384 | pub fn read_timeout(&mut self, dur: Option) -> &mut Self { 385 | self.read_timeout = dur; 386 | self 387 | } 388 | 389 | /// Set the size of the buffer used to read the first line 390 | pub fn first_line_buf_size(&mut self, s: usize) -> &mut Self { 391 | self.first_line_buf_size = s; 392 | self 393 | } 394 | 395 | /// Set the size of the buffer used to read data blocks 396 | pub fn data_blocks_buf_size(&mut self, s: usize) -> &mut Self { 397 | self.data_blocks_buf_size = s; 398 | self 399 | } 400 | 401 | /// Create a connection from the config 402 | pub fn connect(&self, addr: impl ToSocketAddrs) -> Result<(NntpConnection, RawResponse)> { 403 | NntpConnection::connect(addr, self.clone()) 404 | } 405 | } 406 | 407 | /// Read the initial response from a stream 408 | /// 409 | /// Per [RFC 3977](https://tools.ietf.org/html/rfc3977#section-3.1) the initial response 410 | /// should not exceed 512 bytes 411 | fn read_initial_response( 412 | stream: &mut S, 413 | buffer: &mut Vec, 414 | ) -> Result { 415 | stream.read_until(b'\n', buffer)?; 416 | let (_initial_line_buffer, resp) = parse_first_line(&buffer).map_err(|_e| { 417 | io::Error::new( 418 | ErrorKind::InvalidData, 419 | "Failed to parse first line of response", 420 | ) 421 | })?; 422 | 423 | // This made it past the parser -> infallible 424 | let code_str = std::str::from_utf8(resp.code).unwrap(); 425 | // All three digit integers will fit w/in u16 -> also infallible 426 | let code_u16 = u16::from_str(code_str).unwrap(); 427 | 428 | Ok(code_u16.into()) 429 | } 430 | 431 | /// Read multi-line data block portion from a stream 432 | /// 433 | /// * The data will be read line-by-line into the provided `buffer` 434 | /// * The `line_boundaries` vector will contain a list two-tuples containing the start and ending 435 | /// of every line within the `buffer` 436 | /// * Note that depending on the command the total data size may be on the order of several megabytes! 437 | fn read_data_blocks( 438 | stream: &mut S, 439 | buffer: &mut Vec, 440 | line_boundaries: &mut Vec<(usize, usize)>, 441 | ) -> Result<()> { 442 | let mut read_head = 0; 443 | trace!("Reading data blocks..."); 444 | 445 | // n.b. - icky imperative style so that we have zero allocations outside of the reader 446 | loop { 447 | // n.b. - read_until will _append_ data from the current end of the vector 448 | let bytes_read = stream.read_until(b'\n', buffer)?; 449 | 450 | let (_empty, line) = parse_data_block_line(&buffer[read_head..]).map_err(|e| { 451 | trace!("parse_data_block_line failed -- {:?}", e); 452 | io::Error::new( 453 | ErrorKind::InvalidData, 454 | format!( 455 | "Failed to parse line {} of data blocks", 456 | line_boundaries.len() + 1 457 | ), 458 | ) 459 | })?; 460 | // we keep track of line boundaries rather than slices as borrowck won't allow 461 | // us to reuse the buffer AND keep track of sub-slices within it 462 | line_boundaries.push((read_head, read_head + bytes_read)); 463 | 464 | // n.b. we use bytes read rather than the length of line so that we don't drop the 465 | // terminators 466 | read_head += bytes_read; 467 | 468 | if is_end_of_datablock(line) { 469 | trace!( 470 | "Read {} bytes of data across {} lines", 471 | read_head, 472 | line_boundaries.len() 473 | ); 474 | break; 475 | } 476 | } 477 | 478 | Ok(()) 479 | } 480 | --------------------------------------------------------------------------------