use std::time::Duration;
use irc_proto::chan::ChannelExt;
use irc_proto::CapSubCommand;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufWriter};
use crate::auth::Auth;
use crate::context::sanitize;
use crate::irc::{Command, Message, Response};
use crate::server::Server;
use crate::transport;
use crate::types::{Channel, Nick};
use crate::BoxError;
pub const DEFAULT_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
pub const DEFAULT_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_FLOOD_BURST: usize = 4;
pub const DEFAULT_FLOOD_RATE: Duration = Duration::from_millis(500);
pub const DEFAULT_KEEPNICK_INTERVAL: Duration = Duration::from_secs(60);
pub const REGISTRATION_TIMEOUT: Duration = Duration::from_secs(30);
const SASL_CHUNK_LEN: usize = 400;
const MAX_HANDSHAKE_LINES: usize = 1024;
#[derive(Clone, Debug)]
pub(crate) struct Settings {
pub(crate) keepalive_interval: Duration,
pub(crate) keepalive_timeout: Duration,
pub(crate) flood_burst: usize,
pub(crate) flood_rate: Duration,
pub(crate) ctcp_version: Option<String>,
pub(crate) keepnick_interval: Option<Duration>,
pub(crate) roles: Vec<(String, Vec<String>)>,
}
impl Default for Settings {
fn default() -> Self {
Settings {
keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL,
keepalive_timeout: DEFAULT_KEEPALIVE_TIMEOUT,
flood_burst: DEFAULT_FLOOD_BURST,
flood_rate: DEFAULT_FLOOD_RATE,
ctcp_version: None,
keepnick_interval: None,
roles: Vec::new(),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct Blueprint {
nick: Nick,
server: Server,
channels: Vec<Channel>,
settings: Settings,
}
impl Blueprint {
pub(crate) async fn connect(&self) -> Result<State, Box<dyn std::error::Error + Send + Sync>> {
let mut state = State::connect(
self.nick.clone(),
self.server.clone(),
self.channels.clone(),
)
.await?;
state.settings = self.settings.clone();
Ok(state)
}
}
async fn send(
writer: &mut BufWriter<transport::WriteHalf>,
line: &str,
) -> Result<(), std::io::Error> {
send_secret(writer, line, line).await
}
async fn send_secret(
writer: &mut BufWriter<transport::WriteHalf>,
line: &str,
shown: &str,
) -> Result<(), std::io::Error> {
tracing::trace!(target: crate::PROTOCOL_LOG_TARGET, dir = "send", line = %shown);
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\r\n").await?;
writer.flush().await
}
async fn send_sasl_response(
writer: &mut BufWriter<transport::WriteHalf>,
response: &str,
) -> Result<(), std::io::Error> {
for chunk in response.as_bytes().chunks(SASL_CHUNK_LEN) {
let chunk = String::from_utf8_lossy(chunk);
send_secret(
writer,
&format!("AUTHENTICATE {chunk}"),
"AUTHENTICATE <redacted>",
)
.await?;
}
if response.len().is_multiple_of(SASL_CHUNK_LEN) {
send(writer, "AUTHENTICATE +").await?;
}
Ok(())
}
fn cap_payload<'a>(arg: Option<&'a String>, trailing: Option<&'a String>) -> (bool, &'a str) {
match (arg, trailing) {
(Some(marker), Some(caps)) => (marker == "*", caps.as_str()),
(Some(caps), None) => (false, caps.as_str()),
(None, caps) => (false, caps.map_or("", String::as_str)),
}
}
fn advertised<'a>(available: &'a [String], name: &str) -> Option<&'a str> {
available.iter().find_map(|cap| {
let (cap_name, value) = cap.split_once('=').unwrap_or((cap.as_str(), ""));
(cap_name == name).then_some(value)
})
}
async fn negotiate(
reader: &mut tokio::io::BufReader<transport::ReadHalf>,
writer: &mut BufWriter<transport::WriteHalf>,
auth: &Auth,
) -> Result<Vec<String>, BoxError> {
let mut pending: Vec<String> = Vec::new();
let wanted = auth.wanted_caps();
if wanted.is_empty() {
return Ok(pending);
}
let mut available: Vec<String> = Vec::new();
let mut sasl_started = false;
let deadline = tokio::time::Instant::now() + REGISTRATION_TIMEOUT;
for _ in 0..MAX_HANDSHAKE_LINES {
let mut line = String::new();
let read = tokio::time::timeout_at(deadline, reader.read_line(&mut line))
.await
.map_err(|_| -> BoxError {
format!(
"the server stopped responding during IRCv3 capability negotiation \
(waited {REGISTRATION_TIMEOUT:?}). Make sure that the port speaks IRC and \
that the network supports CAP. A server without CAP support answers with an \
error, not with silence"
)
.into()
})??;
if read == 0 {
return Err("the server closed the connection during IRCv3 capability \
negotiation. A network does this when the server password passed to \
Server::with_password is wrong"
.into());
}
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
continue;
}
tracing::trace!(target: crate::PROTOCOL_LOG_TARGET, dir = "recv", %line);
let Ok(msg) = line.parse::<Message>() else {
pending.push(line.to_string());
continue;
};
match &msg.command {
Command::PING(server, _) => send(writer, &format!("PONG :{server}")).await?,
Command::CAP(_, CapSubCommand::LS, arg, trailing) => {
let (more, caps) = cap_payload(arg.as_ref(), trailing.as_ref());
available.extend(caps.split_whitespace().map(str::to_string));
if more {
continue;
}
let requested: Vec<&str> = wanted
.iter()
.copied()
.filter(|cap| advertised(&available, cap).is_some())
.collect();
if let Some(sasl) = &auth.sasl {
let Some(mechanisms) = advertised(&available, "sasl") else {
return Err(format!(
"SASL authentication was configured but {} does not offer the sasl \
capability. Drop the with_sasl_* call, or connect to a server that \
supports SASL",
server_name(&msg)
)
.into());
};
if !mechanisms.is_empty()
&& !mechanisms
.split(',')
.any(|m| m.eq_ignore_ascii_case(sasl.mechanism()))
{
return Err(format!(
"the server does not support SASL {}. It offers {mechanisms}. Pick \
a mechanism from that list",
sasl.mechanism()
)
.into());
}
}
for cap in wanted.iter().filter(|c| !requested.contains(c)) {
tracing::debug!(capability = cap, "capability not advertised — skipping");
}
if requested.is_empty() {
send(writer, "CAP END").await?;
return Ok(pending);
}
send(writer, &format!("CAP REQ :{}", requested.join(" "))).await?;
}
Command::CAP(_, CapSubCommand::ACK, arg, trailing) => {
let (_, caps) = cap_payload(arg.as_ref(), trailing.as_ref());
tracing::debug!(capabilities = caps, "capabilities acknowledged");
let acked_sasl = caps.split_whitespace().any(|c| c == "sasl");
match (&auth.sasl, acked_sasl) {
(Some(sasl), true) => {
send(writer, &format!("AUTHENTICATE {}", sasl.mechanism())).await?;
sasl_started = true;
}
(Some(sasl), false) => {
return Err(format!(
"the server acknowledged {caps} but not sasl, so the bot cannot \
authenticate with SASL {}. Services are usually down when this \
happens. Retry, or drop the with_sasl_* call to connect \
unauthenticated",
sasl.mechanism()
)
.into());
}
(None, _) => {
send(writer, "CAP END").await?;
return Ok(pending);
}
}
}
Command::CAP(_, CapSubCommand::NAK, arg, trailing) => {
let (_, caps) = cap_payload(arg.as_ref(), trailing.as_ref());
if auth.sasl.is_some() && caps.split_whitespace().any(|c| c == "sasl") {
return Err(format!(
"the server refused the sasl capability ({caps}), so the bot cannot \
authenticate. Services are usually down when this happens. Retry, or \
drop the with_sasl_* call to connect unauthenticated"
)
.into());
}
tracing::warn!(capabilities = caps, "capabilities refused by the server");
send(writer, "CAP END").await?;
return Ok(pending);
}
Command::AUTHENTICATE(_) => {
let Some(sasl) = &auth.sasl else {
pending.push(line.to_string());
continue;
};
send_sasl_response(writer, &sasl.response()).await?;
}
Command::Response(Response::RPL_LOGGEDIN, args) => {
if let Some(account) = args.get(2) {
tracing::info!(%account, "authenticated with SASL");
}
}
Command::Response(Response::RPL_SASLSUCCESS, _) if sasl_started => {
send(writer, "CAP END").await?;
return Ok(pending);
}
Command::Response(
response @ (Response::ERR_NICKLOCKED
| Response::ERR_SASLFAIL
| Response::ERR_SASLTOOLONG
| Response::ERR_SASLABORT
| Response::ERR_SASLALREADY),
args,
) => {
let detail = args.last().map_or("no detail given", String::as_str);
return Err(format!(
"SASL authentication failed: {detail} ({response:?}). Correct the account \
name and password passed to with_sasl_plain, or the client certificate \
registered with the network for with_sasl_external"
)
.into());
}
Command::Response(Response::ERR_UNKNOWNCOMMAND, args)
if args.iter().any(|a| a.eq_ignore_ascii_case("CAP")) =>
{
if auth.sasl.is_some() {
return Err(
"SASL authentication was configured but the server does not implement \
CAP, so it cannot support SASL. Drop the with_sasl_* call, or connect to \
a server that supports IRCv3"
.into(),
);
}
tracing::warn!("server does not support CAP — continuing without capabilities");
return Ok(pending);
}
_ => pending.push(line.to_string()),
}
}
Err(format!(
"the server sent more than {MAX_HANDSHAKE_LINES} lines without finishing IRCv3 \
capability negotiation. Make sure that the address points at an IRC server and not at \
another protocol"
)
.into())
}
fn server_name(msg: &Message) -> &str {
match msg.prefix.as_ref() {
Some(irc_proto::Prefix::ServerName(name)) => name.as_str(),
_ => "the server",
}
}
pub struct State {
pub nick: Nick,
pub channels: Vec<Channel>,
pub server: Server,
pub(crate) settings: Settings,
pub(crate) reader: tokio::io::BufReader<transport::ReadHalf>,
pub(crate) write_half: transport::WriteHalf,
pub(crate) pending_lines: Vec<String>,
#[cfg(unix)]
pub raw_fd: Option<std::os::unix::io::RawFd>,
}
impl State {
fn normalise_channel(ch: &str) -> Channel {
if ch.is_channel_name() {
Channel::from(ch)
} else {
Channel::from(format!("#{ch}"))
}
}
pub async fn connect(
nick: impl Into<Nick>,
server: impl Into<Server>,
channels: Vec<Channel>,
) -> Result<State, Box<dyn std::error::Error + Send + Sync>> {
let nick = nick.into();
let server = server.into();
let channels: Vec<Channel> = channels
.iter()
.map(|c| Self::normalise_channel(c.as_str()))
.collect();
let connection = transport::connect(&server).await?;
#[cfg(unix)]
let raw_fd = connection.raw_fd;
let mut reader = tokio::io::BufReader::new(connection.reader);
let mut writer = BufWriter::new(connection.writer);
if server.auth.negotiates_caps() {
send(&mut writer, "CAP LS 302").await?;
}
if let Some(password) = &server.auth.password {
send_secret(
&mut writer,
&format!("PASS :{}", sanitize(password)),
"PASS :<redacted>",
)
.await?;
}
send(&mut writer, &format!("NICK {nick}")).await?;
send(&mut writer, &format!("USER {nick} 0 * :{nick}")).await?;
let pending_lines = negotiate(&mut reader, &mut writer, &server.auth).await?;
let write_half = writer.into_inner();
Ok(State {
nick,
channels,
server,
settings: Settings::default(),
reader,
write_half,
pending_lines,
#[cfg(unix)]
raw_fd,
})
}
pub(crate) fn blueprint(&self) -> Blueprint {
Blueprint {
nick: self.nick.clone(),
server: self.server.clone(),
channels: self.channels.clone(),
settings: self.settings.clone(),
}
}
#[cfg(unix)]
pub fn try_inherit_from_env() -> Result<Option<State>, Box<dyn std::error::Error + Send + Sync>>
{
use std::os::unix::io::RawFd;
use crate::hot_reload::{
ENV_CHANNELS, ENV_FD, ENV_FLOOD_BURST, ENV_FLOOD_RATE, ENV_KA_INTERVAL, ENV_KA_TIMEOUT,
ENV_NICK, ENV_SERVER,
};
let fd_str = match std::env::var(ENV_FD) {
Ok(v) => v,
Err(_) => return Ok(None), };
let raw_fd: RawFd = fd_str.parse()?;
let nick = std::env::var(ENV_NICK)?;
let server = std::env::var(ENV_SERVER)?;
let channels_raw = std::env::var(ENV_CHANNELS)?;
let ka_interval_ms: u64 = std::env::var(ENV_KA_INTERVAL)?.parse()?;
let ka_timeout_ms: u64 = std::env::var(ENV_KA_TIMEOUT)?.parse()?;
let flood_burst = std::env::var(ENV_FLOOD_BURST)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_FLOOD_BURST);
let flood_rate = std::env::var(ENV_FLOOD_RATE)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map_or(DEFAULT_FLOOD_RATE, Duration::from_millis);
for var in &[
ENV_FD,
ENV_NICK,
ENV_SERVER,
ENV_CHANNELS,
ENV_KA_INTERVAL,
ENV_KA_TIMEOUT,
ENV_FLOOD_BURST,
ENV_FLOOD_RATE,
] {
std::env::remove_var(var);
}
let channels: Vec<Channel> = if channels_raw.is_empty() {
vec![]
} else {
channels_raw.split(',').map(Channel::from).collect()
};
let connection = transport::from_inherited_fd(raw_fd)?;
let reader = tokio::io::BufReader::new(connection.reader);
Ok(Some(State {
nick: Nick::from(nick),
channels,
server: Server::plain(server),
settings: Settings {
keepalive_interval: Duration::from_millis(ka_interval_ms),
keepalive_timeout: Duration::from_millis(ka_timeout_ms),
flood_burst,
flood_rate,
..Settings::default()
},
reader,
write_half: connection.writer,
pending_lines: Vec::new(),
raw_fd: connection.raw_fd,
}))
}
pub fn with_keepalive(mut self, interval: Duration, timeout: Duration) -> Self {
self.settings.keepalive_interval = interval;
self.settings.keepalive_timeout = timeout;
self
}
pub fn with_flood_control(mut self, burst: usize, rate: Duration) -> Self {
self.settings.flood_burst = burst;
self.settings.flood_rate = rate;
self
}
pub fn with_ctcp_version(mut self, version: impl Into<String>) -> Self {
self.settings.ctcp_version = Some(version.into());
self
}
pub fn with_keepnick_interval(mut self, interval: Duration) -> Self {
self.settings.keepnick_interval = Some(interval);
self
}
pub fn with_keepnick(self) -> Self {
self.with_keepnick_interval(DEFAULT_KEEPNICK_INTERVAL)
}
pub fn with_role(
mut self,
name: impl Into<String>,
masks: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let patterns: Vec<String> = masks.into_iter().map(Into::into).collect();
self.settings.roles.push((name.into(), patterns));
self
}
pub fn keepalive_interval(&self) -> Duration {
self.settings.keepalive_interval
}
pub fn keepalive_timeout(&self) -> Duration {
self.settings.keepalive_timeout
}
pub fn flood_burst(&self) -> usize {
self.settings.flood_burst
}
pub fn flood_rate(&self) -> Duration {
self.settings.flood_rate
}
pub fn keepnick_interval(&self) -> Option<Duration> {
self.settings.keepnick_interval
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
#[test]
fn normalise_channel_prefixes_bare_name() {
assert_eq!(State::normalise_channel("general"), "#general");
}
#[test]
fn normalise_channel_keeps_existing_prefixes() {
for ch in ["#rust", "&local", "+modeless", "!network"] {
assert_eq!(State::normalise_channel(ch), ch);
}
}
fn parse_cap(line: &str) -> (bool, String) {
let msg: Message = line.parse().expect("a valid CAP line");
let Command::CAP(_, _, arg, trailing) = &msg.command else {
panic!("not a CAP command: {line}");
};
let (more, caps) = cap_payload(arg.as_ref(), trailing.as_ref());
(more, caps.to_string())
}
#[test]
fn a_final_cap_ls_yields_its_capability_list() {
let (more, caps) = parse_cap(":srv CAP * LS :sasl=PLAIN multi-prefix");
assert!(!more);
assert_eq!(caps, "sasl=PLAIN multi-prefix");
}
#[test]
fn a_continued_cap_ls_is_flagged_and_still_yields_its_list() {
let (more, caps) = parse_cap(":srv CAP * LS * :sasl=PLAIN multi-prefix");
assert!(more);
assert_eq!(caps, "sasl=PLAIN multi-prefix");
}
#[test]
fn a_cap_ack_yields_its_capability_list() {
let (more, caps) = parse_cap(":srv CAP * ACK :sasl");
assert!(!more);
assert_eq!(caps, "sasl");
}
#[test]
fn advertised_returns_the_value_after_the_equals_sign() {
let caps = vec!["sasl=PLAIN,EXTERNAL".to_string(), "server-time".to_string()];
assert_eq!(advertised(&caps, "sasl"), Some("PLAIN,EXTERNAL"));
}
#[test]
fn advertised_returns_an_empty_value_for_a_valueless_capability() {
let caps = vec!["sasl".to_string()];
assert_eq!(advertised(&caps, "sasl"), Some(""));
}
#[test]
fn advertised_returns_none_when_absent() {
let caps = vec!["server-time".to_string()];
assert_eq!(advertised(&caps, "sasl"), None);
}
#[test]
fn advertised_does_not_match_a_name_prefix() {
let caps = vec!["sasl-not-really".to_string()];
assert_eq!(advertised(&caps, "sasl"), None);
}
async fn connect_loopback() -> State {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let _sock = listener.accept().await;
tokio::time::sleep(Duration::from_secs(5)).await;
});
State::connect("tester".to_string(), &addr, vec![Channel::from("general")])
.await
.expect("loopback connect failed")
}
#[tokio::test]
async fn connect_normalises_channels() {
let state = connect_loopback().await;
assert_eq!(state.channels, vec![Channel::from("#general")]);
}
async fn serve_loopback() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((sock, _)) = listener.accept().await {
held.push(sock);
}
});
addr
}
#[tokio::test]
async fn reconnect_preserves_every_configured_setting() {
let addr = serve_loopback().await;
let original = State::connect("tester", &addr, vec![Channel::from("general")])
.await
.expect("loopback connect failed")
.with_keepalive(Duration::from_secs(12), Duration::from_secs(4))
.with_flood_control(9, Duration::from_millis(750))
.with_ctcp_version("mybot 1.2.3")
.with_keepnick_interval(Duration::from_secs(15))
.with_role("admin", ["*!*@trusted.host"]);
let reconnected = original
.blueprint()
.connect()
.await
.expect("reconnect failed");
assert_eq!(reconnected.keepalive_interval(), Duration::from_secs(12));
assert_eq!(reconnected.keepalive_timeout(), Duration::from_secs(4));
assert_eq!(reconnected.flood_burst(), 9);
assert_eq!(reconnected.flood_rate(), Duration::from_millis(750));
assert_eq!(
reconnected.keepnick_interval(),
Some(Duration::from_secs(15))
);
assert_eq!(
reconnected.settings.ctcp_version.as_deref(),
Some("mybot 1.2.3")
);
assert_eq!(
reconnected.settings.roles,
vec![("admin".to_string(), vec!["*!*@trusted.host".to_string()])]
);
}
#[tokio::test]
async fn reconnect_preserves_nick_server_and_channels() {
let addr = serve_loopback().await;
let original = State::connect("tester", &addr, vec![Channel::from("general")])
.await
.expect("loopback connect failed");
let reconnected = original
.blueprint()
.connect()
.await
.expect("reconnect failed");
assert_eq!(reconnected.nick, "tester");
assert_eq!(reconnected.server.addr(), addr);
assert_eq!(reconnected.channels, vec![Channel::from("#general")]);
}
#[tokio::test]
async fn keepnick_disabled_by_default() {
let state = connect_loopback().await;
assert_eq!(state.keepnick_interval(), None);
}
#[tokio::test]
async fn with_keepnick_interval_sets_interval() {
let state = connect_loopback()
.await
.with_keepnick_interval(Duration::from_secs(15));
assert_eq!(state.keepnick_interval(), Some(Duration::from_secs(15)));
}
#[tokio::test]
async fn with_keepnick_uses_default_interval() {
let state = connect_loopback().await.with_keepnick();
assert_eq!(state.keepnick_interval(), Some(DEFAULT_KEEPNICK_INTERVAL));
}
#[cfg(unix)]
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(unix)]
fn clear_inherit_env() {
use crate::hot_reload::{
ENV_CHANNELS, ENV_FD, ENV_FLOOD_BURST, ENV_FLOOD_RATE, ENV_KA_INTERVAL, ENV_KA_TIMEOUT,
ENV_NICK, ENV_SERVER,
};
for var in [
ENV_FD,
ENV_NICK,
ENV_SERVER,
ENV_CHANNELS,
ENV_KA_INTERVAL,
ENV_KA_TIMEOUT,
ENV_FLOOD_BURST,
ENV_FLOOD_RATE,
] {
std::env::remove_var(var);
}
}
#[cfg(unix)]
#[test]
fn try_inherit_returns_none_on_normal_startup() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_inherit_env();
let result = State::try_inherit_from_env().expect("should not error");
assert!(result.is_none());
}
#[cfg(unix)]
#[test]
fn try_inherit_errors_on_malformed_fd() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_inherit_env();
std::env::set_var(crate::hot_reload::ENV_FD, "notanint");
let result = State::try_inherit_from_env();
clear_inherit_env();
assert!(result.is_err(), "malformed fd should yield an error");
}
#[cfg(unix)]
#[tokio::test]
async fn try_inherit_reconstructs_state_from_env() {
use std::os::unix::io::IntoRawFd;
use crate::hot_reload::{
ENV_CHANNELS, ENV_FD, ENV_KA_INTERVAL, ENV_KA_TIMEOUT, ENV_NICK, ENV_SERVER,
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let _sock = listener.accept().await;
tokio::time::sleep(Duration::from_secs(5)).await;
});
let std_stream = std::net::TcpStream::connect(&addr).expect("connect failed");
let raw_fd = std_stream.into_raw_fd();
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_inherit_env();
std::env::set_var(ENV_FD, raw_fd.to_string());
std::env::set_var(ENV_NICK, "inheritbot");
std::env::set_var(ENV_SERVER, &addr);
std::env::set_var(ENV_CHANNELS, "#a,#b");
std::env::set_var(ENV_KA_INTERVAL, "12000");
std::env::set_var(ENV_KA_TIMEOUT, "4000");
std::env::set_var(crate::hot_reload::ENV_FLOOD_BURST, "9");
std::env::set_var(crate::hot_reload::ENV_FLOOD_RATE, "750");
let state = State::try_inherit_from_env()
.expect("inherit should succeed")
.expect("env vars present → Some(State)");
assert_eq!(state.nick, "inheritbot");
assert_eq!(state.server.addr(), addr);
assert!(!state.server.is_tls());
assert_eq!(
state.channels,
vec![Channel::from("#a"), Channel::from("#b")]
);
assert_eq!(state.keepalive_interval(), Duration::from_millis(12000));
assert_eq!(state.keepalive_timeout(), Duration::from_millis(4000));
assert_eq!(state.flood_burst(), 9);
assert_eq!(state.flood_rate(), Duration::from_millis(750));
assert!(std::env::var(ENV_FD).is_err());
assert!(std::env::var(crate::hot_reload::ENV_FLOOD_BURST).is_err());
assert!(std::env::var(crate::hot_reload::ENV_FLOOD_RATE).is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn try_inherit_defaults_flood_when_env_absent() {
use std::os::unix::io::IntoRawFd;
use crate::hot_reload::{
ENV_CHANNELS, ENV_FD, ENV_KA_INTERVAL, ENV_KA_TIMEOUT, ENV_NICK, ENV_SERVER,
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let _sock = listener.accept().await;
tokio::time::sleep(Duration::from_secs(5)).await;
});
let raw_fd = std::net::TcpStream::connect(&addr)
.expect("connect failed")
.into_raw_fd();
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_inherit_env();
std::env::set_var(ENV_FD, raw_fd.to_string());
std::env::set_var(ENV_NICK, "inheritbot");
std::env::set_var(ENV_SERVER, &addr);
std::env::set_var(ENV_CHANNELS, "");
std::env::set_var(ENV_KA_INTERVAL, "30000");
std::env::set_var(ENV_KA_TIMEOUT, "10000");
let state = State::try_inherit_from_env().unwrap().unwrap();
assert_eq!(state.flood_burst(), DEFAULT_FLOOD_BURST);
assert_eq!(state.flood_rate(), DEFAULT_FLOOD_RATE);
}
#[cfg(unix)]
#[tokio::test]
async fn try_inherit_parses_empty_channels() {
use std::os::unix::io::IntoRawFd;
use crate::hot_reload::{
ENV_CHANNELS, ENV_FD, ENV_KA_INTERVAL, ENV_KA_TIMEOUT, ENV_NICK, ENV_SERVER,
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let _sock = listener.accept().await;
tokio::time::sleep(Duration::from_secs(5)).await;
});
let raw_fd = std::net::TcpStream::connect(&addr)
.expect("connect failed")
.into_raw_fd();
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_inherit_env();
std::env::set_var(ENV_FD, raw_fd.to_string());
std::env::set_var(ENV_NICK, "inheritbot");
std::env::set_var(ENV_SERVER, &addr);
std::env::set_var(ENV_CHANNELS, "");
std::env::set_var(ENV_KA_INTERVAL, "30000");
std::env::set_var(ENV_KA_TIMEOUT, "10000");
let state = State::try_inherit_from_env().unwrap().unwrap();
assert!(state.channels.is_empty());
}
}