use std::path::{Path, PathBuf};
use std::sync::Arc;
use keel_core_api::policy::JournalLocation;
use keel_core_api::{ErrorCode, KeelError};
use keel_journal::{Journal, PostgresJournal, SqliteJournal, SystemClock};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum JournalBackend {
File(PathBuf),
Postgres(String),
}
impl JournalBackend {
pub(crate) fn select(location: &JournalLocation) -> Self {
match location.0.strip_prefix("file:") {
Some(path) => Self::File(resolve_file_path(Path::new(path))),
None => Self::Postgres(location.0.clone()),
}
}
}
fn redact_postgres_url(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return "postgres://<malformed>".to_owned();
};
let (scheme, rest) = url.split_at(scheme_end);
let after_scheme = &rest[3..];
after_scheme.rfind('@').map_or_else(
|| url.to_owned(),
|at| format!("{scheme}://***@{}", &after_scheme[at + 1..]),
)
}
fn resolve_file_path(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_owned()
} else {
std::env::current_dir().map_or_else(|_| path.to_owned(), |cwd| cwd.join(path))
}
}
pub(crate) fn open(backend: &JournalBackend) -> Result<Arc<dyn Journal>, KeelError> {
match backend {
JournalBackend::File(path) => {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| open_failed(path, &e))?;
}
let journal =
SqliteJournal::open(path, SystemClock).map_err(|e| open_failed(path, &e))?;
Ok(Arc::new(journal))
}
JournalBackend::Postgres(url) => {
let journal = PostgresJournal::open(url).map_err(|e| postgres_open_failed(url, &e))?;
Ok(Arc::new(journal))
}
}
}
fn open_failed(path: &Path, cause: &dyn core::fmt::Display) -> KeelError {
KeelError {
code: ErrorCode::Internal,
message: format!(
"could not open the policy-selected journal at {}: {cause}. Check the path and \
directory permissions, or drop the `journal` key to use the default \
.keel/journal.db.",
path.display()
),
}
}
fn postgres_open_failed(url: &str, cause: &dyn core::fmt::Display) -> KeelError {
KeelError {
code: ErrorCode::Internal,
message: format!(
"could not open the policy-selected journal at {}: {cause}. Check the connection \
string, that the server is reachable, and that the connecting role can create \
tables, or drop the `journal` key to use the default .keel/journal.db.",
redact_postgres_url(url)
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn location(s: &str) -> JournalLocation {
s.parse().expect("valid journal location")
}
#[test]
fn file_paths_resolve_relative_to_cwd() {
let JournalBackend::File(resolved) = JournalBackend::select(&location("file:rel/j.db"))
else {
panic!("file: selects the File backend");
};
assert!(resolved.is_absolute());
assert_eq!(
resolved,
std::env::current_dir().unwrap().join("rel/j.db"),
"relative file: paths join the current working directory"
);
let abs = if cfg!(windows) {
PathBuf::from("C:\\keel\\j.db")
} else {
PathBuf::from("/var/keel/j.db")
};
let loc = location(&format!("file:{}", abs.display()));
assert_eq!(JournalBackend::select(&loc), JournalBackend::File(abs));
}
#[test]
fn postgres_selects_the_backend_carrying_its_url() {
assert_eq!(
JournalBackend::select(&location("postgres://user:secret@db.internal/keel")),
JournalBackend::Postgres("postgres://user:secret@db.internal/keel".to_owned())
);
}
fn open_err(backend: &JournalBackend) -> KeelError {
match open(backend) {
Ok(_) => panic!("open unexpectedly succeeded"),
Err(err) => err,
}
}
#[test]
fn opening_postgres_with_a_malformed_location_fails_loudly_without_leaking_credentials() {
let err = open_err(&JournalBackend::Postgres(
"postgres://user:s3cr3t@[not-a-valid-host/keel".to_owned(),
));
assert_eq!(err.code, ErrorCode::Internal);
assert_eq!(err.code.as_str(), "KEEL-E040");
assert!(
!err.message.contains("s3cr3t"),
"credentials must be redacted"
);
}
#[test]
fn redact_postgres_url_masks_only_the_credential_segment() {
assert_eq!(
redact_postgres_url("postgres://user:secret@db.internal:5432/keel"),
"postgres://***@db.internal:5432/keel"
);
assert_eq!(
redact_postgres_url("postgres://db.internal/keel"),
"postgres://db.internal/keel"
);
}
#[test]
fn opening_a_file_backend_creates_parent_directories() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("deeper").join("journal.db");
let journal = open(&JournalBackend::File(path.clone())).expect("open creates dirs");
drop(journal);
assert!(path.exists(), "journal file created at the selected path");
}
#[test]
fn an_unopenable_file_path_is_a_loud_e040() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("journal.db");
std::fs::create_dir_all(&path).unwrap();
let err = open_err(&JournalBackend::File(path.clone()));
assert_eq!(err.code, ErrorCode::Internal);
assert!(err.message.contains(&path.display().to_string()));
assert!(err.message.contains("journal"));
}
}