use std::fmt;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Database {
System,
Url(String),
DockerPostgres,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerMode {
Native,
Docker,
}
impl fmt::Display for ServerMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
ServerMode::Native => "native",
ServerMode::Docker => "docker",
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LocalRequest {
pub port: Option<u16>,
pub database: Option<Database>,
pub docker: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
pub server: ServerMode,
pub database: Database,
}
pub fn resolve(req: &LocalRequest) -> Plan {
let database = req.database.clone().unwrap_or(Database::System);
let server = if req.docker || database == Database::DockerPostgres {
ServerMode::Docker
} else {
ServerMode::Native
};
Plan { server, database }
}
#[derive(Clone, PartialEq, Eq)]
pub struct DbUrl {
pub user: String,
password: Option<String>,
pub host: String,
pub port: u16,
pub database: String,
pub sslmode: Option<String>,
}
impl fmt::Debug for DbUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.redacted())
}
}
impl DbUrl {
pub fn system_default(user: &str) -> DbUrl {
DbUrl {
user: user.to_string(),
password: None,
host: "localhost".into(),
port: 5432,
database: "deciduous".into(),
sslmode: None,
}
}
pub fn parse(input: &str) -> Result<DbUrl, String> {
let input = input.trim();
if input.contains(['\'', '\n', '\r']) {
return Err(
"the database URL cannot contain quotes or line breaks; percent-encode them \
(' is %27)"
.into(),
);
}
let rest = ["postgres://", "postgresql://", "ecto://"]
.iter()
.find_map(|s| input.strip_prefix(s))
.ok_or("a PostgreSQL URL starts with postgres://, postgresql:// or ecto://")?;
let (rest, query) = match rest.split_once('?') {
Some((r, q)) => (r, Some(q)),
None => (rest, None),
};
let (authority, database) = rest
.split_once('/')
.ok_or("the URL names no database (…/deciduous at the end)")?;
if database.is_empty() || database.contains('/') {
return Err(format!(
"{database:?} is not a database name; end the URL with /<database>"
));
}
let (userinfo, hostport) = match authority.rsplit_once('@') {
Some((u, h)) => (Some(u), h),
None => (None, authority),
};
let (user, password) = match userinfo {
Some(u) => match u.split_once(':') {
Some((user, pw)) => (user.to_string(), Some(pw.to_string())),
None => (u.to_string(), None),
},
None => (String::new(), None),
};
let (host, port) = if let Some(h) = hostport.strip_prefix('[') {
let (h, after) = h.split_once(']').ok_or("unclosed [ in the host")?;
(h.to_string(), after.strip_prefix(':'))
} else {
match hostport.rsplit_once(':') {
Some((h, p)) => (h.to_string(), Some(p)),
None => (hostport.to_string(), None),
}
};
if host.is_empty() {
return Err("the URL names no host".into());
}
let port = match port {
None => 5432,
Some(p) => p
.parse::<u16>()
.ok()
.filter(|&p| p != 0)
.ok_or_else(|| format!("{p:?} is not a port number"))?,
};
let mut sslmode = None;
for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
match pair.split_once('=') {
Some(("sslmode", v)) => match v {
"disable" | "allow" | "prefer" | "require" | "verify-ca" | "verify-full" => {
sslmode = Some(v.to_string())
}
other => return Err(format!("sslmode={other} is not one libpq knows")),
},
_ => {
return Err(format!(
"unsupported URL parameter {pair:?}: only sslmode is understood \
(other settings go in ~/.config/deciduous/server/.env)"
))
}
}
}
if user.is_empty() {
return Err(
"the URL names no user (postgres://USER@host/db); the server runs as a \
service, where there is no login name to fall back on"
.into(),
);
}
Ok(DbUrl {
user,
password,
host,
port,
database: database.to_string(),
sslmode,
})
}
pub fn has_password(&self) -> bool {
self.password.is_some()
}
pub fn set_password(&mut self, plain: &str) {
let encoded = plain
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect();
self.password = Some(encoded);
}
fn host_for_url(&self) -> String {
if self.host.contains(':') {
format!("[{}]", self.host)
} else {
self.host.clone()
}
}
pub fn ecto_url(&self) -> String {
let creds = match &self.password {
Some(p) => format!("{}:{p}", self.user),
None => self.user.clone(),
};
format!(
"ecto://{creds}@{}:{}/{}",
self.host_for_url(),
self.port,
self.database
)
}
pub fn redacted(&self) -> String {
let creds = match &self.password {
Some(_) => format!("{}:***", self.user),
None => self.user.clone(),
};
let q = self
.sslmode
.as_ref()
.map(|m| format!("?sslmode={m}"))
.unwrap_or_default();
format!(
"postgres://{creds}@{}:{}/{}{q}",
self.host_for_url(),
self.port,
self.database
)
}
pub fn is_loopback(&self) -> bool {
matches!(self.host.as_str(), "localhost" | "127.0.0.1" | "::1")
}
pub fn ssl_settings(&self) -> (&'static str, &'static str) {
match self.sslmode.as_deref() {
Some("require") => ("true", "none"),
Some("verify-ca") => ("true", "ca"),
Some("verify-full") => ("true", "full"),
_ => ("false", "full"),
}
}
pub fn for_container(&self, os: &str) -> (DbUrl, Option<String>) {
if !self.is_loopback() {
return (self.clone(), None);
}
let mut url = self.clone();
url.host = "host.docker.internal".into();
let note = if os == "linux" {
format!(
"The server container reaches PostgreSQL on this machine at host.docker.internal, \
the Docker host gateway (compose.yaml maps it). PostgreSQL must listen there \
(listen_addresses in postgresql.conf) and pg_hba.conf must admit the Docker \
networks (172.16.0.0/12) for user {}; out of the box it listens on localhost \
only.",
self.user
)
} else {
format!(
"The server container reaches PostgreSQL on this machine at host.docker.internal \
(Docker Desktop routes it to this machine's localhost:{}).",
self.port
)
};
(url, Some(note))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub mode: ServerMode,
pub port: Option<u16>,
pub token: Option<String>,
pub version: Option<String>,
}
pub fn env_value(text: &str, key: &str) -> Option<String> {
text.lines().find_map(|l| {
l.strip_prefix(&format!("{key}="))
.map(|v| v.trim().trim_matches('\'').to_string())
})
}
impl Settings {
pub fn parse(text: &str) -> Result<Settings, String> {
let mode = match env_value(text, "DECIDUOUS_SERVER_MODE").as_deref() {
Some("native") => ServerMode::Native,
Some("docker") | None => ServerMode::Docker,
Some(other) => {
return Err(format!(
"DECIDUOUS_SERVER_MODE={other} in the server settings file is neither native \
nor docker"
))
}
};
let port = env_value(text, "DECIDUOUS_PORT").and_then(|p| p.parse().ok());
Ok(Settings {
mode,
port,
token: env_value(text, "DECIDUOUS_MCP_TOKEN"),
version: env_value(text, "DECIDUOUS_SERVER_VERSION"),
})
}
}
pub fn native_settings(version: &str, port: u16, token: &str, db: &DbUrl) -> String {
let (ssl, verify) = db.ssl_settings();
format!(
"DECIDUOUS_SERVER_MODE=native\n\
DECIDUOUS_SERVER_VERSION={version}\n\
DECIDUOUS_BIND_ADDRESS=127.0.0.1\n\
DECIDUOUS_PORT={port}\n\
DECIDUOUS_MCP_TOKEN='{token}'\n\
DATABASE_URL='{url}'\n\
DB_SSL={ssl}\n\
DB_SSL_VERIFY={verify}\n\
DB_SSL_CA_FILE=\n\
POOL_SIZE=10\n\
DECIDUOUS_PREPARE_DATABASE=true\n",
url = db.ecto_url()
)
}
pub fn with_value(text: &str, key: &str, value: &str) -> String {
let prefix = format!("{key}=");
let mut found = false;
let mut out: Vec<String> = text
.lines()
.map(|l| {
if l.starts_with(&prefix) {
found = true;
format!("{prefix}{value}")
} else {
l.to_string()
}
})
.collect();
if !found {
out.push(format!("{prefix}{value}"));
}
out.join("\n") + "\n"
}
pub fn older_than(installed: Option<&str>, current: &str) -> bool {
fn parse(v: &str) -> Option<(u64, u64, u64)> {
let mut it = v.trim().trim_start_matches('v').split('.');
let t = (
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
it.next()?.split(['-', '+']).next()?.parse().ok()?,
);
Some(t)
}
match (installed.and_then(parse), parse(current)) {
(Some(i), Some(c)) => i < c,
(None, Some(_)) => true,
_ => false,
}
}
pub fn server_asset(os: &str, arch: &str) -> Option<String> {
let os = match os {
"macos" => "darwin",
"linux" => "linux",
"windows" => "windows",
_ => return None,
};
let arch = match arch {
"aarch64" => "arm64",
"x86_64" => "amd64",
_ => return None,
};
if os == "windows" && arch == "arm64" {
return None;
}
let ext = if os == "windows" { ".exe" } else { "" };
Some(format!("deciduous-mcp-{os}-{arch}{ext}"))
}
pub const LAUNCHD_LABEL: &str = "dev.deciduous.server";
pub const SYSTEMD_UNIT: &str = "deciduous-server.service";
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub struct ServicePaths<'a> {
pub binary: &'a Path,
pub env_file: &'a Path,
pub runtime_dir: &'a Path,
pub home: &'a Path,
pub log: &'a Path,
}
pub fn launchd_plist(p: &ServicePaths) -> String {
let s = |path: &Path| xml_escape(&path.display().to_string());
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{bin}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>DECIDUOUS_ENV_FILE</key>
<string>{env}</string>
<key>DECIDUOUS_MCP_INSTALL_DIR</key>
<string>{rt}</string>
</dict>
<key>WorkingDirectory</key>
<string>{home}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
</dict>
</plist>
"#,
bin = s(p.binary),
env = s(p.env_file),
rt = s(p.runtime_dir),
home = s(p.home),
log = s(p.log),
)
}
pub fn systemd_unit(p: &ServicePaths) -> String {
let q = |path: &Path| {
format!(
"\"{}\"",
path.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
)
};
let env = |k: &str, path: &Path| {
format!(
"\"{k}={}\"",
path.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
)
};
format!(
"[Unit]\n\
Description=deciduous graph server\n\
After=network.target\n\
\n\
[Service]\n\
Environment={envfile}\n\
Environment={rt}\n\
WorkingDirectory={home}\n\
ExecStart={bin}\n\
Restart=always\n\
RestartSec=5\n\
StandardOutput=append:{log}\n\
StandardError=append:{log}\n\
\n\
[Install]\n\
WantedBy=default.target\n",
envfile = env("DECIDUOUS_ENV_FILE", p.env_file),
rt = env("DECIDUOUS_MCP_INSTALL_DIR", p.runtime_dir),
home = q(p.home),
bin = q(p.binary),
log = p.log.display(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn the_default_database_is_this_users_postgres_on_5432_with_no_password() {
let db = DbUrl::system_default("bg");
assert_eq!(db.ecto_url(), "ecto://bg@localhost:5432/deciduous");
assert_eq!(db.redacted(), "postgres://bg@localhost:5432/deciduous");
assert!(db.is_loopback());
}
#[test]
fn postgres_postgresql_and_ecto_urls_all_normalise_to_ecto() {
for u in [
"postgres://ann:pw@db.internal:6543/graphs",
"postgresql://ann:pw@db.internal:6543/graphs",
"ecto://ann:pw@db.internal:6543/graphs",
" postgres://ann:pw@db.internal:6543/graphs\n",
] {
assert_eq!(
DbUrl::parse(u).unwrap().ecto_url(),
"ecto://ann:pw@db.internal:6543/graphs",
"{u}"
);
}
assert_eq!(
DbUrl::parse("postgres://ann@h/g").unwrap().ecto_url(),
"ecto://ann@h:5432/g"
);
assert_eq!(
DbUrl::parse("postgres://ann@[::1]:5433/g")
.unwrap()
.ecto_url(),
"ecto://ann@[::1]:5433/g"
);
}
#[test]
fn the_password_is_never_shown_even_through_debug() {
let db = DbUrl::parse("postgres://ann:s3cr%40t@db:5432/g?sslmode=require").unwrap();
assert_eq!(
db.redacted(),
"postgres://ann:***@db:5432/g?sslmode=require"
);
assert!(!format!("{db:?}").contains("s3cr"));
assert!(db.ecto_url().contains("s3cr%40t"), "the server does get it");
}
#[test]
fn a_typed_password_is_encoded_into_the_url_and_still_not_shown() {
let mut db = DbUrl::parse("postgres://ann@db:5432/g").unwrap();
assert!(!db.has_password());
db.set_password("p@ss:w/rd's 1");
assert_eq!(
db.ecto_url(),
"ecto://ann:p%40ss%3Aw%2Frd%27s%201@db:5432/g"
);
assert_eq!(db.redacted(), "postgres://ann:***@db:5432/g");
assert_eq!(DbUrl::parse(&db.ecto_url()).unwrap(), db);
}
#[test]
fn sslmode_becomes_the_servers_tls_settings() {
let ssl = |q: &str| {
DbUrl::parse(&format!("postgres://a@h/d{q}"))
.unwrap()
.ssl_settings()
};
assert_eq!(ssl(""), ("false", "full"));
assert_eq!(ssl("?sslmode=disable"), ("false", "full"));
assert_eq!(ssl("?sslmode=require"), ("true", "none"));
assert_eq!(ssl("?sslmode=verify-ca"), ("true", "ca"));
assert_eq!(ssl("?sslmode=verify-full"), ("true", "full"));
assert!(DbUrl::parse("postgres://a@h/d?sslmode=bogus").is_err());
}
#[test]
fn urls_the_server_could_not_use_are_refused_with_a_reason() {
for (u, why) in [
("mysql://a@h/d", "starts with postgres://"),
("postgres://a@h", "no database"),
("postgres://a@h/", "not a database name"),
("postgres://a@h:port/d", "not a port"),
("postgres://a@h:0/d", "not a port"),
("postgres://h/d", "no user"),
("postgres://a@/d", "no host"),
("postgres://a:it's@h/d", "quotes"),
("postgres://a@h/d?connect_timeout=5", "only sslmode"),
] {
let e = DbUrl::parse(u).unwrap_err();
assert!(e.contains(why), "{u}: {e}");
}
}
#[test]
fn inside_a_container_localhost_becomes_host_docker_internal() {
let db = DbUrl::parse("postgres://bg:pw@127.0.0.1:5432/deciduous").unwrap();
let (mac, note) = db.for_container("macos");
assert_eq!(
mac.ecto_url(),
"ecto://bg:pw@host.docker.internal:5432/deciduous"
);
assert!(note.unwrap().contains("Docker Desktop"));
let (_, note) = db.for_container("linux");
let note = note.unwrap();
assert!(note.contains("host gateway") && note.contains("pg_hba.conf"));
assert!(!note.contains("pw"));
let remote = DbUrl::parse("postgres://a@db.example.com/d").unwrap();
assert_eq!(remote.for_container("linux"), (remote.clone(), None));
}
#[test]
fn with_no_flags_the_plan_is_the_native_server_on_system_postgres() {
assert_eq!(
resolve(&LocalRequest::default()),
Plan {
server: ServerMode::Native,
database: Database::System
}
);
}
#[test]
fn flags_choose_the_database_and_how_the_server_runs() {
let url = Database::Url("postgres://a@h/d".into());
let plan = |database: Option<Database>, docker| {
resolve(&LocalRequest {
port: None,
database,
docker,
})
};
assert_eq!(
plan(Some(url.clone()), false),
Plan {
server: ServerMode::Native,
database: url.clone()
}
);
assert_eq!(
plan(Some(url.clone()), true),
Plan {
server: ServerMode::Docker,
database: url
}
);
assert_eq!(plan(None, true).database, Database::System);
assert_eq!(
plan(Some(Database::DockerPostgres), false).server,
ServerMode::Docker
);
}
#[test]
fn a_settings_file_from_before_native_servers_is_a_docker_install() {
let old = "COMPOSE_PROJECT_NAME=deciduous\nDECIDUOUS_DATABASE_MODE=local\n\
DECIDUOUS_BIND_ADDRESS=127.0.0.1\nDECIDUOUS_PORT=24555\n\
DECIDUOUS_MCP_TOKEN='abc'\nPOSTGRES_PASSWORD='p'\n\
DATABASE_URL='ecto://deciduous:p@db:5432/deciduous'\nDB_SSL=false\n";
let s = Settings::parse(old).unwrap();
assert_eq!(s.mode, ServerMode::Docker);
assert_eq!(s.port, Some(24555));
assert_eq!(s.token.as_deref(), Some("abc"));
assert_eq!(s.version, None);
let external = old.replace("=local", "=external");
assert_eq!(Settings::parse(&external).unwrap().mode, ServerMode::Docker);
assert!(Settings::parse("DECIDUOUS_SERVER_MODE=podman\n").is_err());
}
#[test]
fn a_native_settings_file_says_everything_the_server_reads() {
let db = DbUrl::parse("postgres://bg:pw@localhost:5432/deciduous?sslmode=require").unwrap();
let text = native_settings("1.0.11", 24123, "t0ken", &db);
let s = Settings::parse(&text).unwrap();
assert_eq!(s.mode, ServerMode::Native);
assert_eq!(s.port, Some(24123));
assert_eq!(s.version.as_deref(), Some("1.0.11"));
for line in [
"DECIDUOUS_BIND_ADDRESS=127.0.0.1",
"DATABASE_URL='ecto://bg:pw@localhost:5432/deciduous'",
"DB_SSL=true",
"DB_SSL_VERIFY=none",
"DECIDUOUS_PREPARE_DATABASE=true",
"DECIDUOUS_MCP_TOKEN='t0ken'",
] {
assert!(text.lines().any(|l| l == line), "missing {line}:\n{text}");
}
}
#[test]
fn a_value_is_replaced_in_place_or_appended() {
let t = "A=1\nDECIDUOUS_SERVER_VERSION=1.0.10\nB=2\n";
assert_eq!(
with_value(t, "DECIDUOUS_SERVER_VERSION", "1.0.11"),
"A=1\nDECIDUOUS_SERVER_VERSION=1.0.11\nB=2\n"
);
assert_eq!(with_value("A=1\n", "B", "2"), "A=1\nB=2\n");
}
#[test]
fn an_older_server_is_one_with_a_lower_version_or_none_recorded() {
assert!(older_than(Some("1.0.10"), "1.0.11"));
assert!(older_than(Some("0.9.99"), "1.0.0"));
assert!(!older_than(Some("1.0.11"), "1.0.11"));
assert!(!older_than(Some("1.1.0"), "1.0.11"));
assert!(older_than(None, "1.0.11"));
assert!(older_than(Some("garbage"), "1.0.11"));
}
#[test]
fn the_asset_name_matches_what_the_release_publishes() {
assert_eq!(
server_asset("macos", "aarch64").as_deref(),
Some("deciduous-mcp-darwin-arm64")
);
assert_eq!(
server_asset("linux", "x86_64").as_deref(),
Some("deciduous-mcp-linux-amd64")
);
assert_eq!(
server_asset("windows", "x86_64").as_deref(),
Some("deciduous-mcp-windows-amd64.exe")
);
assert_eq!(server_asset("freebsd", "x86_64"), None);
assert_eq!(server_asset("linux", "riscv64"), None);
let workflow = include_str!("../.github/workflows/release.yml");
for (os, arch) in [
("macos", "aarch64"),
("macos", "x86_64"),
("linux", "aarch64"),
("linux", "x86_64"),
("windows", "x86_64"),
] {
let name = server_asset(os, arch).unwrap();
assert!(
workflow.contains(&format!("artifacts/{0}/{0}", name.trim_end_matches(".exe")))
|| workflow.contains(&format!("/{name}")),
"release.yml does not publish {name}"
);
}
}
fn paths(root: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf, PathBuf) {
let h = PathBuf::from(root);
(
h.join("bin/deciduous-mcp"),
h.join(".env"),
h.join("runtime"),
h.clone(),
h.join("logs/server.log"),
)
}
#[test]
fn the_launch_agent_runs_the_binary_with_only_paths_and_keeps_it_alive() {
let (bin, env, rt, home, log) = paths("/Users/a & b/.config/deciduous/server");
let plist = launchd_plist(&ServicePaths {
binary: &bin,
env_file: &env,
runtime_dir: &rt,
home: &home,
log: &log,
});
assert!(plist.contains("<string>dev.deciduous.server</string>"));
assert!(plist.contains(
"<string>/Users/a & b/.config/deciduous/server/bin/deciduous-mcp</string>"
));
assert!(plist.contains("<key>DECIDUOUS_ENV_FILE</key>"));
assert!(plist.contains("<key>KeepAlive</key>\n <true/>"));
assert!(plist.contains("<key>RunAtLoad</key>\n <true/>"));
assert!(plist.contains("logs/server.log</string>"));
assert!(!plist.contains("DATABASE_URL") && !plist.contains("TOKEN"));
}
#[test]
fn the_systemd_unit_quotes_paths_and_restarts_the_server() {
let (bin, env, rt, home, log) = paths("/home/a b/.config/deciduous/server");
let unit = systemd_unit(&ServicePaths {
binary: &bin,
env_file: &env,
runtime_dir: &rt,
home: &home,
log: &log,
});
assert!(unit.contains("ExecStart=\"/home/a b/.config/deciduous/server/bin/deciduous-mcp\""));
assert!(unit.contains(
"Environment=\"DECIDUOUS_ENV_FILE=/home/a b/.config/deciduous/server/.env\""
));
assert!(unit.contains("Restart=always"));
assert!(unit.contains("WantedBy=default.target"));
assert!(!unit.contains("DATABASE_URL") && !unit.contains("TOKEN"));
}
}