use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::Mutex;
use super::types::HostId;
pub trait ClientIdStore: Send + Sync + 'static {
fn load(
&self,
host_id: HostId,
) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + '_>>;
fn store(
&self,
host_id: HostId,
client_id: String,
) -> Pin<Box<dyn Future<Output = io::Result<()>> + Send + '_>>;
}
#[derive(Debug, Default)]
pub struct InMemoryClientIdStore {
inner: Arc<Mutex<HashMap<HostId, String>>>,
}
impl InMemoryClientIdStore {
pub fn new() -> Self {
Self::default()
}
}
impl ClientIdStore for InMemoryClientIdStore {
fn load(
&self,
host_id: HostId,
) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + '_>> {
let inner = self.inner.clone();
Box::pin(async move {
let map = inner.lock().await;
Ok(map.get(&host_id).cloned())
})
}
fn store(
&self,
host_id: HostId,
client_id: String,
) -> Pin<Box<dyn Future<Output = io::Result<()>> + Send + '_>> {
let inner = self.inner.clone();
Box::pin(async move {
let mut map = inner.lock().await;
map.insert(host_id, client_id);
Ok(())
})
}
}
#[derive(Debug)]
pub struct FileClientIdStore {
directory: PathBuf,
guard: Mutex<()>,
}
impl FileClientIdStore {
pub fn new(directory: impl Into<PathBuf>) -> Self {
Self {
directory: directory.into(),
guard: Mutex::new(()),
}
}
fn file_path(&self, host_id: &HostId) -> PathBuf {
self.directory
.join(format!("{}.clientid", encode_host_id(host_id.as_str())))
}
}
impl ClientIdStore for FileClientIdStore {
fn load(
&self,
host_id: HostId,
) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + '_>> {
Box::pin(async move {
let _guard = self.guard.lock().await;
let path = self.file_path(&host_id);
tokio::task::spawn_blocking(move || match std::fs::read_to_string(&path) {
Ok(contents) => {
if contents.is_empty() {
Ok(None)
} else {
Ok(Some(contents))
}
}
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
})
.await
.map_err(|join_err| {
io::Error::other(format!("client id store load join error: {join_err}"))
})?
})
}
fn store(
&self,
host_id: HostId,
client_id: String,
) -> Pin<Box<dyn Future<Output = io::Result<()>> + Send + '_>> {
Box::pin(async move {
let _guard = self.guard.lock().await;
let directory = self.directory.clone();
let final_path = self.file_path(&host_id);
tokio::task::spawn_blocking(move || {
ensure_directory(&directory)?;
atomic_write(&final_path, client_id.as_bytes())
})
.await
.map_err(|join_err| {
io::Error::other(format!("client id store store join error: {join_err}"))
})?
})
}
}
fn encode_host_id(id: &str) -> String {
let mut out = String::with_capacity(id.len());
for byte in id.as_bytes() {
match *byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(*byte as char);
}
other => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", other);
}
}
}
out
}
fn ensure_directory(dir: &Path) -> io::Result<()> {
match std::fs::metadata(dir) {
Ok(meta) if meta.is_dir() => return Ok(()),
Ok(_) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"client id store path {} exists but is not a directory",
dir.display()
),
));
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
Ok(())
}
fn atomic_write(final_path: &Path, content: &[u8]) -> io::Result<()> {
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let pid = std::process::id();
let parent = final_path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "store path has no parent"))?;
let file_name = final_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "store path is not utf-8"))?;
const MAX_TEMP_RETRIES: u32 = 8;
let mut last_err: Option<io::Error> = None;
for _ in 0..MAX_TEMP_RETRIES {
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let temp_path = parent.join(format!(".{file_name}.{pid}.{counter}.tmp"));
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
match opts.open(&temp_path) {
Ok(mut file) => {
if let Err(err) = file.write_all(content).and_then(|_| file.flush()) {
drop(file);
let _ = std::fs::remove_file(&temp_path);
return Err(err);
}
drop(file);
return match std::fs::rename(&temp_path, final_path) {
Ok(()) => Ok(()),
Err(err) => {
let _ = std::fs::remove_file(&temp_path);
Err(err)
}
};
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
last_err = Some(err);
continue;
}
Err(err) => return Err(err),
}
}
Err(last_err.unwrap_or_else(|| {
io::Error::new(
io::ErrorKind::AlreadyExists,
"exhausted temp-file candidates while atomically writing client id",
)
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_passes_unreserved_chars_through() {
assert_eq!(encode_host_id("abcXYZ-._~123"), "abcXYZ-._~123");
}
#[test]
fn encode_percent_escapes_reserved_chars() {
assert_eq!(encode_host_id("ahp://host?q=1"), "ahp%3A%2F%2Fhost%3Fq%3D1");
}
#[tokio::test]
async fn in_memory_round_trips() {
let store = InMemoryClientIdStore::new();
assert_eq!(store.load(HostId::new("a")).await.unwrap(), None);
store.store(HostId::new("a"), "id-a".into()).await.unwrap();
assert_eq!(
store.load(HostId::new("a")).await.unwrap(),
Some("id-a".into())
);
}
#[tokio::test]
async fn in_memory_store_overwrites_previous_value() {
let store = InMemoryClientIdStore::new();
store.store(HostId::new("k"), "first".into()).await.unwrap();
store
.store(HostId::new("k"), "second".into())
.await
.unwrap();
assert_eq!(
store.load(HostId::new("k")).await.unwrap(),
Some("second".into())
);
}
}