use std::path::{Path, PathBuf};
use base64::prelude::{BASE64_STANDARD, Engine as _};
use secrecy::{SecretBox, SecretString};
use crate::blob::container::DEK_BYTES;
use crate::error::{KernelError, Result};
pub const DATABASE_URL_ENV: &str = "GWK_DATABASE_URL";
pub const ADMIN_DATABASE_URL_ENV: &str = "GWK_ADMIN_DATABASE_URL";
pub const RUNTIME_ROLE_ENV: &str = "GWK_RUNTIME_ROLE";
pub const SOCKET_PATH_ENV: &str = "GWK_SOCKET_PATH";
pub const BLOB_ROOT_ENV: &str = "GWK_BLOB_ROOT";
pub const BLOB_KEK_ENV: &str = "GWK_BLOB_KEK";
pub const BLOB_KEK_ID_ENV: &str = "GWK_BLOB_KEK_ID";
pub const DEFAULT_SOCKET_PATH: &str = "/run/gridwork/gwk.sock";
pub const MAX_IDENTIFIER_BYTES: usize = 63;
pub const MAX_KEK_ID_BYTES: usize = 64;
#[derive(Debug)]
pub struct KernelConfig {
database_url: SecretString,
socket_path: PathBuf,
}
#[derive(Debug)]
pub struct AdminConfig {
admin_database_url: SecretString,
runtime_role: String,
}
impl KernelConfig {
pub fn from_env() -> Result<Self> {
Self::from_lookup(env_lookup)
}
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
if get(ADMIN_DATABASE_URL_ENV).is_some() {
return Err(KernelError::Config(format!(
"{ADMIN_DATABASE_URL_ENV} is set: the schema-owner credential is for one-shot \
`gw admin init` only and must never reach the daemon's environment"
)));
}
let database_url = database_url(&get, DATABASE_URL_ENV)?;
let socket_path = get(SOCKET_PATH_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET_PATH));
if socket_path.as_os_str().is_empty() {
return Err(KernelError::Config(format!("{SOCKET_PATH_ENV} is empty")));
}
Ok(Self {
database_url,
socket_path,
})
}
pub fn database_url(&self) -> &SecretString {
&self.database_url
}
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
}
impl AdminConfig {
pub fn from_env() -> Result<Self> {
Self::from_lookup(env_lookup)
}
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
let admin_database_url = database_url(&get, ADMIN_DATABASE_URL_ENV)?;
let runtime_role = get(RUNTIME_ROLE_ENV).ok_or_else(|| {
KernelError::Config(format!(
"{RUNTIME_ROLE_ENV} is not set: initialization grants an ALREADY-CREATED runtime \
role and never creates one"
))
})?;
validate_role(&runtime_role)?;
Ok(Self {
admin_database_url,
runtime_role,
})
}
pub fn admin_database_url(&self) -> &SecretString {
&self.admin_database_url
}
pub fn runtime_role(&self) -> &str {
&self.runtime_role
}
}
#[derive(Debug)]
pub struct BlobConfig {
root: PathBuf,
kek: SecretBox<[u8; DEK_BYTES]>,
kek_id: String,
}
impl BlobConfig {
pub fn from_env() -> Result<Self> {
Self::from_lookup(env_lookup)
}
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
let raw_root = get(BLOB_ROOT_ENV)
.ok_or_else(|| KernelError::Config(format!("{BLOB_ROOT_ENV} is not set")))?;
let root = PathBuf::from(raw_root.trim());
if root.as_os_str().is_empty() {
return Err(KernelError::Config(format!("{BLOB_ROOT_ENV} is empty")));
}
if !root.is_absolute() {
return Err(KernelError::Config(format!(
"{BLOB_ROOT_ENV} must be an absolute path, got {root:?}"
)));
}
let encoded = get(BLOB_KEK_ENV)
.ok_or_else(|| KernelError::Config(format!("{BLOB_KEK_ENV} is not set")))?;
let decoded = BASE64_STANDARD
.decode(encoded.trim())
.map_err(|_| KernelError::Config(format!("{BLOB_KEK_ENV} is not valid base64")))?;
let mut kek = Box::new([0u8; DEK_BYTES]);
if decoded.len() != DEK_BYTES {
return Err(KernelError::Config(format!(
"{BLOB_KEK_ENV} decodes to {} bytes, expected exactly {DEK_BYTES}",
decoded.len()
)));
}
kek.copy_from_slice(&decoded);
let kek_id = get(BLOB_KEK_ID_ENV)
.ok_or_else(|| KernelError::Config(format!("{BLOB_KEK_ID_ENV} is not set")))?;
validate_kek_id(&kek_id)?;
Ok(Self {
root,
kek: SecretBox::new(kek),
kek_id,
})
}
pub fn new(root: PathBuf, kek: [u8; DEK_BYTES], kek_id: String) -> Result<Self> {
validate_kek_id(&kek_id)?;
Ok(Self {
root,
kek: SecretBox::new(Box::new(kek)),
kek_id,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn kek(&self) -> &SecretBox<[u8; DEK_BYTES]> {
&self.kek
}
pub fn kek_id(&self) -> &str {
&self.kek_id
}
}
pub fn validate_kek_id(kek_id: &str) -> Result<()> {
let invalid = |why: &str| {
Err(KernelError::Config(format!(
"{BLOB_KEK_ID_ENV} {why}: expected 1..={MAX_KEK_ID_BYTES} bytes matching \
[A-Za-z0-9._-], got {kek_id:?}"
)))
};
if kek_id.is_empty() {
return invalid("is empty");
}
if kek_id.len() > MAX_KEK_ID_BYTES {
return invalid("is too long");
}
if !kek_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
{
return invalid("contains a character outside [A-Za-z0-9._-]");
}
Ok(())
}
fn env_lookup(key: &str) -> Option<String> {
std::env::var(key).ok()
}
fn database_url(get: &impl Fn(&str) -> Option<String>, key: &str) -> Result<SecretString> {
let raw = get(key).ok_or_else(|| KernelError::Config(format!("{key} is not set")))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(KernelError::Config(format!("{key} is empty")));
}
if !trimmed.starts_with("postgres://") && !trimmed.starts_with("postgresql://") {
return Err(KernelError::Config(format!(
"{key} is not a PostgreSQL URL (expected a postgres:// or postgresql:// scheme)"
)));
}
Ok(SecretString::from(trimmed.to_owned()))
}
pub fn validate_role(role: &str) -> Result<()> {
let invalid = |why: &str| {
Err(KernelError::Config(format!(
"{RUNTIME_ROLE_ENV} {why}: expected a bare lowercase identifier matching \
[a-z_][a-z0-9_]* of at most {MAX_IDENTIFIER_BYTES} bytes, got {role:?}"
)))
};
if role.is_empty() {
return invalid("is empty");
}
if role.len() > MAX_IDENTIFIER_BYTES {
return invalid("is too long");
}
let mut bytes = role.bytes();
let first = bytes.next().unwrap_or(b'0');
if !(first.is_ascii_lowercase() || first == b'_') {
return invalid("does not start with a lowercase letter or underscore");
}
if !bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
return invalid("contains a character outside [a-z0-9_]");
}
Ok(())
}
#[cfg(test)]
mod tests {
use secrecy::ExposeSecret;
use super::*;
fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let owned: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
move |key| {
owned
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.to_owned())
}
}
const DSN: &str = "postgres://gwk@/gridwork";
const ADMIN_DSN: &str = "postgres://owner@/gridwork";
#[test]
fn the_daemon_refuses_to_start_beside_the_admin_credential() {
let err = KernelConfig::from_lookup(lookup(&[
(DATABASE_URL_ENV, DSN),
(ADMIN_DATABASE_URL_ENV, ADMIN_DSN),
]))
.expect_err("both DSNs present must refuse");
assert!(
err.to_string().contains(ADMIN_DATABASE_URL_ENV),
"the error must name the offending variable: {err}"
);
}
#[test]
fn the_daemon_defaults_its_socket_and_keeps_its_dsn() {
let cfg = KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, DSN)])).expect("config");
assert_eq!(cfg.socket_path(), Path::new(DEFAULT_SOCKET_PATH));
assert_eq!(cfg.database_url().expose_secret(), DSN);
let cfg = KernelConfig::from_lookup(lookup(&[
(DATABASE_URL_ENV, DSN),
(SOCKET_PATH_ENV, "/tmp/gwk.sock"),
]))
.expect("config");
assert_eq!(cfg.socket_path(), Path::new("/tmp/gwk.sock"));
}
#[test]
fn a_missing_or_non_postgres_dsn_is_named_at_startup() {
let err = KernelConfig::from_lookup(lookup(&[])).expect_err("missing DSN");
assert!(err.to_string().contains(DATABASE_URL_ENV), "{err}");
for bad in ["", " ", "mysql://x/y", "/var/run/postgres", "gridwork"] {
let err = KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, bad)]))
.expect_err("non-postgres DSN must refuse");
assert!(err.to_string().contains(DATABASE_URL_ENV), "{bad:?}: {err}");
}
for good in ["postgres://gwk@/db", "postgresql://gwk@/db"] {
KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, good)])).expect(good);
}
}
#[test]
fn the_admin_needs_a_role_to_grant() {
let err = AdminConfig::from_lookup(lookup(&[(ADMIN_DATABASE_URL_ENV, ADMIN_DSN)]))
.expect_err("missing role");
assert!(err.to_string().contains(RUNTIME_ROLE_ENV), "{err}");
let cfg = AdminConfig::from_lookup(lookup(&[
(ADMIN_DATABASE_URL_ENV, ADMIN_DSN),
(RUNTIME_ROLE_ENV, "gwk_runtime"),
]))
.expect("config");
assert_eq!(cfg.runtime_role(), "gwk_runtime");
assert_eq!(cfg.admin_database_url().expose_secret(), ADMIN_DSN);
}
fn kek_b64() -> String {
BASE64_STANDARD.encode([7u8; DEK_BYTES])
}
#[test]
fn the_blob_spine_refuses_to_start_without_a_root_a_key_and_a_label() {
let full = |root: &str| {
vec![
(BLOB_ROOT_ENV, root.to_owned()),
(BLOB_KEK_ENV, kek_b64()),
(BLOB_KEK_ID_ENV, "kek-2026-07".to_owned()),
]
};
let lookup_owned = |pairs: Vec<(&str, String)>| {
let owned: Vec<(String, String)> =
pairs.into_iter().map(|(k, v)| (k.to_owned(), v)).collect();
move |key: &str| {
owned
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.to_owned())
}
};
let cfg =
BlobConfig::from_lookup(lookup_owned(full("/var/lib/gridwork/blobs"))).expect("config");
assert_eq!(cfg.root(), Path::new("/var/lib/gridwork/blobs"));
assert_eq!(cfg.kek_id(), "kek-2026-07");
assert_eq!(cfg.kek().expose_secret(), &[7u8; DEK_BYTES]);
for missing in [BLOB_ROOT_ENV, BLOB_KEK_ENV, BLOB_KEK_ID_ENV] {
let pairs: Vec<_> = full("/var/lib/gridwork/blobs")
.into_iter()
.filter(|(k, _)| *k != missing)
.collect();
let err = BlobConfig::from_lookup(lookup_owned(pairs))
.expect_err(&format!("{missing} must be required"));
assert!(err.to_string().contains(missing), "{err}");
}
for bad_root in ["", " ", "blobs", "./blobs", "../blobs"] {
BlobConfig::from_lookup(lookup_owned(full(bad_root)))
.expect_err(&format!("{bad_root:?} must be refused"));
}
}
#[test]
fn the_kek_must_decode_to_exactly_one_key() {
let with_kek = |value: &str| {
let owned = value.to_owned();
move |key: &str| match key {
BLOB_ROOT_ENV => Some("/var/lib/gridwork/blobs".to_owned()),
BLOB_KEK_ENV => Some(owned.clone()),
BLOB_KEK_ID_ENV => Some("kek-2026-07".to_owned()),
_ => None,
}
};
BlobConfig::from_lookup(with_kek(&kek_b64())).expect("32 bytes");
BlobConfig::from_lookup(with_kek(&format!(" {}\n", kek_b64()))).expect("trimmed");
for (why, value) in [
("not base64", "not base64 at all!".to_owned()),
("too short", BASE64_STANDARD.encode([7u8; DEK_BYTES - 1])),
("too long", BASE64_STANDARD.encode([7u8; DEK_BYTES + 1])),
("empty", String::new()),
] {
let err = BlobConfig::from_lookup(with_kek(&value))
.expect_err(&format!("{why} must be refused"));
let message = err.to_string();
assert!(message.contains(BLOB_KEK_ENV), "{why}: {message}");
assert!(
!message.contains(&value) || value.is_empty(),
"{why}: {message}"
);
}
}
#[test]
fn the_kek_label_stays_a_plain_short_name() {
for good in ["k", "kek-2026-07", "prod.blob_kek", &"a".repeat(64)] {
validate_kek_id(good).unwrap_or_else(|e| panic!("{good:?} should be legal: {e}"));
}
for bad in [
"",
"with space",
"with/slash",
"with\0null",
"with\nnewline",
"émoji",
&"a".repeat(65),
] {
validate_kek_id(bad).expect_err(&format!("{bad:?} must be refused"));
}
}
#[test]
fn only_a_bare_lowercase_identifier_reaches_the_grant_script() {
for good in ["gwk_runtime", "_x", "r0", "a".repeat(63).as_str()] {
validate_role(good).unwrap_or_else(|e| panic!("{good:?} should be legal: {e}"));
}
for bad in [
"",
"0leading",
"Upper",
"with-dash",
"with space",
"quote\"d",
"semi;colon",
"dash--comment",
"role; DROP SCHEMA gwk CASCADE",
"a".repeat(64).as_str(),
] {
validate_role(bad).expect_err(&format!("{bad:?} must be refused"));
}
}
}