use postgresql_embedded::{PostgreSQL, Settings, VersionReq};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
const LOCAL_USER: &str = "postgres";
const DEFAULT_DATABASE: &str = "skilj";
pub fn postgres_version() -> VersionReq {
VersionReq::parse("=18.6.0").expect("a valid version requirement")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalConfig {
pub data_dir: PathBuf,
pub port: Option<u16>,
pub database: String,
pub cleanup: bool,
}
impl Default for LocalConfig {
fn default() -> Self {
Self {
data_dir: default_data_dir(),
port: None,
database: DEFAULT_DATABASE.to_string(),
cleanup: false,
}
}
}
impl LocalConfig {
pub fn from_env() -> Result<Self, Error> {
let mut config = Self::default();
if let Some(value) = std::env::var_os("SKILJ_LOCAL_DATA_DIR") {
config.data_dir = PathBuf::from(value);
}
if let Some(value) = std::env::var_os("SKILJ_LOCAL_PORT") {
let value = value.to_string_lossy();
config.port = Some(value.parse().map_err(|_| Error::InvalidConfig {
variable: "SKILJ_LOCAL_PORT".to_string(),
value: value.to_string(),
})?);
}
if let Some(value) = std::env::var_os("SKILJ_LOCAL_DATABASE") {
config.database = value.to_string_lossy().to_string();
}
if let Some(value) = std::env::var_os("SKILJ_LOCAL_CLEANUP") {
let value = value.to_string_lossy();
config.cleanup = match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false,
_ => {
return Err(Error::InvalidConfig {
variable: "SKILJ_LOCAL_CLEANUP".to_string(),
value: value.to_string(),
})
}
};
}
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<(), Error> {
if self.database.is_empty()
|| !self
.database
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(Error::InvalidDatabase {
database: self.database.clone(),
});
}
Ok(())
}
}
pub fn default_data_dir() -> PathBuf {
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
return PathBuf::from(local_app_data)
.join("skilj")
.join("postgresql");
}
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
return PathBuf::from(xdg).join("skilj").join("postgresql");
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home)
.join(".local")
.join("share")
.join("skilj")
.join("postgresql");
}
PathBuf::from(".skilj").join("postgresql")
}
enum Backend {
Embedded(Box<PostgreSQL>),
System {
bin_dir: PathBuf,
},
}
pub struct LocalPostgres {
server: Backend,
database_url: String,
display_url: String,
data_dir: PathBuf,
cleanup: bool,
}
pub fn key_file(data_dir: &Path) -> PathBuf {
data_dir.with_extension("keys.json")
}
pub fn password_file(data_dir: &Path) -> PathBuf {
data_dir.with_extension("pgpass")
}
pub fn load_or_create_password(path: &Path) -> Result<String, Error> {
let io_error = |source| Error::Io {
path: path.to_path_buf(),
source,
};
if path.exists() {
restrict_to_owner(path).map_err(io_error)?;
let password = std::fs::read_to_string(path).map_err(io_error)?;
let password = password.trim().to_string();
if password.is_empty() {
return Err(Error::EmptyPasswordFile {
path: path.to_path_buf(),
});
}
return Ok(password);
}
let password = skilj_core::shared::generate_token_secret();
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path).map_err(io_error)?;
file.write_all(password.as_bytes()).map_err(io_error)?;
Ok(password)
}
#[cfg(unix)]
fn restrict_to_owner(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path)?.permissions().mode();
if mode & 0o077 != 0 {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
#[cfg(not(unix))]
fn restrict_to_owner(_path: &Path) -> std::io::Result<()> {
Ok(())
}
impl LocalPostgres {
pub async fn start(config: LocalConfig) -> Result<Self, Error> {
config.validate()?;
if running_as_root() {
return Self::start_system(config).await;
}
Self::start_embedded(config).await
}
async fn start_embedded(config: LocalConfig) -> Result<Self, Error> {
std::fs::create_dir_all(&config.data_dir).map_err(|source| Error::Io {
path: config.data_dir.clone(),
source,
})?;
let password_file = password_file(&config.data_dir);
let password = load_or_create_password(&password_file)?;
let mut settings = Settings::new();
settings.version = postgres_version();
settings.data_dir = config.data_dir.clone();
settings.password_file = password_file;
settings.host = "127.0.0.1".to_string();
settings
.configuration
.insert("listen_addresses".to_string(), "127.0.0.1".to_string());
settings
.configuration
.insert("fsync".to_string(), "on".to_string());
settings.port = config.port.unwrap_or(0);
settings.username = LOCAL_USER.to_string();
settings.password = password.clone();
settings.temporary = config.cleanup;
settings.timeout = Some(Duration::from_secs(60));
let mut server = PostgreSQL::new(settings);
server.setup().await?;
if let Some((path, fix)) = missing_utc_zone(
&server.settings().installation_dir,
Path::new(SYSTEM_ZONEINFO),
) {
return Err(Error::MissingTimezoneData { path, fix });
}
server.start().await?;
if !server.database_exists(&config.database).await? {
server.create_database(&config.database).await?;
}
let database_url = server.settings().url(&config.database);
let display_url = database_url.replace(&password, "***");
Ok(Self {
server: Backend::Embedded(Box::new(server)),
database_url,
display_url,
data_dir: config.data_dir,
cleanup: config.cleanup,
})
}
async fn start_system(config: LocalConfig) -> Result<Self, Error> {
let existing_major = std::fs::read_to_string(config.data_dir.join("PG_VERSION"))
.ok()
.and_then(|version| version.trim().parse::<u32>().ok());
let bin_dir = system_pg_bin(existing_major).ok_or(Error::NoSystemPostgres)?;
let socket_dir = config.data_dir.with_extension("sockets");
refuse_symlink(&config.data_dir)?;
refuse_symlink(&socket_dir)?;
std::fs::create_dir_all(&config.data_dir).map_err(|source| Error::Io {
path: config.data_dir.clone(),
source,
})?;
chown_to_postgres(&config.data_dir).await?;
std::fs::create_dir_all(&socket_dir).map_err(|source| Error::Io {
path: socket_dir.clone(),
source,
})?;
set_owner_only_dir(&socket_dir)?;
chown_to_postgres(&socket_dir).await?;
let password = load_or_create_password(&password_file(&config.data_dir))?;
if existing_major.is_none() {
let pwfile = config.data_dir.with_extension("initpw");
let _ = std::fs::remove_file(&pwfile);
{
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let io_error = |source| Error::Io {
path: pwfile.clone(),
source,
};
let mut file = options.open(&pwfile).map_err(io_error)?;
file.write_all(password.as_bytes()).map_err(io_error)?;
}
let initialized = async {
chown_to_postgres(&pwfile).await?;
let mut initdb = as_postgres(&bin_dir, "initdb");
initdb
.args([
"--auth-host=scram-sha-256",
"--auth-local=peer",
"-U",
LOCAL_USER,
])
.arg(format!("--pwfile={}", pwfile.display()))
.arg("-D")
.arg(&config.data_dir);
run_as_postgres_output(&mut initdb).await
}
.await;
let _ = std::fs::remove_file(&pwfile);
initialized?;
} else {
refuse_trust_authentication(&config.data_dir)?;
}
let port = match config.port {
Some(port) if port != 0 => port,
_ => free_port()?,
};
let options = format!(
"-p {port} -k {} -c listen_addresses=127.0.0.1",
socket_dir.display()
);
let log = config.data_dir.with_extension("log");
let mut start = as_postgres(&bin_dir, "pg_ctl");
start
.args(["start", "-w", "-t", "60", "-D"])
.arg(&config.data_dir)
.arg("-l")
.arg(&log)
.arg("-o")
.arg(&options);
run_as_postgres_output(&mut start).await?;
let socket = socket_dir.display().to_string();
let port_arg = port.to_string();
let check = format!(
"SELECT 1 FROM pg_database WHERE datname='{}'",
config.database
);
let mut query = as_postgres(&bin_dir, "psql");
query.args([
"-h", &socket, "-p", &port_arg, "-U", LOCAL_USER, "-d", "postgres", "-tAc", &check,
]);
let exists = run_as_postgres_output(&mut query).await?;
if String::from_utf8_lossy(&exists.stdout).trim() != "1" {
let mut createdb = as_postgres(&bin_dir, "createdb");
createdb
.args(["-h", &socket, "-p", &port_arg, "-U", LOCAL_USER])
.arg(&config.database);
run_as_postgres_output(&mut createdb).await?;
}
let database_url = format!(
"postgres://{LOCAL_USER}:{password}@127.0.0.1:{port}/{}",
config.database
);
let display_url = database_url.replace(&password, "***");
Ok(Self {
server: Backend::System { bin_dir },
database_url,
display_url,
data_dir: config.data_dir,
cleanup: config.cleanup,
})
}
pub fn database_url(&self) -> &str {
&self.database_url
}
pub fn display_url(&self) -> &str {
&self.display_url
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub async fn shutdown(self) -> Result<(), Error> {
match &self.server {
Backend::Embedded(server) => {
server.stop().await?;
}
Backend::System { bin_dir, .. } => {
let mut stop = as_postgres(bin_dir, "pg_ctl");
stop.args(["stop", "-m", "fast", "-w", "-D"])
.arg(&self.data_dir);
run_as_postgres_output(&mut stop).await?;
}
}
drop(self.server);
if self.cleanup {
for path in [
self.data_dir.clone(),
password_file(&self.data_dir),
self.data_dir.with_extension("sockets"),
self.data_dir.with_extension("log"),
] {
let removed = if path.is_dir() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
match removed {
Ok(()) => {}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => return Err(Error::Io { path, source }),
}
}
}
Ok(())
}
}
fn as_postgres(bin_dir: &Path, program: &str) -> tokio::process::Command {
let mut command = tokio::process::Command::new("runuser");
command
.args(["-u", "postgres", "--"])
.arg(bin_dir.join(program));
command
}
async fn run_as_postgres_output(
command: &mut tokio::process::Command,
) -> Result<std::process::Output, Error> {
let output = command.output().await.map_err(|source| Error::Io {
path: PathBuf::from("runuser -u postgres"),
source,
})?;
if output.status.success() {
Ok(output)
} else {
Err(Error::Command {
command: "runuser -u postgres".to_string(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}
fn free_port() -> Result<u16, Error> {
std::net::TcpListener::bind("127.0.0.1:0")
.and_then(|listener| listener.local_addr())
.map(|address| address.port())
.map_err(Error::IoSocket)
}
async fn chown_to_postgres(path: &Path) -> Result<(), Error> {
run_as_postgres_output(
tokio::process::Command::new("chown")
.arg("-h")
.arg("postgres:postgres")
.arg(path),
)
.await
.map(|_| ())
}
fn refuse_symlink(path: &Path) -> Result<(), Error> {
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::SymbolicLink {
path: path.to_path_buf(),
}),
_ => Ok(()),
}
}
#[cfg(unix)]
fn set_owner_only_dir(path: &Path) -> Result<(), Error> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
Error::Io {
path: path.to_path_buf(),
source,
}
})
}
#[cfg(not(unix))]
fn set_owner_only_dir(_path: &Path) -> Result<(), Error> {
Ok(())
}
fn refuse_trust_authentication(data_dir: &Path) -> Result<(), Error> {
let hba = data_dir.join("pg_hba.conf");
let Ok(contents) = std::fs::read_to_string(&hba) else {
return Ok(());
};
let trusts = contents.lines().any(|line| {
let line = line.split('#').next().unwrap_or("");
line.split_whitespace().any(|word| word == "trust")
});
if trusts {
return Err(Error::TrustAuthentication { path: hba });
}
Ok(())
}
const SYSTEM_ZONEINFO: &str = "/usr/share/zoneinfo";
fn missing_utc_zone(installation_dir: &Path, system_zoneinfo: &Path) -> Option<(PathBuf, String)> {
let bundled = installation_dir.join("share").join("timezone");
if bundled.is_dir() {
let utc = bundled.join("UTC");
return (!utc.exists()).then(|| {
let fix = format!(
"this Postgres install lacks its own UTC time zone \
(theseus-rs/postgresql-embedded#263); delete {} to reinstall it",
installation_dir.display()
);
(utc, fix)
});
}
let utc = system_zoneinfo.join("UTC");
(!utc.exists()).then(|| {
let fix = "install the system's time zone data, the `tzdata` package".to_string();
(utc, fix)
})
}
fn running_as_root() -> bool {
std::process::Command::new("id")
.arg("-u")
.output()
.is_ok_and(|output| String::from_utf8_lossy(&output.stdout).trim() == "0")
}
fn system_pg_bin(existing_major: Option<u32>) -> Option<PathBuf> {
if let Ok(dir) =
std::env::var("SKILJ_LOCAL_PGBIN").or_else(|_| std::env::var("SKILJ_TEST_PGBIN"))
{
return Some(dir.into());
}
if let Some(major) = existing_major {
let bin = PathBuf::from(format!("/usr/lib/postgresql/{major}/bin"));
if bin.join("pg_ctl").exists() {
return Some(bin);
}
}
let newest = std::fs::read_dir("/usr/lib/postgresql")
.ok()
.into_iter()
.flatten()
.filter_map(|entry| {
let entry = entry.ok()?;
let version: u32 = entry.file_name().to_string_lossy().parse().ok()?;
let bin = entry.path().join("bin");
bin.join("pg_ctl").exists().then_some((version, bin))
})
.max_by_key(|(version, _)| *version)
.map(|(_, bin)| bin);
newest.or_else(|| {
let output = std::process::Command::new("sh")
.args(["-c", "command -v pg_ctl"])
.output()
.ok()?;
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::path::Path::new(&path)
.parent()
.map(|dir| dir.to_path_buf())
})
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("local Postgres: {0}")]
Embedded(#[from] postgresql_embedded::Error),
#[error("local Postgres: {command} failed: stdout={stdout} stderr={stderr}")]
Command {
command: String,
stdout: String,
stderr: String,
},
#[error("local Postgres: cannot bind a socket: {0}")]
IoSocket(#[source] std::io::Error),
#[error("local Postgres: {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("local Postgres: {variable}={value:?} is not valid")]
InvalidConfig {
variable: String,
value: String,
},
#[error(
"local Postgres: running as root, which needs the system's PostgreSQL server \
binaries, and none were found; install `postgresql`, or set SKILJ_LOCAL_PGBIN to \
their directory"
)]
NoSystemPostgres,
#[error("local Postgres: no UTC time zone at {path}: {fix}")]
MissingTimezoneData {
path: PathBuf,
fix: String,
},
#[error(
"local Postgres: database name {database:?} must be ASCII letters, digits, '_' or '-'"
)]
InvalidDatabase {
database: String,
},
#[error("local Postgres: password file {path} is empty")]
EmptyPasswordFile {
path: PathBuf,
},
#[error(
"local Postgres: {path} is a symbolic link; running as root, local mode only uses a \
real directory"
)]
SymbolicLink {
path: PathBuf,
},
#[error(
"local Postgres: {path} allows `trust` logins, which let any local process in as \
superuser without a password; this data directory was initialized by an earlier \
development build - move it aside and start again"
)]
TrustAuthentication {
path: PathBuf,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_path(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"skilj-local-unit-{label}-{}",
skilj_core::shared::generate_token_id()
));
std::fs::create_dir_all(&dir).unwrap();
dir.join("data.pgpass")
}
#[test]
fn a_first_start_generates_a_random_password() {
let first = load_or_create_password(&temp_path("a")).unwrap();
let second = load_or_create_password(&temp_path("b")).unwrap();
assert_eq!(first.len(), 64, "256 bits, hex-encoded");
assert_ne!(first, second);
}
#[test]
fn a_later_start_reads_the_same_password_back() {
let path = temp_path("reuse");
let created = load_or_create_password(&path).unwrap();
assert_eq!(load_or_create_password(&path).unwrap(), created);
}
#[cfg(unix)]
#[test]
fn the_password_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let path = temp_path("mode");
load_or_create_password(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn an_existing_world_readable_file_is_narrowed() {
use std::os::unix::fs::PermissionsExt;
let path = temp_path("narrow");
std::fs::write(&path, "existing-password\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(load_or_create_password(&path).unwrap(), "existing-password");
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn an_empty_password_file_is_refused() {
let path = temp_path("empty");
std::fs::write(&path, "\n").unwrap();
assert!(matches!(
load_or_create_password(&path),
Err(Error::EmptyPasswordFile { .. })
));
}
#[test]
fn a_data_directory_with_trust_logins_is_refused() {
let dir = temp_path("hba").with_extension("");
std::fs::create_dir_all(&dir).unwrap();
let hba = dir.join("pg_hba.conf");
std::fs::write(&hba, "# trust is mentioned in a comment only\nlocal all all peer\nhost all all 127.0.0.1/32 scram-sha-256\n").unwrap();
assert!(refuse_trust_authentication(&dir).is_ok());
std::fs::write(&hba, "host all all 127.0.0.1/32 trust\n").unwrap();
assert!(matches!(
refuse_trust_authentication(&dir),
Err(Error::TrustAuthentication { .. })
));
}
#[test]
fn a_bundled_time_zone_database_needs_its_own_utc() {
let install = temp_path("tz-bundled").with_extension("install");
let zones = install.join("share").join("timezone");
std::fs::create_dir_all(&zones).unwrap();
let system = temp_path("tz-system").with_extension("zoneinfo");
std::fs::create_dir_all(&system).unwrap();
std::fs::write(system.join("UTC"), "TZif").unwrap();
let (path, fix) = missing_utc_zone(&install, &system).expect("UTC missing");
assert_eq!(path, zones.join("UTC"));
assert!(fix.contains("#263"), "{fix}");
std::fs::write(zones.join("UTC"), "TZif").unwrap();
assert!(missing_utc_zone(&install, &system).is_none());
}
#[test]
fn any_other_build_needs_the_system_utc() {
let install = temp_path("tz-plain").with_extension("install");
std::fs::create_dir_all(install.join("share")).unwrap();
let system = temp_path("tz-none").with_extension("zoneinfo");
let (path, fix) = missing_utc_zone(&install, &system).expect("UTC missing");
assert_eq!(path, system.join("UTC"));
assert!(fix.contains("tzdata"), "{fix}");
std::fs::create_dir_all(&system).unwrap();
std::fs::write(system.join("UTC"), "TZif").unwrap();
assert!(missing_utc_zone(&install, &system).is_none());
}
#[test]
fn the_version_is_pinned() {
let version = postgres_version();
assert!(version.matches(&postgresql_embedded::Version::new(18, 6, 0)));
assert!(!version.matches(&postgresql_embedded::Version::new(18, 6, 1)));
assert!(!version.matches(&postgresql_embedded::Version::new(19, 0, 0)));
}
}