1use std::path::{Path, PathBuf};
12
13use base64::prelude::{BASE64_STANDARD, Engine as _};
14use secrecy::{SecretBox, SecretString};
15
16use crate::blob::container::DEK_BYTES;
17use crate::error::{KernelError, Result};
18
19pub const DATABASE_URL_ENV: &str = "GWK_DATABASE_URL";
21pub const ADMIN_DATABASE_URL_ENV: &str = "GWK_ADMIN_DATABASE_URL";
23pub const RUNTIME_ROLE_ENV: &str = "GWK_RUNTIME_ROLE";
25pub const SOCKET_PATH_ENV: &str = "GWK_SOCKET_PATH";
27
28pub const BLOB_ROOT_ENV: &str = "GWK_BLOB_ROOT";
30pub const BLOB_KEK_ENV: &str = "GWK_BLOB_KEK";
32pub const BLOB_KEK_ID_ENV: &str = "GWK_BLOB_KEK_ID";
34
35pub const DEFAULT_SOCKET_PATH: &str = "/run/gridwork/gwk.sock";
37
38pub const MAX_IDENTIFIER_BYTES: usize = 63;
40
41pub const MAX_KEK_ID_BYTES: usize = 64;
44
45#[derive(Debug)]
49pub struct KernelConfig {
50 database_url: SecretString,
51 socket_path: PathBuf,
52}
53
54#[derive(Debug)]
56pub struct AdminConfig {
57 admin_database_url: SecretString,
58 runtime_role: String,
59}
60
61impl KernelConfig {
62 pub fn from_env() -> Result<Self> {
63 Self::from_lookup(env_lookup)
64 }
65
66 pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
69 if get(ADMIN_DATABASE_URL_ENV).is_some() {
70 return Err(KernelError::Config(format!(
71 "{ADMIN_DATABASE_URL_ENV} is set: the schema-owner credential is for one-shot \
72 `gw admin init` only and must never reach the daemon's environment"
73 )));
74 }
75 let database_url = database_url(&get, DATABASE_URL_ENV)?;
76 let socket_path = get(SOCKET_PATH_ENV)
77 .map(PathBuf::from)
78 .unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET_PATH));
79 if socket_path.as_os_str().is_empty() {
80 return Err(KernelError::Config(format!("{SOCKET_PATH_ENV} is empty")));
81 }
82 Ok(Self {
83 database_url,
84 socket_path,
85 })
86 }
87
88 pub fn database_url(&self) -> &SecretString {
89 &self.database_url
90 }
91
92 pub fn socket_path(&self) -> &Path {
93 &self.socket_path
94 }
95}
96
97impl AdminConfig {
98 pub fn from_env() -> Result<Self> {
99 Self::from_lookup(env_lookup)
100 }
101
102 pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
103 let admin_database_url = database_url(&get, ADMIN_DATABASE_URL_ENV)?;
104 let runtime_role = get(RUNTIME_ROLE_ENV).ok_or_else(|| {
105 KernelError::Config(format!(
106 "{RUNTIME_ROLE_ENV} is not set: initialization grants an ALREADY-CREATED runtime \
107 role and never creates one"
108 ))
109 })?;
110 validate_role(&runtime_role)?;
111 Ok(Self {
112 admin_database_url,
113 runtime_role,
114 })
115 }
116
117 pub fn admin_database_url(&self) -> &SecretString {
118 &self.admin_database_url
119 }
120
121 pub fn runtime_role(&self) -> &str {
123 &self.runtime_role
124 }
125}
126
127#[derive(Debug)]
138pub struct BlobConfig {
139 root: PathBuf,
140 kek: SecretBox<[u8; DEK_BYTES]>,
141 kek_id: String,
142}
143
144impl BlobConfig {
145 pub fn from_env() -> Result<Self> {
146 Self::from_lookup(env_lookup)
147 }
148
149 pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
150 let raw_root = get(BLOB_ROOT_ENV)
151 .ok_or_else(|| KernelError::Config(format!("{BLOB_ROOT_ENV} is not set")))?;
152 let root = PathBuf::from(raw_root.trim());
153 if root.as_os_str().is_empty() {
154 return Err(KernelError::Config(format!("{BLOB_ROOT_ENV} is empty")));
155 }
156 if !root.is_absolute() {
160 return Err(KernelError::Config(format!(
161 "{BLOB_ROOT_ENV} must be an absolute path, got {root:?}"
162 )));
163 }
164
165 let encoded = get(BLOB_KEK_ENV)
166 .ok_or_else(|| KernelError::Config(format!("{BLOB_KEK_ENV} is not set")))?;
167 let decoded = BASE64_STANDARD
168 .decode(encoded.trim())
169 .map_err(|_| KernelError::Config(format!("{BLOB_KEK_ENV} is not valid base64")))?;
172 let mut kek = Box::new([0u8; DEK_BYTES]);
173 if decoded.len() != DEK_BYTES {
174 return Err(KernelError::Config(format!(
175 "{BLOB_KEK_ENV} decodes to {} bytes, expected exactly {DEK_BYTES}",
176 decoded.len()
177 )));
178 }
179 kek.copy_from_slice(&decoded);
180
181 let kek_id = get(BLOB_KEK_ID_ENV)
182 .ok_or_else(|| KernelError::Config(format!("{BLOB_KEK_ID_ENV} is not set")))?;
183 validate_kek_id(&kek_id)?;
184
185 Ok(Self {
186 root,
187 kek: SecretBox::new(kek),
188 kek_id,
189 })
190 }
191
192 pub fn new(root: PathBuf, kek: [u8; DEK_BYTES], kek_id: String) -> Result<Self> {
195 validate_kek_id(&kek_id)?;
196 Ok(Self {
197 root,
198 kek: SecretBox::new(Box::new(kek)),
199 kek_id,
200 })
201 }
202
203 pub fn root(&self) -> &Path {
204 &self.root
205 }
206
207 pub fn kek(&self) -> &SecretBox<[u8; DEK_BYTES]> {
208 &self.kek
209 }
210
211 pub fn kek_id(&self) -> &str {
212 &self.kek_id
213 }
214}
215
216pub fn validate_kek_id(kek_id: &str) -> Result<()> {
221 let invalid = |why: &str| {
222 Err(KernelError::Config(format!(
223 "{BLOB_KEK_ID_ENV} {why}: expected 1..={MAX_KEK_ID_BYTES} bytes matching \
224 [A-Za-z0-9._-], got {kek_id:?}"
225 )))
226 };
227 if kek_id.is_empty() {
228 return invalid("is empty");
229 }
230 if kek_id.len() > MAX_KEK_ID_BYTES {
231 return invalid("is too long");
232 }
233 if !kek_id
234 .bytes()
235 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
236 {
237 return invalid("contains a character outside [A-Za-z0-9._-]");
238 }
239 Ok(())
240}
241
242fn env_lookup(key: &str) -> Option<String> {
243 std::env::var(key).ok()
244}
245
246fn database_url(get: &impl Fn(&str) -> Option<String>, key: &str) -> Result<SecretString> {
250 let raw = get(key).ok_or_else(|| KernelError::Config(format!("{key} is not set")))?;
251 let trimmed = raw.trim();
252 if trimmed.is_empty() {
253 return Err(KernelError::Config(format!("{key} is empty")));
254 }
255 if !trimmed.starts_with("postgres://") && !trimmed.starts_with("postgresql://") {
256 return Err(KernelError::Config(format!(
257 "{key} is not a PostgreSQL URL (expected a postgres:// or postgresql:// scheme)"
258 )));
259 }
260 Ok(SecretString::from(trimmed.to_owned()))
261}
262
263pub fn validate_role(role: &str) -> Result<()> {
268 let invalid = |why: &str| {
269 Err(KernelError::Config(format!(
270 "{RUNTIME_ROLE_ENV} {why}: expected a bare lowercase identifier matching \
271 [a-z_][a-z0-9_]* of at most {MAX_IDENTIFIER_BYTES} bytes, got {role:?}"
272 )))
273 };
274 if role.is_empty() {
275 return invalid("is empty");
276 }
277 if role.len() > MAX_IDENTIFIER_BYTES {
278 return invalid("is too long");
279 }
280 let mut bytes = role.bytes();
281 let first = bytes.next().unwrap_or(b'0');
282 if !(first.is_ascii_lowercase() || first == b'_') {
283 return invalid("does not start with a lowercase letter or underscore");
284 }
285 if !bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
286 return invalid("contains a character outside [a-z0-9_]");
287 }
288 Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293 use secrecy::ExposeSecret;
294
295 use super::*;
296
297 fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
298 let owned: Vec<(String, String)> = pairs
299 .iter()
300 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
301 .collect();
302 move |key| {
303 owned
304 .iter()
305 .find(|(k, _)| k == key)
306 .map(|(_, v)| v.to_owned())
307 }
308 }
309
310 const DSN: &str = "postgres://gwk@/gridwork";
311 const ADMIN_DSN: &str = "postgres://owner@/gridwork";
312
313 #[test]
314 fn the_daemon_refuses_to_start_beside_the_admin_credential() {
315 let err = KernelConfig::from_lookup(lookup(&[
316 (DATABASE_URL_ENV, DSN),
317 (ADMIN_DATABASE_URL_ENV, ADMIN_DSN),
318 ]))
319 .expect_err("both DSNs present must refuse");
320 assert!(
321 err.to_string().contains(ADMIN_DATABASE_URL_ENV),
322 "the error must name the offending variable: {err}"
323 );
324 }
325
326 #[test]
327 fn the_daemon_defaults_its_socket_and_keeps_its_dsn() {
328 let cfg = KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, DSN)])).expect("config");
329 assert_eq!(cfg.socket_path(), Path::new(DEFAULT_SOCKET_PATH));
330 assert_eq!(cfg.database_url().expose_secret(), DSN);
331
332 let cfg = KernelConfig::from_lookup(lookup(&[
333 (DATABASE_URL_ENV, DSN),
334 (SOCKET_PATH_ENV, "/tmp/gwk.sock"),
335 ]))
336 .expect("config");
337 assert_eq!(cfg.socket_path(), Path::new("/tmp/gwk.sock"));
338 }
339
340 #[test]
341 fn a_missing_or_non_postgres_dsn_is_named_at_startup() {
342 let err = KernelConfig::from_lookup(lookup(&[])).expect_err("missing DSN");
343 assert!(err.to_string().contains(DATABASE_URL_ENV), "{err}");
344
345 for bad in ["", " ", "mysql://x/y", "/var/run/postgres", "gridwork"] {
346 let err = KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, bad)]))
347 .expect_err("non-postgres DSN must refuse");
348 assert!(err.to_string().contains(DATABASE_URL_ENV), "{bad:?}: {err}");
349 }
350 for good in ["postgres://gwk@/db", "postgresql://gwk@/db"] {
352 KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, good)])).expect(good);
353 }
354 }
355
356 #[test]
357 fn the_admin_needs_a_role_to_grant() {
358 let err = AdminConfig::from_lookup(lookup(&[(ADMIN_DATABASE_URL_ENV, ADMIN_DSN)]))
359 .expect_err("missing role");
360 assert!(err.to_string().contains(RUNTIME_ROLE_ENV), "{err}");
361
362 let cfg = AdminConfig::from_lookup(lookup(&[
363 (ADMIN_DATABASE_URL_ENV, ADMIN_DSN),
364 (RUNTIME_ROLE_ENV, "gwk_runtime"),
365 ]))
366 .expect("config");
367 assert_eq!(cfg.runtime_role(), "gwk_runtime");
368 assert_eq!(cfg.admin_database_url().expose_secret(), ADMIN_DSN);
369 }
370
371 fn kek_b64() -> String {
374 BASE64_STANDARD.encode([7u8; DEK_BYTES])
375 }
376
377 #[test]
378 fn the_blob_spine_refuses_to_start_without_a_root_a_key_and_a_label() {
379 let full = |root: &str| {
380 vec![
381 (BLOB_ROOT_ENV, root.to_owned()),
382 (BLOB_KEK_ENV, kek_b64()),
383 (BLOB_KEK_ID_ENV, "kek-2026-07".to_owned()),
384 ]
385 };
386 let lookup_owned = |pairs: Vec<(&str, String)>| {
387 let owned: Vec<(String, String)> =
388 pairs.into_iter().map(|(k, v)| (k.to_owned(), v)).collect();
389 move |key: &str| {
390 owned
391 .iter()
392 .find(|(k, _)| k == key)
393 .map(|(_, v)| v.to_owned())
394 }
395 };
396
397 let cfg =
398 BlobConfig::from_lookup(lookup_owned(full("/var/lib/gridwork/blobs"))).expect("config");
399 assert_eq!(cfg.root(), Path::new("/var/lib/gridwork/blobs"));
400 assert_eq!(cfg.kek_id(), "kek-2026-07");
401 assert_eq!(cfg.kek().expose_secret(), &[7u8; DEK_BYTES]);
402
403 for missing in [BLOB_ROOT_ENV, BLOB_KEK_ENV, BLOB_KEK_ID_ENV] {
407 let pairs: Vec<_> = full("/var/lib/gridwork/blobs")
408 .into_iter()
409 .filter(|(k, _)| *k != missing)
410 .collect();
411 let err = BlobConfig::from_lookup(lookup_owned(pairs))
412 .expect_err(&format!("{missing} must be required"));
413 assert!(err.to_string().contains(missing), "{err}");
414 }
415
416 for bad_root in ["", " ", "blobs", "./blobs", "../blobs"] {
419 BlobConfig::from_lookup(lookup_owned(full(bad_root)))
420 .expect_err(&format!("{bad_root:?} must be refused"));
421 }
422 }
423
424 #[test]
425 fn the_kek_must_decode_to_exactly_one_key() {
426 let with_kek = |value: &str| {
427 let owned = value.to_owned();
428 move |key: &str| match key {
429 BLOB_ROOT_ENV => Some("/var/lib/gridwork/blobs".to_owned()),
430 BLOB_KEK_ENV => Some(owned.clone()),
431 BLOB_KEK_ID_ENV => Some("kek-2026-07".to_owned()),
432 _ => None,
433 }
434 };
435 BlobConfig::from_lookup(with_kek(&kek_b64())).expect("32 bytes");
436 BlobConfig::from_lookup(with_kek(&format!(" {}\n", kek_b64()))).expect("trimmed");
438
439 for (why, value) in [
440 ("not base64", "not base64 at all!".to_owned()),
441 ("too short", BASE64_STANDARD.encode([7u8; DEK_BYTES - 1])),
442 ("too long", BASE64_STANDARD.encode([7u8; DEK_BYTES + 1])),
443 ("empty", String::new()),
444 ] {
445 let err = BlobConfig::from_lookup(with_kek(&value))
446 .expect_err(&format!("{why} must be refused"));
447 let message = err.to_string();
448 assert!(message.contains(BLOB_KEK_ENV), "{why}: {message}");
449 assert!(
453 !message.contains(&value) || value.is_empty(),
454 "{why}: {message}"
455 );
456 }
457 }
458
459 #[test]
460 fn the_kek_label_stays_a_plain_short_name() {
461 for good in ["k", "kek-2026-07", "prod.blob_kek", &"a".repeat(64)] {
462 validate_kek_id(good).unwrap_or_else(|e| panic!("{good:?} should be legal: {e}"));
463 }
464 for bad in [
467 "",
468 "with space",
469 "with/slash",
470 "with\0null",
471 "with\nnewline",
472 "émoji",
473 &"a".repeat(65),
474 ] {
475 validate_kek_id(bad).expect_err(&format!("{bad:?} must be refused"));
476 }
477 }
478
479 #[test]
480 fn only_a_bare_lowercase_identifier_reaches_the_grant_script() {
481 for good in ["gwk_runtime", "_x", "r0", "a".repeat(63).as_str()] {
482 validate_role(good).unwrap_or_else(|e| panic!("{good:?} should be legal: {e}"));
483 }
484 for bad in [
486 "",
487 "0leading",
488 "Upper",
489 "with-dash",
490 "with space",
491 "quote\"d",
492 "semi;colon",
493 "dash--comment",
494 "role; DROP SCHEMA gwk CASCADE",
495 "a".repeat(64).as_str(),
496 ] {
497 validate_role(bad).expect_err(&format!("{bad:?} must be refused"));
498 }
499 }
500}