use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::vortix_core::profile::{Profile, ProfileId, ProtocolKind};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ProfileStoreError {
#[error("profile {0} not found")]
NotFound(ProfileId),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("malformed sidecar at {path}: {detail}")]
MalformedSidecar { path: PathBuf, detail: String },
#[error("sidecar serialisation failed: {0}")]
SidecarSerialize(String),
#[error("profile name {name} collides with an existing entry")]
NameCollision { name: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileSummary {
pub id: ProfileId,
pub display_name: String,
pub protocol: ProtocolKind,
pub group: Option<String>,
pub last_used: Option<SystemTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sidecar {
pub schema_version: u32,
pub profile_id: String,
pub display_name: String,
pub protocol: ProtocolKind,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub imported_at: Option<SystemTime>,
#[serde(default)]
pub last_used: Option<SystemTime>,
}
impl Sidecar {
pub const SCHEMA_VERSION: u32 = 1;
#[must_use]
pub fn for_profile(p: &Profile) -> Self {
Self {
schema_version: Self::SCHEMA_VERSION,
profile_id: p.id.as_str().to_string(),
display_name: p.display_name.clone(),
protocol: p.protocol,
group: None,
source: None,
imported_at: Some(SystemTime::now()),
last_used: None,
}
}
}
pub trait ProfileStore {
fn list(&self) -> Result<Vec<ProfileSummary>, ProfileStoreError>;
fn get(&self, id: &ProfileId) -> Result<Profile, ProfileStoreError>;
fn insert(&self, profile: &Profile, raw_body: &[u8]) -> Result<(), ProfileStoreError>;
fn touch(&self, id: &ProfileId) -> Result<(), ProfileStoreError>;
fn delete(&self, id: &ProfileId) -> Result<(), ProfileStoreError>;
}
#[derive(Debug, Clone)]
pub struct FsProfileStore {
pub profiles_dir: PathBuf,
}
impl FsProfileStore {
#[must_use]
pub fn new(profiles_dir: PathBuf) -> Self {
Self { profiles_dir }
}
fn extension(protocol: ProtocolKind) -> &'static str {
match protocol {
ProtocolKind::OpenVpn => "ovpn",
_ => "conf",
}
}
fn config_path(&self, display_name: &str, protocol: ProtocolKind) -> PathBuf {
self.profiles_dir
.join(format!("{display_name}.{}", Self::extension(protocol)))
}
fn sidecar_path(&self, display_name: &str) -> PathBuf {
self.profiles_dir.join(format!("{display_name}.meta.toml"))
}
fn read_sidecar(path: &Path) -> Result<Sidecar, ProfileStoreError> {
let text = std::fs::read_to_string(path)?;
toml::from_str(&text).map_err(|e| ProfileStoreError::MalformedSidecar {
path: path.to_path_buf(),
detail: e.to_string(),
})
}
fn write_sidecar(path: &Path, sidecar: &Sidecar) -> Result<(), ProfileStoreError> {
let text = toml::to_string_pretty(sidecar)
.map_err(|e| ProfileStoreError::SidecarSerialize(e.to_string()))?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
}
impl ProfileStore for FsProfileStore {
fn list(&self) -> Result<Vec<ProfileSummary>, ProfileStoreError> {
let mut out = Vec::new();
if !self.profiles_dir.exists() {
return Ok(out);
}
for entry in std::fs::read_dir(&self.profiles_dir)? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.ends_with(".meta.toml") {
continue;
}
let sidecar = Self::read_sidecar(&path)?;
out.push(ProfileSummary {
id: ProfileId::new(&sidecar.profile_id),
display_name: sidecar.display_name,
protocol: sidecar.protocol,
group: sidecar.group,
last_used: sidecar.last_used,
});
}
Ok(out)
}
fn get(&self, id: &ProfileId) -> Result<Profile, ProfileStoreError> {
for summary in self.list()? {
if &summary.id == id {
return Ok(Profile::new(
summary.id,
summary.display_name.clone(),
summary.protocol,
self.config_path(&summary.display_name, summary.protocol),
));
}
}
Err(ProfileStoreError::NotFound(id.clone()))
}
fn insert(&self, profile: &Profile, raw_body: &[u8]) -> Result<(), ProfileStoreError> {
std::fs::create_dir_all(&self.profiles_dir)?;
let cfg_path = self.config_path(&profile.display_name, profile.protocol);
let meta_path = self.sidecar_path(&profile.display_name);
if meta_path.exists() {
let existing = Self::read_sidecar(&meta_path)?;
if existing.profile_id != profile.id.as_str() {
return Err(ProfileStoreError::NameCollision {
name: profile.display_name.clone(),
});
}
}
let cfg_tmp = cfg_path.with_extension(format!("{}.tmp", Self::extension(profile.protocol)));
std::fs::write(&cfg_tmp, raw_body)?;
std::fs::rename(&cfg_tmp, &cfg_path)?;
Self::write_sidecar(&meta_path, &Sidecar::for_profile(profile))?;
Ok(())
}
fn touch(&self, id: &ProfileId) -> Result<(), ProfileStoreError> {
for entry in std::fs::read_dir(&self.profiles_dir)? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.ends_with(".meta.toml") {
continue;
}
let mut sidecar = Self::read_sidecar(&path)?;
if sidecar.profile_id == id.as_str() {
sidecar.last_used = Some(SystemTime::now());
Self::write_sidecar(&path, &sidecar)?;
return Ok(());
}
}
Err(ProfileStoreError::NotFound(id.clone()))
}
fn delete(&self, id: &ProfileId) -> Result<(), ProfileStoreError> {
for entry in std::fs::read_dir(&self.profiles_dir)? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.ends_with(".meta.toml") {
continue;
}
let sidecar = Self::read_sidecar(&path)?;
if sidecar.profile_id == id.as_str() {
let cfg = self.config_path(&sidecar.display_name, sidecar.protocol);
let _ = std::fs::remove_file(&cfg);
std::fs::remove_file(&path)?;
return Ok(());
}
}
Err(ProfileStoreError::NotFound(id.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn corp() -> Profile {
Profile::new(
ProfileId::new("corp-h1"),
"corp",
ProtocolKind::WireGuard,
PathBuf::from("placeholder"),
)
}
#[test]
fn insert_then_list_then_get() {
let tmp = tempfile::tempdir().unwrap();
let store = FsProfileStore::new(tmp.path().to_path_buf());
store.insert(&corp(), b"[Interface]\n").unwrap();
let list = store.list().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id.as_str(), "corp-h1");
assert_eq!(list[0].display_name, "corp");
let p = store.get(&ProfileId::new("corp-h1")).unwrap();
assert_eq!(p.display_name, "corp");
assert_eq!(p.protocol, ProtocolKind::WireGuard);
assert!(p.config_path.exists());
}
#[test]
fn touch_updates_last_used() {
let tmp = tempfile::tempdir().unwrap();
let store = FsProfileStore::new(tmp.path().to_path_buf());
store.insert(&corp(), b"[Interface]\n").unwrap();
assert!(store.list().unwrap()[0].last_used.is_none());
store.touch(&ProfileId::new("corp-h1")).unwrap();
assert!(store.list().unwrap()[0].last_used.is_some());
}
#[test]
fn delete_removes_both_files() {
let tmp = tempfile::tempdir().unwrap();
let store = FsProfileStore::new(tmp.path().to_path_buf());
store.insert(&corp(), b"[Interface]\n").unwrap();
store.delete(&ProfileId::new("corp-h1")).unwrap();
assert!(store.list().unwrap().is_empty());
}
#[test]
fn insert_with_same_id_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let store = FsProfileStore::new(tmp.path().to_path_buf());
store.insert(&corp(), b"[Interface]\n").unwrap();
store
.insert(&corp(), b"[Interface]\nDifferent = true\n")
.unwrap();
assert_eq!(store.list().unwrap().len(), 1);
}
#[test]
fn name_collision_with_different_id_errors() {
let tmp = tempfile::tempdir().unwrap();
let store = FsProfileStore::new(tmp.path().to_path_buf());
store.insert(&corp(), b"[Interface]\n").unwrap();
let other = Profile::new(
ProfileId::new("different-id"),
"corp",
ProtocolKind::WireGuard,
PathBuf::from("ignored"),
);
let err = store.insert(&other, b"[Interface]\n").unwrap_err();
assert!(matches!(err, ProfileStoreError::NameCollision { .. }));
}
}