mirror of
https://github.com/ZeusWPI/ZNS.git
synced 2024-11-21 21:41:10 +01:00
Respond with fixed SOA of zones
This commit is contained in:
parent
4939d2b3e1
commit
c9767db879
13 changed files with 209 additions and 95 deletions
|
@ -1,13 +1,14 @@
|
|||
use std::{env, net::IpAddr, sync::OnceLock};
|
||||
|
||||
use dotenvy::dotenv;
|
||||
use zns::labelstring::LabelString;
|
||||
|
||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||
|
||||
pub struct Config {
|
||||
pub zauth_url: String,
|
||||
pub db_uri: String,
|
||||
pub authoritative_zone: Vec<String>,
|
||||
pub authoritative_zone: LabelString,
|
||||
pub port: u16,
|
||||
pub address: IpAddr,
|
||||
}
|
||||
|
@ -25,11 +26,7 @@ impl Config {
|
|||
Config {
|
||||
db_uri: env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
|
||||
zauth_url: env::var("ZAUTH_URL").expect("ZAUTH_URL must be set"),
|
||||
authoritative_zone: env::var("ZONE")
|
||||
.expect("ZONE must be set")
|
||||
.split('.')
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
authoritative_zone: LabelString::from(&env::var("ZONE").expect("ZONE must be set")),
|
||||
port: env::var("ZNS_PORT")
|
||||
.map(|v| v.parse::<u16>().expect("ZNS_PORT is invalid"))
|
||||
.unwrap_or(5333),
|
||||
|
|
|
@ -2,6 +2,7 @@ use diesel::prelude::*;
|
|||
use diesel::sql_types::Text;
|
||||
use zns::{
|
||||
errors::ZNSError,
|
||||
labelstring::LabelString,
|
||||
structs::{Class, Type, RR},
|
||||
};
|
||||
|
||||
|
@ -103,7 +104,7 @@ pub fn insert_into_database(rr: &RR, connection: &mut PgConnection) -> Result<()
|
|||
}
|
||||
|
||||
let record = Record {
|
||||
name: rr.name.join("."),
|
||||
name: rr.name.to_string(),
|
||||
_type: rr._type.clone().into(),
|
||||
class: rr.class.clone().into(),
|
||||
ttl: rr.ttl,
|
||||
|
@ -119,14 +120,14 @@ pub fn insert_into_database(rr: &RR, connection: &mut PgConnection) -> Result<()
|
|||
}
|
||||
|
||||
pub fn get_from_database(
|
||||
name: &[String],
|
||||
name: &LabelString,
|
||||
_type: Option<Type>,
|
||||
class: Class,
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<Vec<RR>, ZNSError> {
|
||||
let records = Record::get(
|
||||
connection,
|
||||
name.join("."),
|
||||
name.to_string(),
|
||||
_type.map(|t| t.into()),
|
||||
class.into(),
|
||||
)
|
||||
|
@ -137,7 +138,7 @@ pub fn get_from_database(
|
|||
Ok(records
|
||||
.into_iter()
|
||||
.map(|record| RR {
|
||||
name: record.name.split('.').map(str::to_string).collect(),
|
||||
name: LabelString::from(&record.name),
|
||||
_type: Type::from(record._type as u16),
|
||||
class: Class::from(record.class as u16),
|
||||
ttl: record.ttl,
|
||||
|
@ -149,7 +150,7 @@ pub fn get_from_database(
|
|||
|
||||
//TODO: cleanup models
|
||||
pub fn delete_from_database(
|
||||
name: &[String],
|
||||
name: &LabelString,
|
||||
_type: Option<Type>,
|
||||
class: Class,
|
||||
rdata: Option<Vec<u8>>,
|
||||
|
@ -157,7 +158,7 @@ pub fn delete_from_database(
|
|||
) {
|
||||
let _ = Record::delete(
|
||||
connection,
|
||||
name.join("."),
|
||||
name.to_string(),
|
||||
_type.map(|f| f.into()),
|
||||
class.into(),
|
||||
rdata,
|
||||
|
|
|
@ -2,7 +2,9 @@ use diesel::PgConnection;
|
|||
|
||||
use zns::{
|
||||
errors::ZNSError,
|
||||
structs::{Message, Question, RR},
|
||||
labelstring::LabelString,
|
||||
parser::ToBytes,
|
||||
structs::{Class, Message, Question, RRClass, RRType, SoaRData, Type, RR},
|
||||
};
|
||||
|
||||
use crate::{config::Config, db::models::get_from_database};
|
||||
|
@ -35,10 +37,14 @@ impl ResponseHandler for QueryHandler {
|
|||
if rrs.is_empty() {
|
||||
rrs.extend(try_wildcard(question, connection)?);
|
||||
if rrs.is_empty() {
|
||||
return Err(ZNSError::NXDomain {
|
||||
domain: question.qname.join("."),
|
||||
qtype: question.qtype.clone(),
|
||||
});
|
||||
if question.qtype == Type::Type(RRType::SOA) {
|
||||
rrs.extend([get_soa(&question.qname)?])
|
||||
} else {
|
||||
return Err(ZNSError::NXDomain {
|
||||
domain: question.qname.to_string(),
|
||||
qtype: question.qtype.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
response.header.ancount += rrs.len() as u16;
|
||||
|
@ -59,13 +65,13 @@ impl ResponseHandler for QueryHandler {
|
|||
fn try_wildcard(question: &Question, connection: &mut PgConnection) -> Result<Vec<RR>, ZNSError> {
|
||||
let records = get_from_database(&question.qname, None, question.qclass.clone(), connection)?;
|
||||
|
||||
if !records.is_empty() || question.qname.is_empty() {
|
||||
if !records.is_empty() || question.qname.as_slice().is_empty() {
|
||||
Ok(vec![])
|
||||
} else {
|
||||
let mut qname = question.qname.clone();
|
||||
qname[0] = String::from("*");
|
||||
let qname = question.qname.clone().to_vec();
|
||||
qname.to_vec()[0] = String::from("*");
|
||||
Ok(get_from_database(
|
||||
&qname,
|
||||
&qname.into(),
|
||||
Some(question.qtype.clone()),
|
||||
question.qclass.clone(),
|
||||
connection,
|
||||
|
@ -79,6 +85,47 @@ fn try_wildcard(question: &Question, connection: &mut PgConnection) -> Result<Ve
|
|||
}
|
||||
}
|
||||
|
||||
fn get_soa(name: &LabelString) -> Result<RR, ZNSError> {
|
||||
let auth_zone = Config::get().authoritative_zone.clone();
|
||||
let rdata = if &Config::get().authoritative_zone == name {
|
||||
// Recommended values taken from wikipedia: https://en.wikipedia.org/wiki/SOA_record
|
||||
Ok(SoaRData {
|
||||
mname: auth_zone,
|
||||
rname: LabelString::from("admin.zeus.ugent.be"),
|
||||
serial: 1,
|
||||
refresh: 86400,
|
||||
retry: 7200,
|
||||
expire: 3600000,
|
||||
minimum: 172800,
|
||||
})
|
||||
} else if name.len() > auth_zone.len() {
|
||||
let zone: LabelString = name.as_slice()[name.len() - auth_zone.len() - 1..].into();
|
||||
Ok(SoaRData {
|
||||
mname: zone.clone(),
|
||||
rname: LabelString::from(&format!("{}.zeus.ugent.be", zone.as_slice()[0])),
|
||||
serial: 1,
|
||||
refresh: 86400,
|
||||
retry: 7200,
|
||||
expire: 3600000,
|
||||
minimum: 172800,
|
||||
})
|
||||
} else {
|
||||
Err(ZNSError::NXDomain {
|
||||
domain: name.to_string(),
|
||||
qtype: Type::Type(RRType::SOA),
|
||||
})
|
||||
}?;
|
||||
|
||||
Ok(RR {
|
||||
name: name.to_owned(),
|
||||
_type: Type::Type(RRType::SOA),
|
||||
class: Class::Class(RRClass::IN),
|
||||
ttl: 11200,
|
||||
rdlength: 0,
|
||||
rdata: SoaRData::to_bytes(rdata),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
@ -5,9 +5,10 @@ use crate::{config::Config, db::models::get_from_database};
|
|||
|
||||
use zns::{
|
||||
errors::ZNSError,
|
||||
labelstring::LabelString,
|
||||
parser::FromBytes,
|
||||
reader::Reader,
|
||||
structs::{Class, LabelString, RRClass, RRType, Type},
|
||||
structs::{Class, RRClass, RRType, Type},
|
||||
};
|
||||
|
||||
use super::{dnskey::DNSKeyRData, sig::Sig};
|
||||
|
@ -17,8 +18,9 @@ pub async fn authenticate(
|
|||
zone: &LabelString,
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, ZNSError> {
|
||||
if zone.len() > Config::get().authoritative_zone.len() {
|
||||
let username = &zone[zone.len() - Config::get().authoritative_zone.len() - 1];
|
||||
if zone.as_slice().len() > Config::get().authoritative_zone.as_slice().len() {
|
||||
let username = &zone.as_slice()
|
||||
[zone.as_slice().len() - Config::get().authoritative_zone.as_slice().len() - 1];
|
||||
|
||||
let ssh_verified = validate_ssh(&username.to_lowercase(), sig)
|
||||
.await
|
||||
|
@ -62,7 +64,7 @@ async fn validate_ssh(username: &String, sig: &Sig) -> Result<bool, reqwest::Err
|
|||
}
|
||||
|
||||
async fn validate_dnskey(
|
||||
zone: &[String],
|
||||
zone: &LabelString,
|
||||
sig: &Sig,
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, ZNSError> {
|
||||
|
|
|
@ -5,8 +5,8 @@ use crate::{
|
|||
db::models::{delete_from_database, insert_into_database},
|
||||
};
|
||||
|
||||
use zns::errors::ZNSError;
|
||||
use zns::structs::{Class, Message, RRClass, RRType, Type};
|
||||
use zns::{errors::ZNSError, utils::labels_equal};
|
||||
|
||||
use self::sig::Sig;
|
||||
|
||||
|
@ -41,7 +41,7 @@ impl ResponseHandler for UpdateHandler {
|
|||
// Check Prerequisite TODO: implement this
|
||||
|
||||
let zone = &message.question[0];
|
||||
let zlen = zone.qname.len();
|
||||
let zlen = zone.qname.as_slice().len();
|
||||
|
||||
//TODO: this code is ugly
|
||||
let last = message.additional.last();
|
||||
|
@ -61,10 +61,10 @@ impl ResponseHandler for UpdateHandler {
|
|||
|
||||
// Update Section Prescan
|
||||
for rr in &message.authority {
|
||||
let rlen = rr.name.len();
|
||||
let rlen = rr.name.as_slice().len();
|
||||
|
||||
// Check if rr has same zone
|
||||
if rlen < zlen || !(labels_equal(&zone.qname, &rr.name[rlen - zlen..].into())) {
|
||||
if rlen < zlen || !(&zone.qname == &rr.name.as_slice()[rlen - zlen..].into()) {
|
||||
return Err(ZNSError::Refused {
|
||||
message: "RR has different zone from Question".to_string(),
|
||||
});
|
||||
|
|
|
@ -4,10 +4,7 @@ use base64::prelude::*;
|
|||
use int_enum::IntEnum;
|
||||
|
||||
use zns::{
|
||||
errors::ZNSError,
|
||||
parser::FromBytes,
|
||||
reader::Reader,
|
||||
structs::{LabelString, RR},
|
||||
errors::ZNSError, labelstring::LabelString, parser::FromBytes, reader::Reader, structs::RR,
|
||||
};
|
||||
|
||||
use super::{
|
||||
|
|
85
zns/src/labelstring.rs
Normal file
85
zns/src/labelstring.rs
Normal file
|
@ -0,0 +1,85 @@
|
|||
use std::fmt::Display;
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabelString(Vec<String>);
|
||||
|
||||
pub fn labels_equal(vec1: &LabelString, vec2: &LabelString) -> bool {
|
||||
if vec1.as_slice().len() != vec2.as_slice().len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (elem1, elem2) in vec1.as_slice().iter().zip(vec2.as_slice().iter()) {
|
||||
if elem1.to_lowercase() != elem2.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
impl LabelString {
|
||||
pub fn from(string: &str) -> Self {
|
||||
LabelString(string.split('.').map(str::to_string).collect())
|
||||
}
|
||||
|
||||
pub fn as_slice(&self) -> &[String] {
|
||||
self.0.as_slice()
|
||||
}
|
||||
|
||||
pub fn to_vec(self) -> Vec<String> {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for LabelString {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
labels_equal(self, other)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&[String]> for LabelString {
|
||||
fn from(value: &[String]) -> Self {
|
||||
LabelString(value.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<String>> for LabelString {
|
||||
fn from(value: Vec<String>) -> Self {
|
||||
LabelString(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for LabelString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0.join("."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_labels_equal() {
|
||||
assert!(labels_equal(
|
||||
&LabelString::from("one.two"),
|
||||
&LabelString::from("oNE.two")
|
||||
));
|
||||
|
||||
assert!(!labels_equal(
|
||||
&LabelString::from("onne.two"),
|
||||
&LabelString::from("oNEe.two")
|
||||
));
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
pub mod errors;
|
||||
pub mod labelstring;
|
||||
pub mod message;
|
||||
pub mod parser;
|
||||
pub mod reader;
|
||||
pub mod structs;
|
||||
|
||||
pub mod test_utils;
|
||||
pub mod utils;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use crate::{
|
||||
errors::ZNSError,
|
||||
structs::{LabelString, Message, Opcode, RCODE},
|
||||
utils::labels_equal,
|
||||
labelstring::LabelString,
|
||||
structs::{Message, Opcode, RCODE},
|
||||
};
|
||||
|
||||
impl Message {
|
||||
|
@ -23,10 +23,12 @@ impl Message {
|
|||
for question in &self.question {
|
||||
let zlen = question.qname.len();
|
||||
if !(zlen >= auth_zone.len()
|
||||
&& labels_equal(&question.qname[zlen - auth_zone.len()..].into(), auth_zone))
|
||||
&& &Into::<LabelString>::into(
|
||||
question.qname.as_slice()[zlen - auth_zone.len()..].to_vec(),
|
||||
) == auth_zone)
|
||||
{
|
||||
return Err(ZNSError::Refused {
|
||||
message: format!("Not authoritative for: {}", question.qname.join(".")),
|
||||
message: format!("Not authoritative for: {}", question.qname),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -69,20 +71,16 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_authoritative() {
|
||||
let name = vec![
|
||||
String::from("not"),
|
||||
String::from("good"),
|
||||
String::from("zone"),
|
||||
];
|
||||
let name = LabelString::from("not.good.zone");
|
||||
|
||||
let message = get_message(Some(name));
|
||||
|
||||
assert!(message
|
||||
.check_authoritative(&vec![String::from("good")])
|
||||
.check_authoritative(&LabelString::from("good"))
|
||||
.is_err_and(|x| x.rcode() == RCODE::REFUSED));
|
||||
|
||||
assert!(message
|
||||
.check_authoritative(&vec![String::from("Zone")])
|
||||
.check_authoritative(&LabelString::from("Zone"))
|
||||
.is_ok())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,8 +2,9 @@ use std::mem::size_of;
|
|||
|
||||
use crate::{
|
||||
errors::ZNSError,
|
||||
labelstring::LabelString,
|
||||
reader::Reader,
|
||||
structs::{Class, Header, LabelString, Message, Opcode, Question, RRClass, RRType, Type, RR},
|
||||
structs::{Class, Header, Message, Opcode, Question, RRClass, RRType, SoaRData, Type, RR},
|
||||
};
|
||||
|
||||
type Result<T> = std::result::Result<T, ZNSError>;
|
||||
|
@ -143,17 +144,17 @@ impl FromBytes for LabelString {
|
|||
if code & 0b11000000 != 0 {
|
||||
let offset = (((code & 0b00111111) as u16) << 8) | reader.read_u8()? as u16;
|
||||
let mut reader_past = reader.seek(offset as usize)?;
|
||||
out.extend(LabelString::from_bytes(&mut reader_past)?);
|
||||
out.extend(LabelString::from_bytes(&mut reader_past)?.to_vec());
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
Ok(out.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl ToBytes for LabelString {
|
||||
fn to_bytes(name: Self) -> Vec<u8> {
|
||||
let mut result: Vec<u8> = vec![];
|
||||
for label in name {
|
||||
for label in name.as_slice() {
|
||||
result.push(label.len() as u8);
|
||||
result.extend(label.as_bytes());
|
||||
}
|
||||
|
@ -289,6 +290,19 @@ impl ToBytes for Message {
|
|||
}
|
||||
}
|
||||
|
||||
impl ToBytes for SoaRData {
|
||||
fn to_bytes(rdata: Self) -> Vec<u8> {
|
||||
let mut result = LabelString::to_bytes(rdata.mname);
|
||||
result.extend(LabelString::to_bytes(rdata.rname));
|
||||
result.extend(u32::to_be_bytes(rdata.serial));
|
||||
result.extend(i32::to_be_bytes(rdata.refresh));
|
||||
result.extend(i32::to_be_bytes(rdata.retry));
|
||||
result.extend(i32::to_be_bytes(rdata.expire));
|
||||
result.extend(u32::to_be_bytes(rdata.minimum));
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use crate::test_utils::{get_message, get_rr};
|
||||
|
@ -315,7 +329,7 @@ pub mod tests {
|
|||
#[test]
|
||||
fn test_parse_question() {
|
||||
let question = Question {
|
||||
qname: vec![String::from("example"), String::from("org")],
|
||||
qname: LabelString::from("example.org"),
|
||||
qtype: Type::Type(RRType::A),
|
||||
qclass: Class::Class(RRClass::IN),
|
||||
};
|
||||
|
@ -338,7 +352,7 @@ pub mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_labelstring() {
|
||||
let labelstring = vec![String::from("example"), String::from("org")];
|
||||
let labelstring: LabelString = vec![String::from("example"), String::from("org")].into();
|
||||
|
||||
let bytes = LabelString::to_bytes(labelstring.clone());
|
||||
let parsed = LabelString::from_bytes(&mut Reader::new(&bytes));
|
||||
|
@ -348,7 +362,7 @@ pub mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_labelstring_ptr() {
|
||||
let labelstring = vec![String::from("example"), String::from("org")];
|
||||
let labelstring: LabelString = vec![String::from("example"), String::from("org")].into();
|
||||
|
||||
let mut bytes = LabelString::to_bytes(labelstring.clone());
|
||||
|
||||
|
@ -370,7 +384,7 @@ pub mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_labelstring_invalid_ptr() {
|
||||
let labelstring = vec![String::from("example"), String::from("org")];
|
||||
let labelstring: LabelString = vec![String::from("example"), String::from("org")].into();
|
||||
|
||||
let mut bytes = LabelString::to_bytes(labelstring.clone());
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
use int_enum::IntEnum;
|
||||
|
||||
use crate::labelstring::LabelString;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Type {
|
||||
Type(RRType),
|
||||
|
@ -89,4 +91,12 @@ pub struct RR {
|
|||
pub rdata: Vec<u8>,
|
||||
}
|
||||
|
||||
pub type LabelString = Vec<String>;
|
||||
pub struct SoaRData {
|
||||
pub mname: LabelString,
|
||||
pub rname: LabelString,
|
||||
pub serial: u32,
|
||||
pub refresh: i32,
|
||||
pub retry: i32,
|
||||
pub expire: i32,
|
||||
pub minimum: u32,
|
||||
}
|
||||
|
|
|
@ -2,9 +2,10 @@
|
|||
use crate::structs::*;
|
||||
|
||||
#[cfg(feature = "test-utils")]
|
||||
use crate::labelstring::LabelString;
|
||||
pub fn get_rr(name: Option<LabelString>) -> RR {
|
||||
RR {
|
||||
name: name.unwrap_or(vec![String::from("example"), String::from("org")]),
|
||||
name: name.unwrap_or(LabelString::from("example.org")),
|
||||
_type: Type::Type(RRType::A),
|
||||
class: Class::Class(RRClass::IN),
|
||||
ttl: 10,
|
||||
|
@ -25,16 +26,12 @@ pub fn get_message(name: Option<LabelString>) -> Message {
|
|||
},
|
||||
question: vec![
|
||||
Question {
|
||||
qname: name
|
||||
.clone()
|
||||
.unwrap_or(vec![String::from("example"), String::from("org")]),
|
||||
qname: name.clone().unwrap_or(LabelString::from("example.org")),
|
||||
qtype: Type::Type(RRType::A),
|
||||
qclass: Class::Class(RRClass::IN),
|
||||
},
|
||||
Question {
|
||||
qname: name
|
||||
.clone()
|
||||
.unwrap_or(vec![String::from("example"), String::from("org")]),
|
||||
qname: name.clone().unwrap_or(LabelString::from("example.org")),
|
||||
qtype: Type::Type(RRType::A),
|
||||
qclass: Class::Class(RRClass::IN),
|
||||
},
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
use crate::structs::LabelString;
|
||||
|
||||
pub fn labels_equal(vec1: &LabelString, vec2: &LabelString) -> bool {
|
||||
if vec1.len() != vec2.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (elem1, elem2) in vec1.iter().zip(vec2.iter()) {
|
||||
if elem1.to_lowercase() != elem2.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_labels_equal() {
|
||||
assert!(labels_equal(
|
||||
&vec![String::from("one"), String::from("two")],
|
||||
&vec![String::from("oNE"), String::from("two")]
|
||||
));
|
||||
|
||||
assert!(!labels_equal(
|
||||
&vec![String::from("one"), String::from("two")],
|
||||
&vec![String::from("oNEe"), String::from("two")]
|
||||
));
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue