use std::path::PathBuf;
pub fn data_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ilink-hub")
}
pub fn default_database_url() -> String {
let db = data_dir().join("ilink-hub.db");
format!("sqlite:{}", db.display())
}
pub fn default_bridge_config_path() -> PathBuf {
data_dir().join("ilink-hub-bridge.yaml")
}
pub fn default_bridge_credentials_path() -> PathBuf {
data_dir().join("bridge-credentials.json")
}
pub fn bridge_manager_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ilink-hub-bridge")
}
pub fn default_bridge_profiles_dir() -> PathBuf {
bridge_manager_dir().join("profiles")
}
pub fn default_bridge_manager_credentials_dir() -> PathBuf {
bridge_manager_dir().join("credentials")
}
pub fn desktop_bridge_dir() -> PathBuf {
data_dir().join("desktop-bridge")
}
pub fn desktop_bridge_profiles_dir() -> PathBuf {
desktop_bridge_dir().join("profiles")
}
pub fn desktop_bridge_credentials_dir() -> PathBuf {
desktop_bridge_dir().join("credentials")
}
pub fn expand_user_path(path: &str) -> String {
let path = path.trim();
if path == "~" {
return dirs::home_dir()
.map(|h| h.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
}
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest).to_string_lossy().into_owned();
}
}
if let Some(rest) = path.strip_prefix("$HOME/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest).to_string_lossy().into_owned();
}
}
if path == "$HOME" {
return dirs::home_dir()
.map(|h| h.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
}
path.to_string()
}
pub const DEFAULT_HUB_PORT: u16 = 8765;
pub fn parse_host_port(s: &str) -> Option<String> {
let s = s.trim();
if let Ok(url) = reqwest::Url::parse(s) {
if let Some(host) = url.host_str() {
let port = url.port().unwrap_or(DEFAULT_HUB_PORT);
return Some(format!("{}:{}", host, port));
}
}
if s.contains(':') {
return Some(s.to_string());
}
None
}
pub fn relay_secret_path() -> PathBuf {
data_dir().join("relay-secret")
}
pub fn load_or_create_relay_secret() -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
let path = relay_secret_path();
if let Ok(existing) = std::fs::read_to_string(&path) {
let trimmed = existing.trim();
if (24..=64).contains(&trimmed.len())
&& trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& URL_SAFE_NO_PAD.decode(trimmed).is_ok()
{
return trimmed.to_string();
}
tracing::warn!(
path = %path.display(),
"ignoring malformed relay-secret file; rotating"
);
}
let mut bytes = [0u8; 24];
rand::rng().fill_bytes(&mut bytes);
let encoded = URL_SAFE_NO_PAD.encode(bytes);
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&path, &encoded) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"failed to persist relay secret to disk; using ephemeral secret"
);
}
}
encoded
}
#[cfg(test)]
mod relay_secret_tests {
use super::*;
#[test]
fn load_or_create_relay_secret_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("relay-secret");
let s1 = load_or_create_relay_secret();
assert_eq!(s1.len(), 32, "32 base64url chars = 24 raw bytes");
assert!(
s1.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"must be base64url charset"
);
let s2 = load_or_create_relay_secret();
assert_eq!(s1, s2, "subsequent loads return the same secret");
let _ = path;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_database_url_under_data_dir() {
let url = default_database_url();
assert!(url.starts_with("sqlite:"));
assert!(url.contains(".ilink-hub"));
assert!(url.ends_with("ilink-hub.db"));
}
#[test]
fn bridge_defaults_live_under_data_dir() {
let base = data_dir();
assert_eq!(
default_bridge_config_path(),
base.join("ilink-hub-bridge.yaml")
);
assert_eq!(
default_bridge_credentials_path(),
base.join("bridge-credentials.json")
);
}
#[test]
fn bridge_manager_defaults_live_under_manager_dir() {
let base = bridge_manager_dir();
assert_eq!(default_bridge_profiles_dir(), base.join("profiles"));
assert_eq!(
default_bridge_manager_credentials_dir(),
base.join("credentials")
);
}
#[test]
fn desktop_bridge_paths_live_under_data_dir() {
let base = data_dir();
assert_eq!(desktop_bridge_dir(), base.join("desktop-bridge"));
assert_eq!(
desktop_bridge_profiles_dir(),
base.join("desktop-bridge").join("profiles")
);
assert_eq!(
desktop_bridge_credentials_dir(),
base.join("desktop-bridge").join("credentials")
);
}
#[test]
fn expand_user_path_tilde() {
let home = dirs::home_dir().expect("home");
assert_eq!(
expand_user_path("~/foo"),
home.join("foo").to_string_lossy()
);
assert_eq!(expand_user_path("~"), home.to_string_lossy());
}
}