use super::config::RedisConfig;
use super::resp::{self, Value};
use rustlavel_core::{Error, Result};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
const READ_CHUNK: usize = 8 * 1024;
pub struct Connection {
stream: TcpStream,
buffer: Vec<u8>,
consumed: usize,
config: RedisConfig,
broken: bool,
}
impl Connection {
pub async fn connect(config: &RedisConfig) -> Result<Connection> {
let address = config.address();
let stream = tokio::time::timeout(config.connect_timeout, TcpStream::connect(&address))
.await
.map_err(|_| {
Error::msg(format!("timed out connecting to {address}. Is Redis running and reachable?"))
})?
.map_err(|e| Error::msg(format!("cannot connect to {}: {e}", config.redacted_url())))?;
let _ = stream.set_nodelay(true);
let mut connection = Connection {
stream,
buffer: Vec::with_capacity(READ_CHUNK),
consumed: 0,
config: config.clone(),
broken: false,
};
connection.handshake().await?;
Ok(connection)
}
pub fn is_broken(&self) -> bool {
self.broken
}
async fn handshake(&mut self) -> Result<()> {
let username = self.config.username.clone();
let password = self.config.password.clone();
if !password.is_empty() {
let reply = if username.is_empty() {
self.command(&[b"AUTH", password.as_bytes()]).await?
} else {
self.command(&[b"AUTH", username.as_bytes(), password.as_bytes()]).await?
};
if let Value::Error(message) = reply {
return Err(Error::msg(format!(
"Redis rejected authentication for {}: {message}",
self.config.redacted_url()
)));
}
}
if self.config.database != 0 {
let db = self.config.database.to_string();
self.command(&[b"SELECT", db.as_bytes()]).await?.into_result().map_err(|e| {
Error::msg(format!("cannot SELECT database {}: {e}", self.config.database))
})?;
}
Ok(())
}
pub async fn command(&mut self, args: &[&[u8]]) -> Result<Value> {
let request = resp::encode_command(args);
let write = self.stream.write_all(&request);
match tokio::time::timeout(self.config.command_timeout, write).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
self.broken = true;
return Err(Error::msg(format!("cannot write to Redis: {e}")));
}
Err(_) => {
self.broken = true;
return Err(Error::msg("timed out sending a command to Redis"));
}
}
self.read_reply().await
}
async fn read_reply(&mut self) -> Result<Value> {
loop {
match resp::decode(&self.buffer[self.consumed..]) {
Ok(Some((value, used))) => {
self.consumed += used;
if self.consumed == self.buffer.len() {
self.buffer.clear();
self.consumed = 0;
}
return Ok(value);
}
Ok(None) => {}
Err(e) => {
self.broken = true;
return Err(e);
}
}
if self.consumed > 0 {
self.buffer.drain(..self.consumed);
self.consumed = 0;
}
let start = self.buffer.len();
self.buffer.resize(start + READ_CHUNK, 0);
let read = self.stream.read(&mut self.buffer[start..]);
let count = match tokio::time::timeout(self.config.command_timeout, read).await {
Ok(Ok(count)) => count,
Ok(Err(e)) => {
self.buffer.truncate(start);
self.broken = true;
return Err(Error::msg(format!("cannot read from Redis: {e}")));
}
Err(_) => {
self.buffer.truncate(start);
self.broken = true;
return Err(Error::msg("timed out waiting for a reply from Redis"));
}
};
self.buffer.truncate(start + count);
if count == 0 {
self.broken = true;
return Err(Error::msg(format!(
"Redis at {} closed the connection mid-reply",
self.config.redacted_url()
)));
}
}
}
pub async fn close(mut self) {
let _ = self.stream.write_all(&resp::encode_command(&[b"QUIT"])).await;
let _ = self.stream.shutdown().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tokio::net::TcpListener;
async fn fake_server(script: Vec<Vec<u8>>) -> (RedisConfig, tokio::task::JoinHandle<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("a free port");
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("a client");
let mut received = Vec::new();
for reply in script {
let mut chunk = vec![0u8; 1024];
match socket.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(count) => received.extend_from_slice(&chunk[..count]),
}
if socket.write_all(&reply).await.is_err() {
break;
}
}
received
});
let config = RedisConfig {
port,
connect_timeout: Duration::from_secs(2),
command_timeout: Duration::from_secs(2),
..RedisConfig::default()
};
(config, handle)
}
#[tokio::test]
async fn a_command_is_written_as_resp_and_its_reply_decoded() {
let (config, server) = fake_server(vec![b"+PONG\r\n".to_vec()]).await;
let mut connection = Connection::connect(&config).await.unwrap();
assert_eq!(connection.command(&[b"PING"]).await.unwrap(), Value::Simple("PONG".into()));
drop(connection);
assert_eq!(server.await.unwrap(), b"*1\r\n$4\r\nPING\r\n".to_vec());
}
#[tokio::test]
async fn the_handshake_sends_auth_and_select_before_anything_else() {
let (mut config, server) =
fake_server(vec![b"+OK\r\n".to_vec(), b"+OK\r\n".to_vec(), b":1\r\n".to_vec()]).await;
config.password = "hunter2".into();
config.database = 4;
let mut connection = Connection::connect(&config).await.unwrap();
connection.command(&[b"EXISTS", b"k"]).await.unwrap();
drop(connection);
let sent = server.await.unwrap();
let expected = [
resp::encode_command(&[b"AUTH", b"hunter2"]),
resp::encode_command(&[b"SELECT", b"4"]),
resp::encode_command(&[b"EXISTS", b"k"]),
]
.concat();
assert_eq!(sent, expected);
}
#[tokio::test]
async fn a_rejected_password_is_reported_without_echoing_it() {
let (mut config, _server) = fake_server(vec![b"-WRONGPASS invalid password\r\n".to_vec()]).await;
config.password = "hunter2".into();
let error = match Connection::connect(&config).await {
Ok(_) => panic!("connecting should have failed"),
Err(e) => e.to_string(),
};
assert!(error.contains("rejected authentication"), "got: {error}");
assert!(!error.contains("hunter2"), "the error must not leak the password");
}
#[tokio::test]
async fn a_reply_split_across_packets_is_reassembled() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut chunk = vec![0u8; 1024];
let _ = socket.read(&mut chunk).await;
for piece in [&b"$11\r\n"[..], &b"hello "[..], &b"world"[..], &b"\r\n"[..]] {
socket.write_all(piece).await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(5)).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
});
let config = RedisConfig { port, ..RedisConfig::default() };
let mut connection = Connection::connect(&config).await.unwrap();
assert_eq!(
connection.command(&[b"GET", b"greeting"]).await.unwrap(),
Value::Bulk(b"hello world".to_vec())
);
}
#[tokio::test]
async fn a_server_that_hangs_up_mid_reply_marks_the_connection_broken() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut chunk = vec![0u8; 1024];
let _ = socket.read(&mut chunk).await;
socket.write_all(b"$11\r\n").await.unwrap();
socket.shutdown().await.unwrap();
});
let config = RedisConfig { port, ..RedisConfig::default() };
let mut connection = Connection::connect(&config).await.unwrap();
let error = connection.command(&[b"GET", b"k"]).await.unwrap_err();
assert!(error.to_string().contains("closed the connection"), "got: {error}");
assert!(connection.is_broken(), "a truncated reply must never be reused");
}
#[tokio::test]
async fn something_that_is_not_redis_is_reported_as_a_protocol_error() {
let (config, _server) = fake_server(vec![b"HTTP/1.1 400 Bad Request\r\n".to_vec()]).await;
let mut connection = Connection::connect(&config).await.unwrap();
assert!(connection.command(&[b"PING"]).await.is_err());
assert!(connection.is_broken());
}
#[tokio::test]
async fn connecting_to_a_closed_port_explains_itself() {
let config = RedisConfig { port: 1, connect_timeout: Duration::from_secs(2), ..RedisConfig::default() };
let error = match Connection::connect(&config).await {
Ok(_) => panic!("connecting should have failed"),
Err(e) => e.to_string(),
};
assert!(error.contains("cannot connect to redis://"), "got: {error}");
}
}