feat(transport): Use nom for parsing smtp responses
This commit is contained in:
@@ -16,6 +16,7 @@ travis-ci = { repository = "lettre/lettre" }
|
||||
|
||||
[dependencies]
|
||||
log = "^0.3"
|
||||
nom = { version = "^3.2", optional = true }
|
||||
bufstream = { version = "^0.1", optional = true }
|
||||
native-tls = { version = "^0.1", optional = true }
|
||||
base64 = { version = "^0.8", optional = true }
|
||||
@@ -34,7 +35,7 @@ unstable = []
|
||||
serde-impls = ["serde", "serde_derive"]
|
||||
file-transport = ["serde-impls", "serde_json"]
|
||||
crammd5-auth = ["rust-crypto", "hex"]
|
||||
smtp-transport = ["bufstream", "native-tls", "base64"]
|
||||
smtp-transport = ["bufstream", "native-tls", "base64", "nom"]
|
||||
sendmail-transport = []
|
||||
|
||||
[[example]]
|
||||
|
||||
@@ -23,6 +23,9 @@ extern crate serde_json;
|
||||
#[cfg(feature = "serde-impls")]
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
#[cfg(feature = "smtp-transport")]
|
||||
#[macro_use]
|
||||
extern crate nom;
|
||||
|
||||
#[cfg(feature = "smtp-transport")]
|
||||
pub mod smtp;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
//! SMTP client
|
||||
|
||||
use bufstream::BufStream;
|
||||
use nom::ErrorKind as NomErrorKind;
|
||||
use smtp::{CRLF, MESSAGE_ENDING};
|
||||
use smtp::authentication::{Credentials, Mechanism};
|
||||
use smtp::client::net::{ClientTlsParameters, Connector, NetworkStream, Timeout};
|
||||
use smtp::commands::*;
|
||||
use smtp::error::{Error, SmtpResult};
|
||||
use smtp::response::ResponseParser;
|
||||
use smtp::response::Response;
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::string::String;
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
pub mod net;
|
||||
pub mod mock;
|
||||
|
||||
@@ -73,12 +75,6 @@ fn escape_crlf(string: &str) -> String {
|
||||
string.replace(CRLF, "<CRLF>")
|
||||
}
|
||||
|
||||
/// Returns the string removing all the CRLF
|
||||
/// Used for debug displays
|
||||
fn remove_crlf(string: &str) -> String {
|
||||
string.replace(CRLF, "")
|
||||
}
|
||||
|
||||
/// Structure that implements the SMTP client
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Client<S: Write + Read = NetworkStream> {
|
||||
@@ -253,31 +249,33 @@ impl<S: Connector + Write + Read + Timeout + Debug> Client<S> {
|
||||
/// Gets the SMTP response
|
||||
fn get_reply(&mut self) -> SmtpResult {
|
||||
|
||||
let mut parser = ResponseParser::default();
|
||||
let mut raw_response = String::new();
|
||||
let mut response = raw_response.parse::<Response>();
|
||||
|
||||
let mut line = String::new();
|
||||
self.stream.as_mut().unwrap().read_line(&mut line)?;
|
||||
|
||||
debug!("Read: {}", escape_crlf(line.as_ref()));
|
||||
|
||||
while parser.read_line(remove_crlf(line.as_ref()).as_ref())? {
|
||||
line.clear();
|
||||
self.stream.as_mut().unwrap().read_line(&mut line)?;
|
||||
while response.is_err() {
|
||||
if response.as_ref().err().unwrap() != &NomErrorKind::Complete {
|
||||
break;
|
||||
}
|
||||
// TODO read more than one line
|
||||
self.stream.as_mut().unwrap().read_line(&mut raw_response)?;
|
||||
response = raw_response.parse::<Response>();
|
||||
}
|
||||
|
||||
let response = parser.response()?;
|
||||
debug!("Read: {}", escape_crlf(raw_response.as_ref()));
|
||||
|
||||
if response.is_positive() {
|
||||
Ok(response)
|
||||
let final_response = response?;
|
||||
|
||||
if final_response.is_positive() {
|
||||
Ok(final_response)
|
||||
} else {
|
||||
Err(From::from(response))
|
||||
Err(From::from(final_response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::{ClientCodec, escape_crlf, remove_crlf};
|
||||
use super::{ClientCodec, escape_crlf};
|
||||
|
||||
#[test]
|
||||
fn test_codec() {
|
||||
@@ -299,16 +297,6 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_crlf() {
|
||||
assert_eq!(remove_crlf("\r\n"), "");
|
||||
assert_eq!(remove_crlf("EHLO my_name\r\n"), "EHLO my_name");
|
||||
assert_eq!(
|
||||
remove_crlf("EHLO my_name\r\nSIZE 42\r\n"),
|
||||
"EHLO my_nameSIZE 42"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_crlf() {
|
||||
assert_eq!(escape_crlf("\r\n"), "<CRLF>");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use self::Error::*;
|
||||
use base64::DecodeError;
|
||||
use native_tls;
|
||||
use nom;
|
||||
use smtp::response::{Response, Severity};
|
||||
use std::error::Error as StdError;
|
||||
use std::fmt;
|
||||
@@ -35,6 +36,8 @@ pub enum Error {
|
||||
Io(io::Error),
|
||||
/// TLS error
|
||||
Tls(native_tls::Error),
|
||||
/// Parsing error
|
||||
Parsing(nom::simple_errors::Err),
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
@@ -68,6 +71,7 @@ impl StdError for Error {
|
||||
Client(err) => err,
|
||||
Io(ref err) => err.description(),
|
||||
Tls(ref err) => err.description(),
|
||||
Parsing(ref err) => err.description(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +81,7 @@ impl StdError for Error {
|
||||
Utf8Parsing(ref err) => Some(&*err as &StdError),
|
||||
Io(ref err) => Some(&*err as &StdError),
|
||||
Tls(ref err) => Some(&*err as &StdError),
|
||||
Parsing(ref err) => Some(&*err as &StdError),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -94,6 +99,12 @@ impl From<native_tls::Error> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nom::simple_errors::Err> for Error {
|
||||
fn from(err: nom::simple_errors::Err) -> Error {
|
||||
Parsing(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Response> for Error {
|
||||
fn from(response: Response) -> Error {
|
||||
match response.code.severity {
|
||||
|
||||
@@ -106,6 +106,10 @@ impl ServerInfo {
|
||||
let mut features: HashSet<Extension> = HashSet::new();
|
||||
|
||||
for line in response.message.as_slice() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let splitted: Vec<&str> = line.split_whitespace().collect();
|
||||
match splitted[0] {
|
||||
"8BITMIME" => {
|
||||
|
||||
@@ -1,53 +1,43 @@
|
||||
//! SMTP response, containing a mandatory return code and an optional text
|
||||
//! message
|
||||
|
||||
use self::Category::*;
|
||||
use self::Severity::*;
|
||||
use smtp::error::{Error, SmtpResult};
|
||||
use nom::{ErrorKind as NomErrorKind, IResult as NomResult, crlf};
|
||||
|
||||
use nom::simple_errors::Err as NomError;
|
||||
use std::fmt::{Display, Formatter, Result};
|
||||
use std::result;
|
||||
use std::str::FromStr;
|
||||
use std::str::{FromStr, from_utf8};
|
||||
|
||||
|
||||
/// First digit indicates severity
|
||||
#[derive(PartialEq, Eq, Copy, Clone, Debug)]
|
||||
pub enum Severity {
|
||||
/// 2yx
|
||||
PositiveCompletion,
|
||||
PositiveCompletion = 2,
|
||||
/// 3yz
|
||||
PositiveIntermediate,
|
||||
PositiveIntermediate = 3,
|
||||
/// 4yz
|
||||
TransientNegativeCompletion,
|
||||
TransientNegativeCompletion = 4,
|
||||
/// 5yz
|
||||
PermanentNegativeCompletion,
|
||||
PermanentNegativeCompletion = 5,
|
||||
}
|
||||
|
||||
impl FromStr for Severity {
|
||||
type Err = Error;
|
||||
fn from_str(s: &str) -> result::Result<Severity, Error> {
|
||||
match s {
|
||||
"2" => Ok(PositiveCompletion),
|
||||
"3" => Ok(PositiveIntermediate),
|
||||
"4" => Ok(TransientNegativeCompletion),
|
||||
"5" => Ok(PermanentNegativeCompletion),
|
||||
_ => Err(Error::ResponseParsing(
|
||||
"First digit must be between 2 and 5",
|
||||
)),
|
||||
type Err = NomError;
|
||||
|
||||
fn from_str(s: &str) -> result::Result<Severity, NomError> {
|
||||
match parse_severity(s.as_bytes()) {
|
||||
NomResult::Done(_, res) => Ok(res),
|
||||
NomResult::Error(e) => Err(e),
|
||||
NomResult::Incomplete(_) => Err(NomErrorKind::Complete),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Severity {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match *self {
|
||||
PositiveCompletion => 2,
|
||||
PositiveIntermediate => 3,
|
||||
TransientNegativeCompletion => 4,
|
||||
PermanentNegativeCompletion => 5,
|
||||
}
|
||||
)
|
||||
write!(f, "{}", *self as u8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,50 +45,34 @@ impl Display for Severity {
|
||||
#[derive(PartialEq, Eq, Copy, Clone, Debug)]
|
||||
pub enum Category {
|
||||
/// x0z
|
||||
Syntax,
|
||||
Syntax = 0,
|
||||
/// x1z
|
||||
Information,
|
||||
Information = 1,
|
||||
/// x2z
|
||||
Connections,
|
||||
Connections = 2,
|
||||
/// x3z
|
||||
Unspecified3,
|
||||
Unspecified3 = 3,
|
||||
/// x4z
|
||||
Unspecified4,
|
||||
Unspecified4 = 4,
|
||||
/// x5z
|
||||
MailSystem,
|
||||
MailSystem = 5,
|
||||
}
|
||||
|
||||
impl FromStr for Category {
|
||||
type Err = Error;
|
||||
fn from_str(s: &str) -> result::Result<Category, Error> {
|
||||
match s {
|
||||
"0" => Ok(Syntax),
|
||||
"1" => Ok(Information),
|
||||
"2" => Ok(Connections),
|
||||
"3" => Ok(Unspecified3),
|
||||
"4" => Ok(Unspecified4),
|
||||
"5" => Ok(MailSystem),
|
||||
_ => Err(Error::ResponseParsing(
|
||||
"Second digit must be between 0 and 5",
|
||||
)),
|
||||
type Err = NomError;
|
||||
|
||||
fn from_str(s: &str) -> result::Result<Category, NomError> {
|
||||
match parse_category(s.as_bytes()) {
|
||||
NomResult::Done(_, res) => Ok(res),
|
||||
NomResult::Error(e) => Err(e),
|
||||
NomResult::Incomplete(_) => Err(NomErrorKind::Complete),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Category {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match *self {
|
||||
Syntax => 0,
|
||||
Information => 1,
|
||||
Connections => 2,
|
||||
Unspecified3 => 3,
|
||||
Unspecified4 => 4,
|
||||
MailSystem => 5,
|
||||
}
|
||||
)
|
||||
write!(f, "{}", *self as u8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,17 +81,18 @@ impl Display for Category {
|
||||
pub struct Detail(pub u8);
|
||||
|
||||
impl FromStr for Detail {
|
||||
type Err = Error;
|
||||
fn from_str(s: &str) -> result::Result<Detail, Error> {
|
||||
match s.parse::<u8>() {
|
||||
Ok(d) if d < 10 => Ok(Detail(d)),
|
||||
_ => Err(Error::ResponseParsing(
|
||||
"Third digit must be between 0 and 9",
|
||||
)),
|
||||
type Err = NomError;
|
||||
|
||||
fn from_str(s: &str) -> result::Result<Detail, NomError> {
|
||||
match parse_detail(s.as_bytes()) {
|
||||
NomResult::Done(_, res) => Ok(res),
|
||||
NomResult::Error(e) => Err(e),
|
||||
NomResult::Incomplete(_) => Err(NomErrorKind::Complete),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Display for Detail {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result {
|
||||
write!(f, "{}", self.0)
|
||||
@@ -142,29 +117,13 @@ impl Display for Code {
|
||||
}
|
||||
|
||||
impl FromStr for Code {
|
||||
type Err = Error;
|
||||
type Err = NomError;
|
||||
|
||||
#[inline]
|
||||
fn from_str(s: &str) -> result::Result<Code, Error> {
|
||||
if s.len() == 3 {
|
||||
match (
|
||||
s[0..1].parse::<Severity>(),
|
||||
s[1..2].parse::<Category>(),
|
||||
s[2..3].parse::<Detail>(),
|
||||
) {
|
||||
(Ok(severity), Ok(category), Ok(detail)) => {
|
||||
Ok(Code {
|
||||
severity: severity,
|
||||
category: category,
|
||||
detail: detail,
|
||||
})
|
||||
}
|
||||
_ => Err(Error::ResponseParsing("Could not parse response code")),
|
||||
}
|
||||
} else {
|
||||
Err(Error::ResponseParsing(
|
||||
"Wrong code length (should be 3 digit)",
|
||||
))
|
||||
fn from_str(s: &str) -> result::Result<Code, NomError> {
|
||||
match parse_code(s.as_bytes()) {
|
||||
NomResult::Done(_, res) => Ok(res),
|
||||
NomResult::Error(e) => Err(e),
|
||||
NomResult::Incomplete(_) => Err(NomErrorKind::Complete),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,60 +143,6 @@ impl Code {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an SMTP response
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Default)]
|
||||
pub struct ResponseParser {
|
||||
/// Response code
|
||||
code: Option<Code>,
|
||||
/// Server response string (optional)
|
||||
/// Handle multiline responses
|
||||
message: Vec<String>,
|
||||
}
|
||||
|
||||
impl ResponseParser {
|
||||
/// Parses a line and return a `bool` indicating if there are more lines to come
|
||||
pub fn read_line(&mut self, line: &str) -> result::Result<bool, Error> {
|
||||
|
||||
if line.len() < 3 {
|
||||
return Err(Error::ResponseParsing(
|
||||
"Incorrect response code (should be 3 digits)",
|
||||
));
|
||||
}
|
||||
|
||||
match self.code {
|
||||
Some(ref code) => {
|
||||
if code.to_string() != line[0..3] {
|
||||
return Err(Error::ResponseParsing(
|
||||
"Response code has changed during a \
|
||||
reponse",
|
||||
));
|
||||
}
|
||||
}
|
||||
None => self.code = Some(line[0..3].parse::<Code>()?),
|
||||
}
|
||||
|
||||
if line.len() > 4 {
|
||||
self.message.push(line[4..].to_string());
|
||||
Ok(line.as_bytes()[3] == b'-')
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a response from a `ResponseParser`
|
||||
pub fn response(self) -> SmtpResult {
|
||||
match self.code {
|
||||
Some(code) => Ok(Response::new(code, self.message)),
|
||||
None => {
|
||||
Err(Error::ResponseParsing(
|
||||
"Incomplete response, could not read response \
|
||||
code",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains an SMTP reply, with separated code and message
|
||||
///
|
||||
/// The text message is optional, only the code is mandatory
|
||||
@@ -250,6 +155,18 @@ pub struct Response {
|
||||
pub message: Vec<String>,
|
||||
}
|
||||
|
||||
impl FromStr for Response {
|
||||
type Err = NomError;
|
||||
|
||||
fn from_str(s: &str) -> result::Result<Response, NomError> {
|
||||
match parse_response(s.as_bytes()) {
|
||||
NomResult::Done(_, res) => Ok(res),
|
||||
NomResult::Error(e) => Err(e),
|
||||
NomResult::Incomplete(_) => Err(NomErrorKind::Complete),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Creates a new `Response`
|
||||
pub fn new(code: Code, message: Vec<String>) -> Response {
|
||||
@@ -285,9 +202,111 @@ impl Response {
|
||||
}
|
||||
}
|
||||
|
||||
// Parsers (originaly from tokio-smtp)
|
||||
|
||||
named!(parse_code<Code>,
|
||||
map!(
|
||||
tuple!(parse_severity, parse_category, parse_detail),
|
||||
|(severity, category, detail)| {
|
||||
Code {
|
||||
severity: severity,
|
||||
category: category,
|
||||
detail: detail,
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
named!(parse_detail<Detail>,
|
||||
complete!(alt!(
|
||||
tag!("0") => { |_| Detail(0) } |
|
||||
tag!("1") => { |_| Detail(1) } |
|
||||
tag!("2") => { |_| Detail(2) } |
|
||||
tag!("3") => { |_| Detail(3) } |
|
||||
tag!("4") => { |_| Detail(4) } |
|
||||
tag!("5") => { |_| Detail(5) } |
|
||||
tag!("6") => { |_| Detail(6) } |
|
||||
tag!("7") => { |_| Detail(7) } |
|
||||
tag!("8") => { |_| Detail(8) } |
|
||||
tag!("9") => { |_| Detail(9) }
|
||||
))
|
||||
);
|
||||
|
||||
named!(parse_severity<Severity>,
|
||||
complete!(alt!(
|
||||
tag!("2") => { |_| Severity::PositiveCompletion } |
|
||||
tag!("3") => { |_| Severity::PositiveIntermediate } |
|
||||
tag!("4") => { |_| Severity::TransientNegativeCompletion } |
|
||||
tag!("5") => { |_| Severity::PermanentNegativeCompletion }
|
||||
))
|
||||
);
|
||||
|
||||
named!(parse_category<Category>,
|
||||
complete!(alt!(
|
||||
tag!("0") => { |_| Category::Syntax } |
|
||||
tag!("1") => { |_| Category::Information } |
|
||||
tag!("2") => { |_| Category::Connections } |
|
||||
tag!("3") => { |_| Category::Unspecified3 } |
|
||||
tag!("4") => { |_| Category::Unspecified4 } |
|
||||
tag!("5") => { |_| Category::MailSystem }
|
||||
))
|
||||
);
|
||||
|
||||
named!(parse_response<Response>,
|
||||
map_res!(
|
||||
tuple!(
|
||||
// Parse any number of continuation lines.
|
||||
many0!(
|
||||
tuple!(
|
||||
parse_code,
|
||||
preceded!(
|
||||
char!('-'),
|
||||
take_until_and_consume!(b"\r\n".as_ref())
|
||||
)
|
||||
)
|
||||
),
|
||||
// Parse the final line.
|
||||
tuple!(
|
||||
parse_code,
|
||||
terminated!(
|
||||
opt!(
|
||||
preceded!(
|
||||
char!(' '),
|
||||
take_until!(b"\r\n".as_ref())
|
||||
)
|
||||
),
|
||||
crlf
|
||||
)
|
||||
)
|
||||
),
|
||||
|(lines, (last_code, last_line)): (Vec<_>, _)| {
|
||||
// Check that all codes are equal.
|
||||
if !lines.iter().all(|&(ref code, _)| *code == last_code) {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
// Extract text from lines, and append last line.
|
||||
let mut lines = lines.into_iter()
|
||||
.map(|(_, text)| text)
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(text) = last_line {
|
||||
lines.push(text);
|
||||
}
|
||||
|
||||
Ok(Response {
|
||||
code: last_code,
|
||||
message: lines.into_iter()
|
||||
.map(|line| from_utf8(line).map(|s| s.to_string()))
|
||||
.collect::<result::Result<Vec<_>, _>>()
|
||||
.map_err(|_| ())?,
|
||||
})
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::{Category, Code, Detail, Response, ResponseParser, Severity};
|
||||
use super::{Category, Code, Detail, Response, Severity};
|
||||
|
||||
#[test]
|
||||
fn test_severity_from_str() {
|
||||
@@ -300,7 +319,7 @@ mod test {
|
||||
Severity::TransientNegativeCompletion
|
||||
);
|
||||
assert!("1".parse::<Severity>().is_err());
|
||||
assert!("51".parse::<Severity>().is_err());
|
||||
assert!("a51".parse::<Severity>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -356,12 +375,12 @@ mod test {
|
||||
detail: "1".parse::<Detail>().unwrap(),
|
||||
}
|
||||
);
|
||||
assert!("2222".parse::<Code>().is_err());
|
||||
assert!("r2222".parse::<Code>().is_err());
|
||||
assert!("aaa".parse::<Code>().is_err());
|
||||
assert!("-32".parse::<Code>().is_err());
|
||||
assert!("-333".parse::<Code>().is_err());
|
||||
assert!("".parse::<Code>().is_err());
|
||||
assert!("292".parse::<Code>().is_err());
|
||||
assert!("9292".parse::<Code>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -423,35 +442,6 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_parser() {
|
||||
let mut parser = ResponseParser::default();
|
||||
|
||||
assert!(parser.read_line("250-me").unwrap());
|
||||
assert!(parser.read_line("250-8BITMIME").unwrap());
|
||||
assert!(parser.read_line("250-SIZE 42").unwrap());
|
||||
assert!(!parser.read_line("250 AUTH PLAIN CRAM-MD5").unwrap());
|
||||
|
||||
let response = parser.response().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
Response {
|
||||
code: Code {
|
||||
severity: Severity::PositiveCompletion,
|
||||
category: Category::MailSystem,
|
||||
detail: Detail(0),
|
||||
},
|
||||
message: vec![
|
||||
"me".to_string(),
|
||||
"8BITMIME".to_string(),
|
||||
"SIZE 42".to_string(),
|
||||
"AUTH PLAIN CRAM-MD5".to_string(),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_is_positive() {
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user