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 container/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::GroupSync { 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        let parts = normalized_relative_parts(value)?;
75
76        let mut path = PathBuf::new();
77        for (idx, part) in parts.iter().enumerate() {
78            if *part == "." {
79                continue;
80            }
81            if *part == ".." {
82                return Err(DatabaseNameError::Reserved(value.to_string()));
83            }
84            let is_basename = idx == parts.len() - 1;
85            let serialized = serialize_component(part, is_basename)
86                .ok_or_else(|| DatabaseNameError::InvalidCharacters(value.to_string()))?;
87            path.push(serialized);
88        }
89        if path.as_os_str().is_empty() {
90            return Err(DatabaseNameError::Reserved(value.to_string()));
91        }
92        Ok(Self {
93            raw: value.to_string(),
94            relative_path: path,
95        })
96    }
97
98    pub fn as_str(&self) -> &str {
99        &self.raw
100    }
101
102    pub fn relative_path(&self) -> &Path {
103        &self.relative_path
104    }
105}
106
107fn normalized_relative_parts(value: &str) -> Result<Vec<&str>, DatabaseNameError> {
108    if value.is_empty() {
109        return Err(DatabaseNameError::Empty);
110    }
111
112    if value.starts_with('/') || value.starts_with('\\') || looks_like_windows_absolute(value) {
113        return Err(DatabaseNameError::AbsolutePath(value.to_string()));
114    }
115
116    let mut parts: Vec<&str> = value.split(['/', '\\']).collect();
117    if parts.iter().any(|part| part.is_empty()) {
118        return Err(DatabaseNameError::InvalidCharacters(value.to_string()));
119    }
120    let start = parts
121        .iter()
122        .position(|part| *part != ".")
123        .unwrap_or(parts.len());
124    let end = parts
125        .iter()
126        .rposition(|part| *part != ".")
127        .map_or(start, |idx| idx + 1);
128    if start == end {
129        return Err(DatabaseNameError::Reserved(value.to_string()));
130    }
131
132    parts.truncate(end);
133    parts.drain(..start);
134    Ok(parts)
135}
136
137impl fmt::Display for DatabaseName {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        self.raw.fmt(f)
140    }
141}
142
143impl TryFrom<&str> for DatabaseName {
144    type Error = DatabaseNameError;
145
146    fn try_from(value: &str) -> Result<Self, Self::Error> {
147        Self::parse(value)
148    }
149}
150
151impl std::str::FromStr for DatabaseName {
152    type Err = DatabaseNameError;
153
154    fn from_str(value: &str) -> Result<Self, Self::Err> {
155        Self::parse(value)
156    }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum DatabaseNameError {
161    Empty,
162    Reserved(String),
163    AbsolutePath(String),
164    InvalidCharacters(String),
165}
166
167impl fmt::Display for DatabaseNameError {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            Self::Empty => write!(f, "database name must not be empty"),
171            Self::Reserved(name) => write!(f, "database name `{name}` is reserved"),
172            Self::AbsolutePath(name) => write!(
173                f,
174                "invalid database name `{name}`: use a relative path under `database_dir`"
175            ),
176            Self::InvalidCharacters(name) => write!(
177                f,
178                "invalid database name `{name}`: use relative path components containing only letters, digits, `+`, `_`, `-`, with an optional `.loradb` suffix on the basename"
179            ),
180        }
181    }
182}
183
184impl std::error::Error for DatabaseNameError {}
185
186pub fn resolve_database_path(
187    database_name: &str,
188    database_dir: impl AsRef<Path>,
189) -> Result<PathBuf, DatabaseNameError> {
190    let name = DatabaseName::parse(database_name)?;
191    Ok(database_dir.as_ref().join(name.relative_path()))
192}
193
194fn serialize_component(component: &str, is_basename: bool) -> Option<String> {
195    if component.is_empty() {
196        return None;
197    }
198
199    if is_basename {
200        if let Some(stem) = component.strip_suffix(".loradb") {
201            return (!stem.is_empty() && is_portable_component(stem))
202                .then(|| component.to_string());
203        }
204        return is_portable_component(component).then(|| format!("{component}.loradb"));
205    }
206
207    is_portable_component(component).then(|| component.to_string())
208}
209
210fn is_portable_component(value: &str) -> bool {
211    value
212        .bytes()
213        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'_' | b'-'))
214}
215
216fn looks_like_windows_absolute(value: &str) -> bool {
217    let bytes = value.as_bytes();
218    bytes.len() >= 3
219        && bytes[0].is_ascii_alphabetic()
220        && bytes[1] == b':'
221        && matches!(bytes[2], b'/' | b'\\')
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn parses_with_standard_from_str_trait() {
230        let name: DatabaseName = "tenant/app".parse().unwrap();
231        assert_eq!(name.as_str(), "tenant/app");
232        assert_eq!(name.relative_path(), Path::new("tenant/app.loradb"));
233    }
234}