Skip to main content

lora_database/
named.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use lora_wal::{SyncMode, WalConfig};
5
6/// Hard ceiling for one portable database archive/root.
7///
8/// The current WAL backend still stores segment files under this root, but
9/// callers should treat the resolved `.loradb` path as the database artifact.
10/// The archive backend will use the same limit when it starts writing framed
11/// compressed chunks directly into portable files.
12pub const DEFAULT_DATABASE_MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024;
13
14/// Options for opening a named filesystem-backed database.
15#[derive(Debug, Clone)]
16pub struct DatabaseOpenOptions {
17    pub database_dir: PathBuf,
18    pub sync_mode: SyncMode,
19    pub segment_target_bytes: u64,
20    pub max_database_bytes: u64,
21}
22
23impl Default for DatabaseOpenOptions {
24    fn default() -> Self {
25        Self {
26            database_dir: PathBuf::from("."),
27            sync_mode: SyncMode::Group { interval_ms: 1_000 },
28            segment_target_bytes: 8 * 1024 * 1024,
29            max_database_bytes: DEFAULT_DATABASE_MAX_BYTES,
30        }
31    }
32}
33
34impl DatabaseOpenOptions {
35    pub fn with_database_dir(mut self, database_dir: impl Into<PathBuf>) -> Self {
36        self.database_dir = database_dir.into();
37        self
38    }
39
40    pub fn wal_config_for(&self, name: &DatabaseName) -> WalConfig {
41        WalConfig::Enabled {
42            dir: self.database_path_for(name),
43            sync_mode: self.sync_mode,
44            segment_target_bytes: self.segment_target_bytes,
45        }
46    }
47
48    pub fn database_path_for(&self, name: &DatabaseName) -> PathBuf {
49        self.database_dir.join(name.relative_path())
50    }
51}
52
53/// Validated logical database name/path.
54///
55/// The input is serialized as a safe relative path under
56/// [`DatabaseOpenOptions::database_dir`]. For example:
57///
58/// - `app` -> `app.loradb`
59/// - `app.loradb` -> `app.loradb`
60/// - `./tenant-a/app` -> `tenant-a/app.loradb`
61///
62/// Absolute paths, parent-directory traversal, empty components, and characters
63/// outside ASCII letters/digits plus `+`, `_`, `-` are rejected. The final path
64/// component may additionally end in `.loradb`.
65#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
66pub struct DatabaseName {
67    raw: String,
68    relative_path: PathBuf,
69}
70
71impl DatabaseName {
72    pub fn parse(value: impl AsRef<str>) -> Result<Self, DatabaseNameError> {
73        let value = value.as_ref();
74        if value.is_empty() {
75            return Err(DatabaseNameError::Empty);
76        }
77
78        if value.starts_with('/') || value.starts_with('\\') || looks_like_windows_absolute(value) {
79            return Err(DatabaseNameError::AbsolutePath(value.to_string()));
80        }
81
82        let mut parts: Vec<&str> = value.split(['/', '\\']).collect();
83        if parts.iter().any(|part| part.is_empty()) {
84            return Err(DatabaseNameError::InvalidCharacters(value.to_string()));
85        }
86        while parts.first() == Some(&".") {
87            parts.remove(0);
88        }
89        while parts.last() == Some(&".") {
90            parts.pop();
91        }
92        if parts.is_empty() {
93            return Err(DatabaseNameError::Reserved(value.to_string()));
94        }
95
96        let mut path = PathBuf::new();
97        for (idx, part) in parts.iter().enumerate() {
98            if *part == "." {
99                continue;
100            }
101            if *part == ".." {
102                return Err(DatabaseNameError::Reserved(value.to_string()));
103            }
104            let is_basename = idx == parts.len() - 1;
105            let serialized = serialize_component(part, is_basename)
106                .ok_or_else(|| DatabaseNameError::InvalidCharacters(value.to_string()))?;
107            path.push(serialized);
108        }
109        if path.as_os_str().is_empty() {
110            return Err(DatabaseNameError::Reserved(value.to_string()));
111        }
112        Ok(Self {
113            raw: value.to_string(),
114            relative_path: path,
115        })
116    }
117
118    pub fn as_str(&self) -> &str {
119        &self.raw
120    }
121
122    pub fn relative_path(&self) -> &Path {
123        &self.relative_path
124    }
125}
126
127impl fmt::Display for DatabaseName {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        self.raw.fmt(f)
130    }
131}
132
133impl TryFrom<&str> for DatabaseName {
134    type Error = DatabaseNameError;
135
136    fn try_from(value: &str) -> Result<Self, Self::Error> {
137        Self::parse(value)
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum DatabaseNameError {
143    Empty,
144    Reserved(String),
145    AbsolutePath(String),
146    InvalidCharacters(String),
147}
148
149impl fmt::Display for DatabaseNameError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Empty => write!(f, "database name must not be empty"),
153            Self::Reserved(name) => write!(f, "database name '{name}' is reserved"),
154            Self::AbsolutePath(name) => write!(
155                f,
156                "invalid database name '{name}': use a relative path under database_dir"
157            ),
158            Self::InvalidCharacters(name) => write!(
159                f,
160                "invalid database name '{name}': use relative path components containing only letters, digits, '+', '_', '-', with an optional .loradb suffix on the basename"
161            ),
162        }
163    }
164}
165
166impl std::error::Error for DatabaseNameError {}
167
168pub fn resolve_database_path(
169    database_name: &str,
170    database_dir: impl AsRef<Path>,
171) -> Result<PathBuf, DatabaseNameError> {
172    let name = DatabaseName::parse(database_name)?;
173    Ok(database_dir.as_ref().join(name.relative_path()))
174}
175
176fn serialize_component(component: &str, is_basename: bool) -> Option<String> {
177    if component.is_empty() {
178        return None;
179    }
180
181    if is_basename {
182        if let Some(stem) = component.strip_suffix(".loradb") {
183            return (!stem.is_empty() && is_portable_component(stem))
184                .then(|| component.to_string());
185        }
186        return is_portable_component(component).then(|| format!("{component}.loradb"));
187    }
188
189    is_portable_component(component).then(|| component.to_string())
190}
191
192fn is_portable_component(value: &str) -> bool {
193    value
194        .bytes()
195        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'_' | b'-'))
196}
197
198fn looks_like_windows_absolute(value: &str) -> bool {
199    let bytes = value.as_bytes();
200    bytes.len() >= 3
201        && bytes[0].is_ascii_alphabetic()
202        && bytes[1] == b':'
203        && matches!(bytes[2], b'/' | b'\\')
204}