use std::path::PathBuf;
use crate::{Error, Result};
pub(crate) const DEFAULT_APP_NAME: &str = "emdb";
pub(crate) const DEFAULT_DATABASE_NAME: &str = "emdb-default.emdb";
pub(crate) fn default_data_root() -> Option<PathBuf> {
if let Some(p) = platform_data_root() {
return Some(p);
}
std::env::current_dir().ok()
}
#[allow(clippy::needless_return)]
fn platform_data_root() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
{
return linux_or_xdg_root();
}
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var_os("HOME") {
return Some(
PathBuf::from(home)
.join("Library")
.join("Application Support"),
);
}
return None;
}
#[cfg(target_os = "windows")]
{
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
if !local.is_empty() {
return Some(PathBuf::from(local));
}
}
if let Some(roaming) = std::env::var_os("APPDATA") {
if !roaming.is_empty() {
return Some(PathBuf::from(roaming));
}
}
if let Some(profile) = std::env::var_os("USERPROFILE") {
if !profile.is_empty() {
return Some(PathBuf::from(profile).join("AppData").join("Local"));
}
}
return None;
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
return linux_or_xdg_root();
}
}
#[cfg(any(
target_os = "linux",
not(any(target_os = "linux", target_os = "macos", target_os = "windows"))
))]
fn linux_or_xdg_root() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
if !xdg.is_empty() {
return Some(PathBuf::from(xdg));
}
}
if let Some(home) = std::env::var_os("HOME") {
if !home.is_empty() {
return Some(PathBuf::from(home).join(".local").join("share"));
}
}
None
}
pub(crate) fn resolve_database_path(
data_root_override: Option<PathBuf>,
app_name: Option<&str>,
database_name: Option<&str>,
) -> Result<PathBuf> {
let root = match data_root_override {
Some(p) => p,
None => default_data_root().ok_or(Error::InvalidConfig(
"could not resolve a default data directory; \
pass an explicit path via EmdbBuilder::path or \
EmdbBuilder::data_root",
))?,
};
let app = match app_name.map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s,
None => DEFAULT_APP_NAME,
};
validate_path_component(app, "app_name")?;
let file = match database_name.map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s,
None => DEFAULT_DATABASE_NAME,
};
validate_path_component(file, "database_name")?;
let dir = root.join(app);
std::fs::create_dir_all(&dir).map_err(Error::from)?;
Ok(dir.join(file))
}
fn validate_path_component(value: &str, field: &'static str) -> Result<()> {
if value.contains('/') || value.contains('\\') {
return Err(Error::InvalidConfig(match field {
"app_name" => {
"app_name must not contain path separators \
(/ or \\); use a single dash-joined name like \
\"hivedb-kv\", or compose nested paths with \
data_root() yourself"
}
"database_name" => "database_name must not contain path separators (/ or \\)",
_ => "path component must not contain path separators",
}));
}
if value == ".." || value.starts_with("../") || value.starts_with("..\\") {
return Err(Error::InvalidConfig(match field {
"app_name" => "app_name must not contain ..",
"database_name" => "database_name must not contain ..",
_ => "path component must not contain ..",
}));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
resolve_database_path, validate_path_component, DEFAULT_APP_NAME, DEFAULT_DATABASE_NAME,
};
fn temp_root() -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0_u128, |d| d.as_nanos());
p.push(format!("emdb-data-dir-test-{nanos}"));
p
}
#[test]
fn resolve_uses_explicit_root_and_creates_subdir() {
let root = temp_root();
let path = match resolve_database_path(Some(root.clone()), Some("hive"), Some("core.emdb"))
{
Ok(p) => p,
Err(err) => panic!("resolve should succeed: {err}"),
};
assert_eq!(path, root.join("hive").join("core.emdb"));
assert!(path.parent().map(std::path::Path::is_dir).unwrap_or(false));
let _removed = std::fs::remove_dir_all(&root);
}
#[test]
fn resolve_substitutes_defaults_when_args_unset() {
let root = temp_root();
let path = match resolve_database_path(Some(root.clone()), None, None) {
Ok(p) => p,
Err(err) => panic!("resolve should succeed: {err}"),
};
assert_eq!(
path,
root.join(DEFAULT_APP_NAME).join(DEFAULT_DATABASE_NAME)
);
let _removed = std::fs::remove_dir_all(&root);
}
#[test]
fn resolve_substitutes_defaults_for_whitespace_only_values() {
let root = temp_root();
let path = match resolve_database_path(Some(root.clone()), Some(" "), Some("\t\n")) {
Ok(p) => p,
Err(err) => panic!("resolve should succeed: {err}"),
};
assert_eq!(
path,
root.join(DEFAULT_APP_NAME).join(DEFAULT_DATABASE_NAME)
);
let _removed = std::fs::remove_dir_all(&root);
}
#[test]
fn validate_rejects_path_separators() {
assert!(validate_path_component("hive/inner", "app_name").is_err());
assert!(validate_path_component("hive\\inner", "app_name").is_err());
assert!(validate_path_component("inner/file.emdb", "database_name").is_err());
}
#[test]
fn validate_rejects_dotdot() {
assert!(validate_path_component("..", "app_name").is_err());
}
#[test]
fn resolve_rejects_separator_in_app_name() {
let root = temp_root();
let result = resolve_database_path(Some(root.clone()), Some("a/b"), None);
assert!(result.is_err());
let _removed = std::fs::remove_dir_all(&root);
}
#[test]
fn default_root_is_some_in_typical_environment() {
let root = super::default_data_root();
assert!(root.is_some(), "default_data_root should yield a path");
}
}