use super::sftp_utils;
use crate::{Blob, ByteRange};
use anyhow::{Context, Result, anyhow};
use reqwest::Url;
use ssh2::{Session, Sftp};
use std::{
collections::HashMap,
io::{Read, Seek, SeekFrom},
path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard, OnceLock},
};
const CONNECTIONS_PER_SERVER: usize = 4;
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
struct ServerKey {
host: String,
port: u16,
username: String,
}
impl ServerKey {
fn from_url(url: &Url) -> Result<Self> {
Ok(ServerKey {
host: url.host_str().context("SFTP URL has no host")?.to_string(),
port: url.port().unwrap_or(22),
username: if url.username().is_empty() {
"root"
} else {
url.username()
}
.to_string(),
})
}
}
struct OpenFile {
path: PathBuf,
file: ssh2::File,
}
struct ConnectionInner {
_session: Session,
sftp: Sftp,
files: HashMap<u64, OpenFile>,
generation: u64,
next_file_id: u64,
}
pub struct Connection {
inner: Mutex<ConnectionInner>,
url: Url,
identity_file: Option<PathBuf>,
}
impl Connection {
fn open(url: &Url, identity_file: Option<&Path>) -> Result<Arc<Connection>> {
let session = sftp_utils::open_session(url, identity_file)?;
let sftp = session.sftp()?;
Ok(Arc::new(Connection {
inner: Mutex::new(ConnectionInner {
_session: session,
sftp,
files: HashMap::new(),
generation: 0,
next_file_id: 0,
}),
url: url.clone(),
identity_file: identity_file.map(Path::to_path_buf),
}))
}
fn lock(&self) -> Result<MutexGuard<'_, ConnectionInner>> {
self
.inner
.lock()
.map_err(|e| anyhow!("SFTP connection lock poisoned: {e}"))
}
pub fn register(&self, path: &Path) -> Result<(u64, u64)> {
let mut inner = self.lock()?;
let size = inner
.sftp
.stat(path)
.with_context(|| format!("failed to stat remote file {path:?}"))?
.size
.unwrap_or(0);
let file = inner
.sftp
.open(path)
.with_context(|| format!("failed to open remote file {path:?}"))?;
let id = inner.next_file_id;
inner.next_file_id += 1;
inner.files.insert(
id,
OpenFile {
path: path.to_path_buf(),
file,
},
);
Ok((id, size))
}
pub fn unregister(&self, id: u64) {
if let Ok(mut inner) = self.lock() {
inner.files.remove(&id);
}
}
pub fn generation(&self) -> Result<u64> {
Ok(self.lock()?.generation)
}
pub fn read_range(&self, id: u64, range: &ByteRange) -> Result<Blob> {
let mut inner = self.lock()?;
let open_file = inner
.files
.get_mut(&id)
.ok_or_else(|| anyhow!("SFTP file id {id} is not registered"))?;
open_file.file.seek(SeekFrom::Start(range.offset))?;
let mut buffer = vec![0u8; usize::try_from(range.length)?];
open_file.file.read_exact(&mut buffer)?;
Ok(Blob::from(buffer))
}
pub fn reconnect(&self, seen_generation: u64) -> Result<()> {
let mut inner = self.lock()?;
if inner.generation != seen_generation {
return Ok(());
}
log::info!(
"reconnecting pooled SFTP session to '{}'",
sftp_utils::display_name(&self.url)
);
let session = sftp_utils::open_session(&self.url, self.identity_file.as_deref())?;
let sftp = session.sftp()?;
let mut files = HashMap::with_capacity(inner.files.len());
for (id, open_file) in &inner.files {
let file = sftp
.open(&open_file.path)
.with_context(|| format!("failed to reopen remote file {:?}", open_file.path))?;
files.insert(
*id,
OpenFile {
path: open_file.path.clone(),
file,
},
);
}
inner._session = session;
inner.sftp = sftp;
inner.files = files;
inner.generation += 1;
Ok(())
}
}
#[derive(Default)]
struct ServerPool {
connections: Vec<Arc<Connection>>,
next: usize,
}
static POOL: OnceLock<Mutex<HashMap<ServerKey, ServerPool>>> = OnceLock::new();
pub fn acquire(url: &Url, identity_file: Option<&Path>) -> Result<Arc<Connection>> {
let key = ServerKey::from_url(url)?;
let mut pool = POOL
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map_err(|e| anyhow!("SFTP pool lock poisoned: {e}"))?;
let server = pool.entry(key).or_default();
if server.connections.len() < CONNECTIONS_PER_SERVER {
let connection = Connection::open(url, identity_file)?;
server.connections.push(Arc::clone(&connection));
Ok(connection)
} else {
let connection = Arc::clone(&server.connections[server.next]);
server.next = (server.next + 1) % server.connections.len();
Ok(connection)
}
}
#[cfg(test)]
fn connection_count(url: &Url) -> Result<usize> {
let key = ServerKey::from_url(url)?;
let pool = POOL
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map_err(|e| anyhow!("SFTP pool lock poisoned: {e}"))?;
Ok(pool.get(&key).map_or(0, |server| server.connections.len()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_key_from_url_defaults() {
let key = ServerKey::from_url(&Url::parse("sftp://host/path").unwrap()).unwrap();
assert_eq!(key.host, "host");
assert_eq!(key.port, 22);
assert_eq!(key.username, "root");
}
#[test]
fn test_server_key_from_url_explicit() {
let key = ServerKey::from_url(&Url::parse("sftp://alice@host:2222/path").unwrap()).unwrap();
assert_eq!(key.port, 2222);
assert_eq!(key.username, "alice");
}
#[test]
fn test_server_key_ignores_path() {
let a = ServerKey::from_url(&Url::parse("sftp://host/one.bin").unwrap()).unwrap();
let b = ServerKey::from_url(&Url::parse("sftp://host/two.bin").unwrap()).unwrap();
assert_eq!(a, b);
}
#[test]
fn test_server_key_missing_host() {
assert!(ServerKey::from_url(&Url::parse("sftp:///path").unwrap()).is_err());
}
#[cfg(all(feature = "ssh2", unix))]
mod sftp_server_tests {
use super::*;
use crate::io::test_sftp_server::TestSftpServer;
#[tokio::test(flavor = "multi_thread")]
async fn caps_connections_per_server() {
let server = TestSftpServer::start().await;
server.write_file("/a.bin", b"hello").await;
let url = server.url("/a.bin");
let acquired = tokio::task::spawn_blocking({
let url = url.clone();
move || -> Result<Vec<Arc<Connection>>> { (0..12).map(|_| acquire(&url, None)).collect() }
})
.await
.unwrap()
.unwrap();
assert_eq!(acquired.len(), 12);
assert_eq!(connection_count(&url).unwrap(), CONNECTIONS_PER_SERVER);
}
#[tokio::test(flavor = "multi_thread")]
async fn many_files_share_one_connection() {
let server = TestSftpServer::start().await;
server.write_file("/a.bin", b"aaaa").await;
server.write_file("/b.bin", b"bbbbbb").await;
let url = server.url("/a.bin");
tokio::task::spawn_blocking(move || -> Result<()> {
let connection = acquire(&url, None)?;
let (id_a, size_a) = connection.register(Path::new("/a.bin"))?;
let (id_b, size_b) = connection.register(Path::new("/b.bin"))?;
assert_eq!(size_a, 4);
assert_eq!(size_b, 6);
assert_eq!(connection.read_range(id_a, &ByteRange::new(0, 4))?.as_slice(), b"aaaa");
assert_eq!(connection.read_range(id_b, &ByteRange::new(2, 4))?.as_slice(), b"bbbb");
connection.unregister(id_a);
assert!(connection.read_range(id_a, &ByteRange::new(0, 4)).is_err());
Ok(())
})
.await
.unwrap()
.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn reconnect_reopens_registered_files() {
let server = TestSftpServer::start().await;
server.write_file("/a.bin", b"hello").await;
let url = server.url("/a.bin");
tokio::task::spawn_blocking(move || -> Result<()> {
let connection = acquire(&url, None)?;
let (id, _) = connection.register(Path::new("/a.bin"))?;
let generation = connection.generation()?;
connection.reconnect(generation)?;
assert_eq!(connection.generation()?, generation + 1);
assert_eq!(connection.read_range(id, &ByteRange::new(0, 5))?.as_slice(), b"hello");
connection.reconnect(generation)?;
assert_eq!(connection.generation()?, generation + 1);
Ok(())
})
.await
.unwrap()
.unwrap();
}
}
}