feat: harden community production foundation through story 1.5

This commit is contained in:
2026-08-14 00:21:59 +03:00
parent c30461cc92
commit f6fc2e5c9b
161 changed files with 16758 additions and 2515 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "crank-config"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
publish.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
url.workspace = true
@@ -0,0 +1,73 @@
use std::{env, fs, path::Path};
use crank_config::render::{
BEGIN_MARKER, DOC_BEGIN_MARKER, DOC_END_MARKER, END_MARKER, env_section, reference_section,
replace_marked, schema_json,
};
fn main() {
if let Err(error) = run() {
eprintln!("config contract check failed: {error}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let mode = env::args().nth(1).unwrap_or_else(|| "--check".to_owned());
if !matches!(mode.as_str(), "--check" | "--write") {
return Err("expected --check or --write".to_owned());
}
let write = mode == "--write";
sync_file(
Path::new("docs/schemas/runtime-config.schema.json"),
schema_json(),
write,
)?;
for (path, production) in [
(".env.example", false),
("deploy/community/.env.example", true),
("deploy/community/.env.images.example", true),
] {
sync_marked(
Path::new(path),
BEGIN_MARKER,
END_MARKER,
&env_section(production),
write,
)?;
}
sync_marked(
Path::new("docs/runtime-config.md"),
DOC_BEGIN_MARKER,
DOC_END_MARKER,
&reference_section(),
write,
)?;
Ok(())
}
fn sync_marked(
path: &Path,
begin: &str,
end: &str,
replacement: &str,
write: bool,
) -> Result<(), String> {
let current =
fs::read_to_string(path).map_err(|_| format!("cannot read {}", path.display()))?;
let expected = replace_marked(&current, begin, end, replacement)
.ok_or_else(|| format!("missing generated markers in {}", path.display()))?;
sync_file(path, expected, write)
}
fn sync_file(path: &Path, expected: String, write: bool) -> Result<(), String> {
let current = fs::read_to_string(path).unwrap_or_default();
if current == expected {
return Ok(());
}
if write {
fs::write(path, expected).map_err(|_| format!("cannot write {}", path.display()))
} else {
Err(format!("{} is out of date", path.display()))
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::fmt;
use crate::{
AdminProcessConfig, CacheSettings, DatabaseSettings, McpProcessConfig, MetricsSettings,
MigratorConfig, ObservabilitySettings, OtlpSettings, OutboundSettings, RuntimeSettings,
};
impl fmt::Debug for DatabaseSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DatabaseSettings")
.field("url", &self.url.as_ref().map(|_| "configured"))
.field("password", &self.password)
.field("pool", &self.pool)
.finish_non_exhaustive()
}
}
impl fmt::Debug for CacheSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CacheSettings")
.field("backend", &self.backend)
.field("url", &self.url.as_ref().map(|_| "configured"))
.finish()
}
}
impl fmt::Debug for OutboundSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OutboundSettings")
.field("allowed_host_count", &self.allowed_hosts.len())
.field("denied_host_count", &self.denied_hosts.len())
.field("max_response_bytes", &self.max_response_bytes)
.finish()
}
}
impl fmt::Debug for RuntimeSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RuntimeSettings")
.field("master_key", &self.master_key)
.field("base_url", &self.base_url.as_ref().map(|_| "configured"))
.field("max_concurrent_unary", &self.max_concurrent_unary)
.field("max_concurrent_sessions", &self.max_concurrent_sessions)
.field("cache", &self.cache)
.field("outbound", &self.outbound)
.finish()
}
}
impl fmt::Debug for MetricsSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MetricsSettings")
.field("enabled", &self.enabled)
.field("loopback", &self.bind_addr.ip().is_loopback())
.field(
"bearer_token",
&self.bearer_token.as_ref().map(|_| "configured"),
)
.finish()
}
}
impl fmt::Debug for OtlpSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OtlpSettings")
.field("endpoint", &self.endpoint.as_ref().map(|_| "configured"))
.field(
"traces_endpoint",
&self.traces_endpoint.as_ref().map(|_| "configured"),
)
.field("headers", &self.headers.as_ref().map(|_| "configured"))
.field(
"traces_headers",
&self.traces_headers.as_ref().map(|_| "configured"),
)
.field("max_queue_size", &self.max_queue_size)
.field("max_export_batch_size", &self.max_export_batch_size)
.finish_non_exhaustive()
}
}
impl fmt::Debug for ObservabilitySettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ObservabilitySettings")
.field("environment", &"configured")
.field("log_filter", &"configured")
.field(
"sentry_dsn",
&self.sentry_dsn.as_ref().map(|_| "configured"),
)
.field("metrics", &self.metrics)
.field("otlp", &self.otlp)
.finish()
}
}
impl fmt::Debug for AdminProcessConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AdminProcessConfig")
.field("database", &self.database)
.field("runtime", &self.runtime)
.field("observability", &self.observability)
.field("storage_root", &"configured")
.field("session_secret", &self.session_secret)
.field("password_pepper", &self.password_pepper)
.field("bootstrap_password", &self.bootstrap_password)
.finish_non_exhaustive()
}
}
impl fmt::Debug for McpProcessConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("McpProcessConfig")
.field("database", &self.database)
.field("runtime", &self.runtime)
.field("observability", &self.observability)
.finish_non_exhaustive()
}
}
impl fmt::Debug for MigratorConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MigratorConfig")
.field("database", &self.database)
.field("fingerprint", &self.fingerprint())
.finish()
}
}
+206
View File
@@ -0,0 +1,206 @@
use std::fmt;
use serde::{Serialize, Serializer};
const MAX_DIAGNOSTICS: usize = 100;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum DiagnosticCode {
MissingRequired,
InvalidEncoding,
InvalidType,
OutOfRange,
UnknownField,
Conflict,
UnsafeCombination,
DeprecatedNoEffect,
}
impl Serialize for DiagnosticCode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl DiagnosticCode {
pub const fn as_str(self) -> &'static str {
match self {
Self::MissingRequired => "config.missing_required",
Self::InvalidEncoding => "config.invalid_encoding",
Self::InvalidType => "config.invalid_type",
Self::OutOfRange => "config.out_of_range",
Self::UnknownField => "config.unknown_field",
Self::Conflict => "config.conflict",
Self::UnsafeCombination => "config.unsafe_combination",
Self::DeprecatedNoEffect => "config.deprecated_no_effect",
}
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct Diagnostic {
pub code: DiagnosticCode,
pub field: String,
pub message_ru: &'static str,
pub message_en: &'static str,
}
impl Diagnostic {
pub(crate) fn new(code: DiagnosticCode, field: impl Into<String>) -> Self {
let (message_ru, message_en) = match code {
DiagnosticCode::MissingRequired => (
"Обязательный параметр не настроен.",
"A required configuration field is not configured.",
),
DiagnosticCode::InvalidEncoding => (
"Параметр должен быть корректной строкой UTF-8.",
"The configuration field must be valid UTF-8.",
),
DiagnosticCode::InvalidType => (
"Параметр имеет недопустимый тип или формат.",
"The configuration field has an invalid type or format.",
),
DiagnosticCode::OutOfRange => (
"Параметр находится вне допустимых границ.",
"The configuration field is outside its allowed bounds.",
),
DiagnosticCode::UnknownField => (
"Неизвестный параметр в управляемом пространстве имён.",
"Unknown field in an owned configuration namespace.",
),
DiagnosticCode::Conflict => (
"Одновременно заданы конфликтующие источники конфигурации.",
"Conflicting configuration sources are set at the same time.",
),
DiagnosticCode::UnsafeCombination => (
"Комбинация параметров небезопасна или противоречива.",
"The configuration combination is unsafe or inconsistent.",
),
DiagnosticCode::DeprecatedNoEffect => (
"Устаревший параметр не имеет поддерживаемого эффекта.",
"The deprecated field has no supported effect.",
),
};
let mut field = field.into();
if field.len() > 256 {
let mut boundary = 256;
while !field.is_char_boundary(boundary) {
boundary -= 1;
}
field.truncate(boundary);
}
Self {
code,
field,
message_ru,
message_en,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigError {
diagnostics: Vec<Diagnostic>,
omitted: usize,
}
impl ConfigError {
pub fn single(code: DiagnosticCode, field: impl Into<String>) -> Self {
Self::from_diagnostics(vec![Diagnostic::new(code, field)])
}
pub(crate) fn from_diagnostics(mut diagnostics: Vec<Diagnostic>) -> Self {
diagnostics.sort();
diagnostics.dedup();
let mut omitted = diagnostics.len().saturating_sub(MAX_DIAGNOSTICS);
diagnostics.truncate(MAX_DIAGNOSTICS);
while serialized_len(&diagnostics, omitted) > 65_536 && !diagnostics.is_empty() {
diagnostics.pop();
omitted += 1;
}
Self {
diagnostics,
omitted,
}
}
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
pub fn omitted(&self) -> usize {
self.omitted
}
pub fn to_json(&self) -> String {
#[derive(Serialize)]
struct Report<'a> {
diagnostics: &'a [Diagnostic],
omitted: usize,
}
serde_json::to_string(&Report {
diagnostics: &self.diagnostics,
omitted: self.omitted,
})
.unwrap_or_else(|_| "{\"diagnostics\":[],\"omitted\":0}".to_owned())
}
}
fn serialized_len(diagnostics: &[Diagnostic], omitted: usize) -> usize {
#[derive(Serialize)]
struct Report<'a> {
diagnostics: &'a [Diagnostic],
omitted: usize,
}
serde_json::to_vec(&Report {
diagnostics,
omitted,
})
.map_or(usize::MAX, |value| value.len())
}
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, diagnostic) in self.diagnostics.iter().enumerate() {
if index > 0 {
formatter.write_str("; ")?;
}
write!(
formatter,
"{} field={} ru={} en={}",
diagnostic.code.as_str(),
diagnostic.field,
diagnostic.message_ru,
diagnostic.message_en
)?;
}
if self.omitted > 0 {
write!(formatter, "; diagnostics_omitted={}", self.omitted)?;
}
Ok(())
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unicode_fields_and_worst_case_json_remain_bounded() {
let field = format!("{}{}", "\\\"".repeat(120), "💣".repeat(100));
let diagnostics = (0..200)
.map(|index| Diagnostic::new(DiagnosticCode::InvalidType, format!("{index}:{field}")))
.collect();
let error = ConfigError::from_diagnostics(diagnostics);
let json = error.to_json();
assert!(json.len() <= 65_536);
assert!(error.omitted() > 0);
assert!(serde_json::from_str::<serde_json::Value>(&json).is_ok());
assert!(json.contains("config.invalid_type"));
}
}
+11
View File
@@ -0,0 +1,11 @@
use sha2::{Digest, Sha256};
pub(crate) fn sha256_hex(parts: impl IntoIterator<Item = String>) -> String {
let mut hasher = Sha256::new();
hasher.update(b"crank-config-fingerprint-v1\0");
for part in parts {
hasher.update(part.len().to_le_bytes());
hasher.update(part.as_bytes());
}
format!("{:x}", hasher.finalize())
}
+25
View File
@@ -0,0 +1,25 @@
//! Typed bootstrap configuration contract for Crank processes.
mod debug;
mod diagnostic;
mod fingerprint;
mod migrator;
mod process;
pub mod render;
mod schema;
mod source;
mod validation;
mod value;
pub use diagnostic::{ConfigError, Diagnostic, DiagnosticCode};
pub use migrator::{MigratorConfig, parse_migrator};
pub use process::{
AdminProcessConfig, CacheBackend, CacheSettings, DatabaseSettings, DeprecationRecord,
EffectiveConfig, McpProcessConfig, MetricsSettings, ObservabilitySettings, OtlpSettings,
OutboundSettings, PoolSettings, ProcessKind, RateLimitSettings, RuntimeSettings, parse_process,
};
pub use schema::{
FieldMode, FieldSpec, ProcessScope, Sensitivity, deployment_field_registry, field_registry,
};
pub use source::ConfigSource;
pub use value::SecretString;
+47
View File
@@ -0,0 +1,47 @@
use crate::{
ConfigError, ConfigSource, DatabaseSettings, DeprecationRecord, fingerprint::sha256_hex,
process::parse_database_source,
};
#[derive(Clone)]
pub struct MigratorConfig {
pub database: DatabaseSettings,
fingerprint: String,
deprecations: Vec<DeprecationRecord>,
}
impl MigratorConfig {
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
pub fn deprecations(&self) -> &[DeprecationRecord] {
&self.deprecations
}
}
pub fn parse_migrator(source: ConfigSource) -> Result<MigratorConfig, ConfigError> {
let (database, deprecations) = parse_database_source(source.retain_for_migrator())?;
let fingerprint = sha256_hex([
"schema=crank-config-migrator-v1".to_owned(),
format!("url={}", database.url.is_some()),
format!("host={}", database.host.to_ascii_lowercase()),
format!("port={}", database.port),
format!("database={}", database.database),
format!("username={}", database.username),
format!("password={}", database.password.is_configured()),
format!(
"pool={}:{}:{}:{}:{}",
database.pool.max_connections,
database.pool.min_connections,
database.pool.acquire_timeout_ms,
database.pool.idle_timeout_ms,
database.pool.max_lifetime_ms
),
]);
Ok(MigratorConfig {
database,
fingerprint,
deprecations,
})
}
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
use serde::Serialize;
use crate::{FieldMode, FieldSpec, Sensitivity, deployment_field_registry, field_registry};
pub const BEGIN_MARKER: &str = "# BEGIN GENERATED CRANK RUNTIME CONFIG";
pub const END_MARKER: &str = "# END GENERATED CRANK RUNTIME CONFIG";
pub const DOC_BEGIN_MARKER: &str = "<!-- BEGIN GENERATED CRANK RUNTIME CONFIG -->";
pub const DOC_END_MARKER: &str = "<!-- END GENERATED CRANK RUNTIME CONFIG -->";
#[derive(Serialize)]
struct RuntimeContract<'a> {
schema_version: u32,
generated_by: &'static str,
fields: &'a [FieldSpec],
deployment_only_fields: &'static [&'static str],
}
pub fn schema_json() -> String {
let contract = RuntimeContract {
schema_version: 1,
generated_by: "crank-config",
fields: field_registry(),
deployment_only_fields: deployment_field_registry(),
};
let mut rendered = serde_json::to_string_pretty(&contract).expect("static contract serializes");
rendered.push('\n');
rendered
}
pub fn env_section(production: bool) -> String {
let mut output = String::new();
output.push_str(BEGIN_MARKER);
output.push('\n');
for field in field_registry()
.iter()
.filter(|field| field.mode == FieldMode::Effective)
{
let value = example_value(field, production);
output.push_str(field.env_name);
output.push('=');
output.push_str(&value);
output.push('\n');
}
output.push_str(END_MARKER);
output.push('\n');
output
}
pub fn reference_section() -> String {
let mut output = String::new();
output.push_str(DOC_BEGIN_MARKER);
output.push_str("\n\n| Environment | Semantic path | Process | Type/unit | Default | Bounds | Sensitivity | Mode |\n");
output.push_str("|---|---|---|---|---|---|---|---|\n");
for field in field_registry() {
let unit = field.unit.unwrap_or("-");
let default = match (field.sensitivity, field.default, field.required) {
(Sensitivity::Secret, Some(_), _) => "configured",
(Sensitivity::Secret, None, true) => "required/blank",
(Sensitivity::Secret, None, false) => "blank",
(_, Some(default), _) => default,
(_, None, true) => "required/blank",
(_, None, false) => "blank",
};
let bounds = match (field.minimum, field.maximum) {
(Some(minimum), Some(maximum)) => format!("{minimum}..={maximum}"),
_ => "-".to_owned(),
};
output.push_str(&format!(
"| `{}` | `{}` | `{:?}` | `{}/{}` | `{}` | `{}` | `{:?}` | `{:?}` |\n",
field.env_name,
field.semantic_path,
field.process,
field.value_type,
unit,
default,
bounds,
field.sensitivity,
field.mode,
));
}
output.push('\n');
output.push_str(DOC_END_MARKER);
output.push('\n');
output
}
pub fn replace_marked(content: &str, begin: &str, end: &str, replacement: &str) -> Option<String> {
let start = content.find(begin)?;
let tail = &content[start..];
let end_offset = tail.find(end)? + end.len();
let suffix_start = start + end_offset;
let mut rendered = String::with_capacity(content.len() + replacement.len());
rendered.push_str(&content[..start]);
rendered.push_str(replacement.trim_end());
rendered.push_str(&content[suffix_start..]);
Some(rendered)
}
fn example_value(field: &FieldSpec, production: bool) -> String {
if field.sensitivity == Sensitivity::Secret {
return String::new();
}
match (field.env_name, production) {
("CRANK_ENVIRONMENT", true) => "production".to_owned(),
("CRANK_BASE_URL", _) => "http://localhost:3000".to_owned(),
("POSTGRES_HOST", true) => "postgres".to_owned(),
("CRANK_TRUST_FORWARDED_HEADERS", true) => "true".to_owned(),
_ => field.default.unwrap_or("").to_owned(),
}
}
+797
View File
@@ -0,0 +1,797 @@
use serde::Serialize;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcessScope {
Shared,
AdminApi,
McpServer,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Sensitivity {
Public,
Internal,
Secret,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldMode {
Effective,
DeprecatedNoEffect,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct FieldSpec {
pub semantic_path: &'static str,
pub env_name: &'static str,
pub process: ProcessScope,
pub value_type: &'static str,
pub unit: Option<&'static str>,
pub default: Option<&'static str>,
pub required: bool,
pub minimum: Option<u64>,
pub maximum: Option<u64>,
pub sensitivity: Sensitivity,
pub mode: FieldMode,
pub compatibility: Option<&'static str>,
pub rules: &'static [&'static str],
}
impl FieldSpec {
pub fn default_for(self, process: ProcessScope) -> Option<&'static str> {
match (self.env_name, process) {
("CRANK_BASE_URL", ProcessScope::AdminApi) => Some("http://localhost:3000"),
("CRANK_LOG_LEVEL", ProcessScope::AdminApi) => Some("admin_api=info,tower_http=info"),
("CRANK_LOG_LEVEL", ProcessScope::McpServer) => Some("mcp_server=info,tower_http=info"),
_ => self.default,
}
}
}
pub(crate) fn semantic_path_for(env_name: &str) -> &str {
field_registry()
.iter()
.find(|field| field.env_name == env_name)
.map_or(env_name, |field| field.semantic_path)
}
macro_rules! f {
($path:literal,$name:literal,$proc:ident,$type:literal,$unit:expr,$default:expr,$min:expr,$max:expr,$sensitivity:ident) => {
FieldSpec {
semantic_path: $path,
env_name: $name,
process: ProcessScope::$proc,
value_type: $type,
unit: $unit,
default: $default,
required: false,
minimum: $min,
maximum: $max,
sensitivity: Sensitivity::$sensitivity,
mode: FieldMode::Effective,
compatibility: None,
rules: &[],
}
};
}
static FIELDS: [FieldSpec; 57] = [
FieldSpec {
compatibility: Some("legacy URL form"),
rules: &[
"takes precedence over generated default-valued POSTGRES_HOST/PORT/DB/USER/PASSWORD",
"conflicts with any non-default decomposed database value",
],
..f!(
"database.url",
"CRANK_DATABASE_URL",
Shared,
"url",
None,
None,
None,
None,
Secret
)
},
f!(
"database.host",
"POSTGRES_HOST",
Shared,
"string",
None,
Some("postgres"),
None,
None,
Internal
),
f!(
"database.port",
"POSTGRES_PORT",
Shared,
"u16",
Some("port"),
Some("5432"),
Some(1),
Some(65535),
Public
),
f!(
"database.name",
"POSTGRES_DB",
Shared,
"string",
None,
Some("crank"),
None,
None,
Internal
),
f!(
"database.user",
"POSTGRES_USER",
Shared,
"string",
None,
Some("crank"),
None,
None,
Internal
),
f!(
"database.password",
"POSTGRES_PASSWORD",
Shared,
"secret",
None,
Some("configured"),
None,
None,
Secret
),
FieldSpec {
rules: &["must be >= min_connections"],
..f!(
"database.pool.max_connections",
"POSTGRES_MAX_CONNECTIONS",
Shared,
"u32",
Some("connections"),
Some("20"),
Some(1),
Some(1024),
Public
)
},
FieldSpec {
rules: &["must be <= max_connections"],
..f!(
"database.pool.min_connections",
"POSTGRES_MIN_CONNECTIONS",
Shared,
"u32",
Some("connections"),
Some("2"),
Some(0),
Some(1024),
Public
)
},
f!(
"database.pool.acquire_timeout_ms",
"POSTGRES_ACQUIRE_TIMEOUT_MS",
Shared,
"u64",
Some("milliseconds"),
Some("5000"),
Some(1),
Some(300000),
Public
),
f!(
"database.pool.idle_timeout_ms",
"POSTGRES_IDLE_TIMEOUT_MS",
Shared,
"u64",
Some("milliseconds"),
Some("600000"),
Some(1000),
Some(86400000),
Public
),
f!(
"database.pool.max_lifetime_ms",
"POSTGRES_MAX_LIFETIME_MS",
Shared,
"u64",
Some("milliseconds"),
Some("1800000"),
Some(1000),
Some(86400000),
Public
),
FieldSpec {
required: true,
..f!(
"runtime.master_key",
"CRANK_MASTER_KEY",
Shared,
"secret",
None,
None,
None,
None,
Secret
)
},
f!(
"runtime.base_url",
"CRANK_BASE_URL",
Shared,
"url",
None,
None,
None,
None,
Internal
),
f!(
"runtime.max_concurrent_unary",
"CRANK_RUNTIME_MAX_CONCURRENT_UNARY",
Shared,
"u32",
Some("requests"),
Some("64"),
Some(1),
Some(65535),
Public
),
FieldSpec {
compatibility: Some("redis value is deprecated in favor of valkey"),
rules: &["external backend requires cache.url"],
..f!(
"cache.backend",
"CRANK_CACHE_BACKEND",
Shared,
"enum",
None,
Some("memory"),
None,
None,
Public
)
},
FieldSpec {
rules: &["forbidden with memory backend"],
..f!(
"cache.url",
"CRANK_CACHE_URL",
Shared,
"url",
None,
None,
None,
None,
Secret
)
},
FieldSpec {
mode: FieldMode::DeprecatedNoEffect,
..f!(
"cache.default_ttl_ms",
"CRANK_CACHE_DEFAULT_TTL_MS",
Shared,
"u64",
Some("milliseconds"),
None,
Some(1),
Some(86400000),
Public
)
},
f!(
"outbound.allowed_hosts",
"CRANK_OUTBOUND_ALLOWED_HOSTS",
Shared,
"host_list",
None,
Some(""),
None,
None,
Internal
),
FieldSpec {
rules: &["deny entries override allow entries"],
..f!(
"outbound.denied_hosts",
"CRANK_OUTBOUND_DENIED_HOSTS",
Shared,
"host_list",
None,
Some(""),
None,
None,
Internal
)
},
f!(
"outbound.max_response_bytes",
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES",
Shared,
"u64",
Some("bytes"),
Some("4194304"),
Some(1),
Some(67108864),
Public
),
f!(
"observability.environment",
"CRANK_ENVIRONMENT",
Shared,
"label",
None,
Some("development"),
None,
None,
Public
),
f!(
"observability.log_filter",
"CRANK_LOG_LEVEL",
Shared,
"string",
None,
None,
None,
None,
Public
),
f!(
"observability.sentry_dsn",
"CRANK_SENTRY_DSN",
Shared,
"url",
None,
None,
None,
None,
Secret
),
FieldSpec {
compatibility: Some("yes/no/on/off spellings are deprecated"),
..f!(
"observability.metrics.enabled",
"CRANK_METRICS_ENABLED",
Shared,
"bool",
None,
Some("true"),
None,
None,
Public
)
},
FieldSpec {
rules: &["required when an enabled metrics bind is non-loopback"],
..f!(
"observability.metrics.bearer_token",
"CRANK_METRICS_BEARER_TOKEN",
Shared,
"secret",
None,
None,
None,
None,
Secret
)
},
f!(
"observability.otlp.endpoint",
"OTEL_EXPORTER_OTLP_ENDPOINT",
Shared,
"url",
None,
None,
None,
None,
Internal
),
FieldSpec {
rules: &["overrides generic OTLP endpoint"],
..f!(
"observability.otlp.traces_endpoint",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
Shared,
"url",
None,
None,
None,
None,
Internal
)
},
f!(
"observability.otlp.protocol",
"OTEL_EXPORTER_OTLP_PROTOCOL",
Shared,
"enum",
None,
Some("http/protobuf"),
None,
None,
Public
),
f!(
"observability.otlp.traces_protocol",
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
Shared,
"enum",
None,
None,
None,
None,
Public
),
f!(
"observability.otlp.timeout",
"OTEL_EXPORTER_OTLP_TIMEOUT",
Shared,
"duration",
Some("milliseconds"),
Some("10000"),
Some(1),
Some(300000),
Public
),
f!(
"observability.otlp.traces_timeout",
"OTEL_EXPORTER_OTLP_TRACES_TIMEOUT",
Shared,
"duration",
Some("milliseconds"),
None,
Some(1),
Some(300000),
Public
),
f!(
"observability.otlp.headers",
"OTEL_EXPORTER_OTLP_HEADERS",
Shared,
"headers",
None,
None,
None,
None,
Secret
),
f!(
"observability.otlp.traces_headers",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
Shared,
"headers",
None,
None,
None,
None,
Secret
),
f!(
"observability.otlp.max_queue_size",
"OTEL_BSP_MAX_QUEUE_SIZE",
Shared,
"u32",
Some("spans"),
Some("2048"),
Some(1),
Some(65536),
Public
),
FieldSpec {
rules: &["must be <= max_queue_size"],
..f!(
"observability.otlp.max_export_batch_size",
"OTEL_BSP_MAX_EXPORT_BATCH_SIZE",
Shared,
"u32",
Some("spans"),
Some("512"),
Some(1),
Some(65536),
Public
)
},
f!(
"observability.otlp.schedule_delay",
"OTEL_BSP_SCHEDULE_DELAY",
Shared,
"duration",
Some("milliseconds"),
Some("5000"),
Some(1),
Some(300000),
Public
),
f!(
"observability.otlp.export_timeout",
"OTEL_BSP_EXPORT_TIMEOUT",
Shared,
"duration",
Some("milliseconds"),
Some("30000"),
Some(1),
Some(300000),
Public
),
f!(
"admin.bind",
"CRANK_ADMIN_BIND",
AdminApi,
"socket",
None,
Some("0.0.0.0:3001"),
None,
None,
Internal
),
f!(
"admin.metrics_bind",
"CRANK_ADMIN_METRICS_BIND",
AdminApi,
"socket",
None,
Some("127.0.0.1:9464"),
None,
None,
Internal
),
f!(
"admin.storage_root",
"CRANK_STORAGE_ROOT",
AdminApi,
"absolute_path",
None,
Some("/var/lib/crank/storage"),
None,
None,
Internal
),
f!(
"admin.rate_limit.rps",
"CRANK_ADMIN_RATE_LIMIT_RPS",
AdminApi,
"u32",
Some("requests_per_second"),
Some("30"),
Some(1),
Some(100000),
Public
),
FieldSpec {
rules: &["must be >= admin rate RPS"],
..f!(
"admin.rate_limit.burst",
"CRANK_ADMIN_RATE_LIMIT_BURST",
AdminApi,
"u32",
Some("requests"),
Some("60"),
Some(1),
Some(1000000),
Public
)
},
f!(
"admin.invocation_log_retention_days",
"CRANK_INVOCATION_LOG_RETENTION_DAYS",
AdminApi,
"u32",
Some("days"),
Some("30"),
Some(1),
Some(36500),
Public
),
FieldSpec {
required: true,
..f!(
"admin.session.secret",
"CRANK_SESSION_SECRET",
AdminApi,
"secret",
None,
None,
None,
None,
Secret
)
},
FieldSpec {
required: true,
..f!(
"admin.password_pepper",
"CRANK_PASSWORD_PEPPER",
AdminApi,
"secret",
None,
None,
None,
None,
Secret
)
},
f!(
"admin.session.ttl_hours",
"CRANK_SESSION_TTL_HOURS",
AdminApi,
"u32",
Some("hours"),
Some("24"),
Some(1),
Some(8760),
Public
),
FieldSpec {
compatibility: Some("yes/no/on/off spellings are deprecated"),
..f!(
"admin.trust_forwarded_headers",
"CRANK_TRUST_FORWARDED_HEADERS",
AdminApi,
"bool",
None,
Some("false"),
None,
None,
Public
)
},
FieldSpec {
required: true,
..f!(
"admin.bootstrap.email",
"CRANK_BOOTSTRAP_ADMIN_EMAIL",
AdminApi,
"string",
None,
None,
None,
None,
Internal
)
},
FieldSpec {
required: true,
..f!(
"admin.bootstrap.password",
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
AdminApi,
"secret",
None,
None,
None,
None,
Secret
)
},
f!(
"admin.bootstrap.display_name",
"CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME",
AdminApi,
"string",
None,
Some("Crank Owner"),
None,
None,
Internal
),
FieldSpec {
compatibility: Some("yes/no/on/off spellings are deprecated"),
..f!(
"admin.demo_seed",
"CRANK_DEMO_SEED",
AdminApi,
"bool",
None,
Some("false"),
None,
None,
Public
)
},
f!(
"mcp.bind",
"CRANK_MCP_BIND",
McpServer,
"socket",
None,
Some("0.0.0.0:3002"),
None,
None,
Internal
),
f!(
"mcp.metrics_bind",
"CRANK_MCP_METRICS_BIND",
McpServer,
"socket",
None,
Some("127.0.0.1:9465"),
None,
None,
Internal
),
f!(
"mcp.refresh_ms",
"CRANK_MCP_REFRESH_MS",
McpServer,
"u64",
Some("milliseconds"),
Some("5000"),
Some(100),
Some(3600000),
Public
),
f!(
"mcp.rate_limit.rps",
"CRANK_MCP_RATE_LIMIT_RPS",
McpServer,
"u32",
Some("requests_per_second"),
Some("60"),
Some(1),
Some(100000),
Public
),
FieldSpec {
rules: &["must be >= MCP rate RPS"],
..f!(
"mcp.rate_limit.burst",
"CRANK_MCP_RATE_LIMIT_BURST",
McpServer,
"u32",
Some("requests"),
Some("120"),
Some(1),
Some(1000000),
Public
)
},
f!(
"runtime.max_concurrent_sessions",
"CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS",
McpServer,
"u32",
Some("sessions"),
Some("16"),
Some(1),
Some(65535),
Public
),
];
pub fn field_registry() -> &'static [FieldSpec] {
&FIELDS
}
static DEPLOYMENT_FIELDS: [&str; 12] = [
"COMPOSE_PROJECT_NAME",
"POSTGRES_PUBLISH_BIND",
"POSTGRES_PUBLISH_PORT",
"CRANK_ADMIN_API_IMAGE",
"CRANK_MCP_SERVER_IMAGE",
"CRANK_UI_IMAGE",
"CRANK_PUBLISH_BIND",
"CRANK_ADMIN_PUBLISH_PORT",
"CRANK_MCP_PUBLISH_PORT",
"CRANK_UI_PUBLISH_PORT",
"VALKEY_PUBLISH_BIND",
"VALKEY_PUBLISH_PORT",
];
pub fn deployment_field_registry() -> &'static [&'static str] {
&DEPLOYMENT_FIELDS
}
+85
View File
@@ -0,0 +1,85 @@
use std::{collections::BTreeMap, ffi::OsString};
use crate::{ConfigError, Diagnostic, DiagnosticCode, deployment_field_registry, field_registry};
#[derive(Clone, Debug, Default)]
pub struct ConfigSource {
values: BTreeMap<String, String>,
}
impl ConfigSource {
pub fn from_utf8(values: BTreeMap<String, String>) -> Self {
Self { values }
}
pub fn from_os() -> Result<Self, ConfigError> {
Self::from_os_iter(std::env::vars_os())
}
pub fn from_os_for_migrator() -> Result<Self, ConfigError> {
Self::from_os_iter_filtered(std::env::vars_os(), |name| {
name.starts_with("POSTGRES_") || name.starts_with("CRANK_DATABASE_")
})
}
pub fn from_os_iter<I>(values: I) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = (OsString, OsString)>,
{
Self::from_os_iter_filtered(values, |_| true)
}
fn from_os_iter_filtered<I, F>(values: I, include: F) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = (OsString, OsString)>,
F: Fn(&str) -> bool,
{
let mut parsed = BTreeMap::new();
let mut diagnostics = Vec::new();
for (name, value) in values {
let Ok(name) = name.into_string() else {
// Owned names are ASCII. A non-UTF-8 name therefore cannot belong
// to Crank and must not make startup depend on unrelated OS state.
continue;
};
if !include(&name) {
continue;
}
let owned = name.starts_with("CRANK_")
|| name.starts_with("POSTGRES_")
|| name.starts_with("OTEL_");
let known = field_registry().iter().any(|field| field.env_name == name)
|| deployment_field_registry().contains(&name.as_str());
if !owned && !known {
continue;
}
let value = match value.into_string() {
Ok(value) => value,
Err(_) => {
let field = field_registry()
.iter()
.find(|field| field.env_name == name)
.map_or("environment.unknown", |field| field.semantic_path);
diagnostics.push(Diagnostic::new(DiagnosticCode::InvalidEncoding, field));
continue;
}
};
parsed.insert(name, value);
}
if diagnostics.is_empty() {
Ok(Self { values: parsed })
} else {
Err(ConfigError::from_diagnostics(diagnostics))
}
}
pub(crate) fn values(&self) -> &BTreeMap<String, String> {
&self.values
}
pub(crate) fn retain_for_migrator(mut self) -> Self {
self.values
.retain(|name, _| name.starts_with("POSTGRES_") || name.starts_with("CRANK_DATABASE_"));
self
}
}
+42
View File
@@ -0,0 +1,42 @@
pub(crate) fn valid_percent_encoding(value: &str) -> bool {
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
if index + 2 >= bytes.len()
|| !bytes[index + 1].is_ascii_hexdigit()
|| !bytes[index + 2].is_ascii_hexdigit()
{
return false;
}
index += 3;
} else {
index += 1;
}
}
true
}
pub(crate) fn valid_database_host(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 253
&& !value.chars().any(char::is_whitespace)
&& (value.parse::<std::net::IpAddr>().is_ok()
|| value.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
&& !label.starts_with('-')
&& !label.ends_with('-')
}))
}
pub(crate) fn valid_database_identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
}
+42
View File
@@ -0,0 +1,42 @@
use std::fmt;
#[derive(Clone, Eq, PartialEq)]
pub struct SecretString(String);
impl SecretString {
pub(crate) fn new(value: String) -> Self {
Self(value)
}
/// Deliberate composition boundary. Never use in diagnostics or fingerprints.
pub fn expose_secret(&self) -> &str {
&self.0
}
pub fn is_configured(&self) -> bool {
!self.0.is_empty()
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("SecretString")
.field(&if self.is_configured() {
"configured"
} else {
"unconfigured"
})
.finish()
}
}
impl fmt::Display for SecretString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(if self.is_configured() {
"configured"
} else {
"unconfigured"
})
}
}
+558
View File
@@ -0,0 +1,558 @@
use std::collections::BTreeMap;
use crank_config::{
ConfigSource, DiagnosticCode, FieldMode, ProcessKind, ProcessScope, field_registry,
parse_migrator, parse_process,
};
fn required_admin() -> BTreeMap<String, String> {
[
("CRANK_MASTER_KEY", "master"),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect()
}
fn required_mcp() -> BTreeMap<String, String> {
[("CRANK_MASTER_KEY", "master")]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect()
}
#[test]
fn migrator_projection_requires_only_database_configuration() {
let config = parse_migrator(ConfigSource::from_utf8(BTreeMap::new()))
.expect("database defaults are sufficient for the controlled migration job");
assert_eq!(config.database.host, "postgres");
assert_eq!(config.database.port, 5432);
assert_eq!(config.fingerprint().len(), 64);
let debug = format!("{config:?}");
assert!(
!debug.contains("crank"),
"database password must remain redacted"
);
}
#[test]
fn migrator_ignores_service_configuration_and_rejects_database_typos() {
let mut values = BTreeMap::from([
("CRANK_MASTER_KEY".to_owned(), "secret-canary".to_owned()),
(
"CRANK_SESSION_SECRET".to_owned(),
"secret-canary".to_owned(),
),
("CRANK_MCP_REFRESH_MS".to_owned(), "invalid".to_owned()),
]);
parse_migrator(ConfigSource::from_utf8(values.clone()))
.expect("service fields are outside the database-only projection");
values.insert("POSTGRES_PORRT".to_owned(), "5432".to_owned());
let error = parse_migrator(ConfigSource::from_utf8(values)).unwrap_err();
assert!(
error
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.code == DiagnosticCode::UnknownField)
);
assert!(!error.to_string().contains("secret-canary"));
}
fn source_for(
field: &crank_config::FieldSpec,
value: String,
) -> (ProcessKind, BTreeMap<String, String>) {
let kind = match field.process {
ProcessScope::McpServer => ProcessKind::McpServer,
ProcessScope::Shared | ProcessScope::AdminApi => ProcessKind::AdminApi,
};
let mut vars = match kind {
ProcessKind::AdminApi => required_admin(),
ProcessKind::McpServer => required_mcp(),
};
vars.insert(field.env_name.to_owned(), value);
match field.env_name {
"POSTGRES_MAX_CONNECTIONS" => {
vars.insert("POSTGRES_MIN_CONNECTIONS".into(), "0".into());
}
"POSTGRES_MIN_CONNECTIONS" => {
vars.insert("POSTGRES_MAX_CONNECTIONS".into(), "1024".into());
}
"CRANK_ADMIN_RATE_LIMIT_RPS" => {
vars.insert("CRANK_ADMIN_RATE_LIMIT_BURST".into(), "1000000".into());
}
"CRANK_ADMIN_RATE_LIMIT_BURST" => {
vars.insert("CRANK_ADMIN_RATE_LIMIT_RPS".into(), "1".into());
}
"CRANK_MCP_RATE_LIMIT_RPS" => {
vars.insert("CRANK_MCP_RATE_LIMIT_BURST".into(), "1000000".into());
}
"CRANK_MCP_RATE_LIMIT_BURST" => {
vars.insert("CRANK_MCP_RATE_LIMIT_RPS".into(), "1".into());
}
"OTEL_BSP_MAX_QUEUE_SIZE" => {
vars.insert("OTEL_BSP_MAX_EXPORT_BATCH_SIZE".into(), "1".into());
}
"OTEL_BSP_MAX_EXPORT_BATCH_SIZE" => {
vars.insert("OTEL_BSP_MAX_QUEUE_SIZE".into(), "65536".into());
}
_ => {}
}
(kind, vars)
}
#[test]
fn registry_covers_exactly_the_57_observed_runtime_names() {
let registry = field_registry();
assert_eq!(registry.len(), 57);
let unique = registry
.iter()
.map(|field| field.env_name)
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(unique.len(), registry.len());
assert!(!unique.contains("CRANK_RUNTIME_MAX_CONCURRENT_WINDOW"));
assert!(!unique.contains("CRANK_RUNTIME_MAX_CONCURRENT_JOBS"));
for field in registry {
assert!(!field.semantic_path.is_empty());
assert!(!field.env_name.is_empty());
assert!(!field.value_type.is_empty());
if let (Some(minimum), Some(maximum)) = (field.minimum, field.maximum) {
assert!(minimum <= maximum, "{}", field.env_name);
}
}
assert_eq!(
registry
.iter()
.find(|field| field.env_name == "CRANK_CACHE_DEFAULT_TTL_MS")
.unwrap()
.mode,
FieldMode::DeprecatedNoEffect
);
}
#[test]
fn defaults_are_preserved_and_invalid_values_never_fall_back() {
let valid = parse_process(
ProcessKind::AdminApi,
ConfigSource::from_utf8(required_admin()),
)
.expect("minimal admin config");
let admin = valid.admin().expect("admin projection");
assert_eq!(admin.database.port, 5432);
assert_eq!(admin.session_ttl_hours, 24);
assert_eq!(admin.rate_limit.requests_per_second, 30);
for (name, value) in [
("POSTGRES_PORT", "bad"),
("CRANK_SESSION_TTL_HOURS", "bad"),
("CRANK_ADMIN_RATE_LIMIT_RPS", "bad"),
("CRANK_TRUST_FORWARDED_HEADERS", "tru"),
] {
let mut vars = required_admin();
vars.insert(name.to_owned(), value.to_owned());
let error =
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
let path = field_registry()
.iter()
.find(|field| field.env_name == name)
.unwrap()
.semantic_path;
assert!(error.diagnostics().iter().any(|item| item.field == path));
}
}
#[test]
fn database_forms_conflict_and_owned_typos_fail_closed() {
let mut vars = required_admin();
vars.insert(
"CRANK_DATABASE_URL".into(),
"postgres://user:secret@db/crank".into(),
);
vars.insert("POSTGRES_HOST".into(), "db".into());
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
assert!(
error
.diagnostics()
.iter()
.any(|item| item.code == DiagnosticCode::Conflict)
);
let mut vars = required_admin();
vars.insert("CRANK_SESION_SECRET".into(), "canary".into());
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
assert!(
error
.diagnostics()
.iter()
.any(|item| item.code == DiagnosticCode::UnknownField)
);
assert!(!error.to_string().contains("canary"));
for ghost in [
"CRANK_RUNTIME_MAX_CONCURRENT_WINDOW",
"CRANK_RUNTIME_MAX_CONCURRENT_JOBS",
] {
let mut vars = required_admin();
vars.insert(ghost.into(), "4".into());
let error =
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
assert!(error.diagnostics().iter().any(|item| {
item.code == DiagnosticCode::UnknownField && item.field == "environment.unknown"
}));
}
}
#[test]
fn exact_deployment_only_names_are_known_but_never_runtime_fields() {
let mut vars = required_admin();
for name in crank_config::deployment_field_registry() {
vars.insert((*name).to_owned(), "deployment-value".to_owned());
}
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
assert!(config.admin().is_some());
assert!(
field_registry()
.iter()
.all(|field| { !crank_config::deployment_field_registry().contains(&field.env_name) })
);
}
#[test]
fn parsed_but_unused_cache_ttl_is_an_explicit_non_pass_contract() {
let mut vars = required_admin();
vars.insert("CRANK_CACHE_DEFAULT_TTL_MS".into(), "5000".into());
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
assert!(error.diagnostics().iter().any(|item| {
item.code == DiagnosticCode::DeprecatedNoEffect && item.field == "cache.default_ttl_ms"
}));
}
#[cfg(unix)]
#[test]
fn os_source_rejects_non_utf8_without_echoing_bytes() {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
let error = ConfigSource::from_os_iter([(
OsString::from("CRANK_MASTER_KEY"),
OsString::from_vec(vec![0xff, b'S', b'E', b'C', b'R', b'E', b'T']),
)])
.unwrap_err();
assert!(
error
.diagnostics()
.iter()
.any(|item| item.code == DiagnosticCode::InvalidEncoding)
);
assert!(!error.to_string().contains("SECRET"));
}
#[cfg(unix)]
#[test]
fn os_source_ignores_unrelated_invalid_or_unbounded_values() {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
ConfigSource::from_os_iter([
(
OsString::from_vec(vec![0xff]),
OsString::from_vec(vec![0xff]),
),
(
OsString::from("JAVA_TOOL_OPTIONS"),
OsString::from("x".repeat(20_000)),
),
(OsString::from("LANG"), OsString::from_vec(vec![0xff, b'x'])),
])
.expect("unrelated OS state is outside the runtime contract");
}
#[test]
fn process_specific_fields_and_zero_ports_fail_closed() {
let mut mcp = required_mcp();
mcp.insert("CRANK_SESSION_SECRET".into(), "wrong-process".into());
let error = parse_process(ProcessKind::McpServer, ConfigSource::from_utf8(mcp)).unwrap_err();
assert!(error.diagnostics().iter().any(|item| {
item.code == DiagnosticCode::UnknownField && item.field == "admin.session.secret"
}));
for (kind, name, required) in [
(ProcessKind::AdminApi, "CRANK_ADMIN_BIND", required_admin()),
(
ProcessKind::AdminApi,
"CRANK_ADMIN_METRICS_BIND",
required_admin(),
),
(ProcessKind::McpServer, "CRANK_MCP_BIND", required_mcp()),
(
ProcessKind::McpServer,
"CRANK_MCP_METRICS_BIND",
required_mcp(),
),
] {
let mut vars = required;
vars.insert(name.into(), "127.0.0.1:0".into());
let error = parse_process(kind, ConfigSource::from_utf8(vars)).unwrap_err();
let path = field_registry()
.iter()
.find(|field| field.env_name == name)
.unwrap()
.semantic_path;
assert!(
error
.diagnostics()
.iter()
.any(|item| { item.code == DiagnosticCode::OutOfRange && item.field == path })
);
}
}
#[test]
fn whitespace_secrets_and_consumer_invalid_values_fail_in_the_leaf_parser() {
for name in [
"CRANK_MASTER_KEY",
"CRANK_SESSION_SECRET",
"CRANK_PASSWORD_PEPPER",
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
] {
let mut vars = required_admin();
vars.insert(name.into(), " ".into());
assert!(
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).is_err(),
"{name}"
);
}
for (name, value) in [
("CRANK_OUTBOUND_ALLOWED_HOSTS", "example.test:443"),
("CRANK_DATABASE_URL", "postgres://db/crank?sslmode=bogus"),
("CRANK_ENVIRONMENT", "bad environment"),
("CRANK_SENTRY_DSN", "not-a-dsn"),
("OTEL_EXPORTER_OTLP_HEADERS", "bad name=value"),
(
"CRANK_BASE_URL",
"https://user:secret@example.test/path?token=x",
),
] {
let mut vars = required_admin();
vars.insert(name.into(), value.into());
let error =
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).expect_err(name);
assert!(
error
.diagnostics()
.iter()
.any(|item| item.code == DiagnosticCode::InvalidType),
"{name}: {error}"
);
}
}
#[test]
fn cross_field_and_typed_boundaries_fail_closed() {
let cases = [
("POSTGRES_MAX_CONNECTIONS", "1025"),
("CRANK_ADMIN_RATE_LIMIT_RPS", "100001"),
("CRANK_ADMIN_RATE_LIMIT_BURST", "0"),
("CRANK_OUTBOUND_MAX_RESPONSE_BYTES", "67108865"),
("OTEL_BSP_SCHEDULE_DELAY", "bad"),
("OTEL_BSP_EXPORT_TIMEOUT", "300001"),
("CRANK_BASE_URL", "file:///tmp/config"),
];
for (name, value) in cases {
let mut vars = required_admin();
vars.insert(name.to_owned(), value.to_owned());
let error =
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).expect_err(name);
assert!(
error.diagnostics().iter().any(|item| item.field
== field_registry()
.iter()
.find(|field| field.env_name == name)
.unwrap()
.semantic_path),
"{name}: {error}"
);
}
let mut vars = required_admin();
vars.insert("POSTGRES_MIN_CONNECTIONS".into(), "21".into());
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
assert!(
error
.diagnostics()
.iter()
.any(|item| item.code == DiagnosticCode::UnsafeCombination)
);
let mut vars = required_admin();
vars.insert("CRANK_CACHE_BACKEND".into(), "valkey".into());
vars.insert("CRANK_CACHE_URL".into(), "https://not-a-cache.test".into());
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
assert!(
error
.diagnostics()
.iter()
.any(|item| item.field == "cache.url")
);
}
#[test]
fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
let mut vars = required_admin();
vars.extend([
("POSTGRES_PORT".into(), "65535".into()),
("POSTGRES_MAX_CONNECTIONS".into(), "1024".into()),
("POSTGRES_MIN_CONNECTIONS".into(), "0".into()),
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "1".into()),
(
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES".into(),
"67108864".into(),
),
("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into()),
("CRANK_DEMO_SEED".into(), "off".into()),
]);
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
let admin = config.admin().unwrap();
assert_eq!(admin.database.port, 65_535);
assert_eq!(admin.database.pool.max_connections, 1024);
assert_eq!(admin.database.pool.min_connections, 0);
assert_eq!(admin.runtime.max_concurrent_unary, 1);
assert!(admin.trust_forwarded_headers);
assert!(!admin.demo_seed);
}
#[test]
fn admin_and_mcp_share_one_normalized_foundation() {
let mut vars = required_admin();
vars.extend([
("CRANK_BASE_URL".into(), "https://crank.example.test".into()),
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "72".into()),
(
"CRANK_OUTBOUND_ALLOWED_HOSTS".into(),
"api.example.test".into(),
),
("POSTGRES_MAX_CONNECTIONS".into(), "24".into()),
]);
let admin =
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars.clone())).unwrap();
let mut mcp_vars = vars;
for key in [
"CRANK_SESSION_SECRET",
"CRANK_PASSWORD_PEPPER",
"CRANK_BOOTSTRAP_ADMIN_EMAIL",
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
] {
mcp_vars.remove(key);
}
let mcp = parse_process(ProcessKind::McpServer, ConfigSource::from_utf8(mcp_vars)).unwrap();
let admin = admin.admin().unwrap();
let mcp = mcp.mcp().unwrap();
assert_eq!(admin.database.host, mcp.database.host);
assert_eq!(
admin.database.pool.max_connections,
mcp.database.pool.max_connections
);
assert_eq!(admin.runtime.base_url, mcp.runtime.base_url);
assert_eq!(
admin.runtime.max_concurrent_unary,
mcp.runtime.max_concurrent_unary
);
assert_eq!(
admin.runtime.outbound.allowed_hosts,
mcp.runtime.outbound.allowed_hosts
);
}
#[test]
fn every_bounded_numeric_field_accepts_edges_and_rejects_outside_values() {
for field in field_registry().iter().filter(|field| {
field.mode == FieldMode::Effective && field.minimum.is_some() && field.maximum.is_some()
}) {
let minimum = field.minimum.unwrap();
let maximum = field.maximum.unwrap();
for accepted in [minimum, maximum] {
let (kind, vars) = source_for(field, accepted.to_string());
parse_process(kind, ConfigSource::from_utf8(vars))
.unwrap_or_else(|error| panic!("{}={accepted}: {error}", field.env_name));
}
for rejected in [
if minimum == 0 {
"-1".to_owned()
} else {
(minimum - 1).to_string()
},
(maximum + 1).to_string(),
] {
let (kind, vars) = source_for(field, rejected);
let error =
parse_process(kind, ConfigSource::from_utf8(vars)).expect_err(field.env_name);
assert!(
error
.diagnostics()
.iter()
.any(|item| item.field == field.semantic_path),
"{}: {error}",
field.env_name
);
}
for malformed in ["-1", "184467440737095516160", "1.5", " 1", "1\n"] {
let (kind, vars) = source_for(field, malformed.to_owned());
let error =
parse_process(kind, ConfigSource::from_utf8(vars)).expect_err(field.env_name);
assert!(
error
.diagnostics()
.iter()
.any(|item| item.field == field.semantic_path),
"{}={malformed:?}: {error}",
field.env_name
);
}
}
}
#[test]
fn compatibility_values_and_otel_precedence_remain_explicit() {
let mut vars = required_admin();
vars.extend([
("CRANK_CACHE_BACKEND".into(), "redis".into()),
(
"CRANK_CACHE_URL".into(),
"redis://cache.example.test:6379".into(),
),
(
"OTEL_EXPORTER_OTLP_ENDPOINT".into(),
"https://generic.example.test".into(),
),
(
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT".into(),
"https://traces.example.test".into(),
),
("OTEL_EXPORTER_OTLP_TIMEOUT".into(), "10000".into()),
("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT".into(), "5000".into()),
]);
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
let admin = config.admin().unwrap();
assert_eq!(
admin.runtime.cache.backend,
crank_config::CacheBackend::Redis
);
assert_eq!(
admin.observability.otlp.endpoint.as_deref(),
Some("https://generic.example.test")
);
assert_eq!(
admin.observability.otlp.traces_endpoint.as_deref(),
Some("https://traces.example.test")
);
assert_eq!(admin.observability.otlp.timeout.as_deref(), Some("10000"));
assert_eq!(
admin.observability.otlp.traces_timeout.as_deref(),
Some("5000")
);
assert_eq!(config.deprecations().len(), 1);
assert_eq!(config.deprecations()[0].field, "cache.backend");
}
+48
View File
@@ -0,0 +1,48 @@
use crank_config::{field_registry, render};
#[test]
fn generated_contract_is_deterministic_complete_and_redacted() {
assert_eq!(render::schema_json(), render::schema_json());
let schema: serde_json::Value = serde_json::from_str(&render::schema_json()).unwrap();
assert_eq!(
schema["fields"].as_array().unwrap().len(),
field_registry().len()
);
for section in [render::env_section(false), render::env_section(true)] {
assert!(!section.contains("change-me"));
assert!(!section.contains("CRANK_RUNTIME_MAX_CONCURRENT_WINDOW"));
assert!(!section.contains("CRANK_RUNTIME_MAX_CONCURRENT_JOBS"));
assert!(!section.contains("CRANK_CACHE_DEFAULT_TTL_MS"));
}
}
#[test]
fn generated_reference_distinguishes_required_and_optional_fields() {
let reference = render::reference_section();
assert!(reference.contains(
"| `CRANK_MASTER_KEY` | `runtime.master_key` | `Shared` | `secret/-` | `required/blank` |"
));
assert!(
reference
.contains("| `CRANK_DATABASE_URL` | `database.url` | `Shared` | `url/-` | `blank` |")
);
assert!(reference.contains(
"| `CRANK_LOG_LEVEL` | `observability.log_filter` | `Shared` | `string/-` | `blank` |"
));
}
#[test]
fn marker_replacement_is_bounded_to_the_generated_region() {
let input = "before\n# BEGIN GENERATED CRANK RUNTIME CONFIG\nstale\n# END GENERATED CRANK RUNTIME CONFIG\nafter\n";
let output = render::replace_marked(
input,
render::BEGIN_MARKER,
render::END_MARKER,
&render::env_section(false),
)
.unwrap();
assert!(output.starts_with("before\n"));
assert!(output.ends_with("\nafter\n"));
assert!(!output.contains("stale"));
}
+178
View File
@@ -0,0 +1,178 @@
use std::collections::BTreeMap;
use crank_config::{ConfigSource, ProcessKind, parse_process};
fn config(secret: &str) -> crank_config::EffectiveConfig {
let vars = [
("CRANK_MASTER_KEY", secret),
("CRANK_SESSION_SECRET", secret),
("CRANK_PASSWORD_PEPPER", secret),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", secret),
]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect::<BTreeMap<_, _>>();
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap()
}
#[test]
fn secrets_are_absent_from_debug_display_and_fingerprint() {
let first = config("CANARY_ONE");
let second = config("CANARY_TWO");
let rendered = format!("{first:?}");
assert!(!rendered.contains("CANARY_ONE"));
assert_eq!(first.fingerprint(), second.fingerprint());
assert_eq!(first.fingerprint().len(), 64);
assert!(
first
.fingerprint()
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
);
}
#[test]
fn effective_semantics_not_input_spelling_drive_fingerprint() {
let mut canonical = [
("CRANK_MASTER_KEY", "master"),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
("CRANK_TRUST_FORWARDED_HEADERS", "true"),
]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect::<BTreeMap<_, _>>();
let mut compatibility = canonical.clone();
compatibility.insert("CRANK_TRUST_FORWARDED_HEADERS".into(), "yes".into());
let canonical_config = parse_process(
ProcessKind::AdminApi,
ConfigSource::from_utf8(canonical.clone()),
)
.unwrap();
let compatibility_config = parse_process(
ProcessKind::AdminApi,
ConfigSource::from_utf8(compatibility),
)
.unwrap();
assert_eq!(
canonical_config.fingerprint(),
compatibility_config.fingerprint()
);
assert_eq!(compatibility_config.deprecations().len(), 1);
canonical.insert("CRANK_SESSION_TTL_HOURS".into(), "48".into());
let changed = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(canonical)).unwrap();
assert_ne!(changed.fingerprint(), compatibility_config.fingerprint());
}
#[test]
fn diagnostics_are_bounded_json_and_never_echo_secret_canaries() {
let canary = "CANARY_SECRET_VALUE";
let vars = [
("CRANK_MASTER_KEY", canary),
("CRANK_SESSION_SECRET", canary),
("CRANK_PASSWORD_PEPPER", canary),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", canary),
(
"CRANK_DATABASE_URL",
"postgres://owner:CANARY_SECRET_VALUE@db/crank",
),
("POSTGRES_PASSWORD", canary),
("CRANK_CACHE_BACKEND", "memory"),
("CRANK_CACHE_URL", "redis://:CANARY_SECRET_VALUE@cache:6379"),
(
"CRANK_SENTRY_DSN",
"https://CANARY_SECRET_VALUE@sentry.test/1",
),
("CRANK_METRICS_BEARER_TOKEN", canary),
(
"OTEL_EXPORTER_OTLP_HEADERS",
"authorization=CANARY_SECRET_VALUE",
),
]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect::<BTreeMap<_, _>>();
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
let display = error.to_string();
let json = error.to_json();
assert!(json.len() <= 65_536);
assert!(serde_json::from_str::<serde_json::Value>(&json).is_ok());
assert!(!display.contains(canary));
assert!(!json.contains(canary));
assert!(error.diagnostics().len() <= 100);
}
#[test]
fn public_projection_debug_omits_urls_hosts_paths_and_identity_values() {
let mut vars = [
("CRANK_MASTER_KEY", "master"),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@CANARY.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
("CRANK_STORAGE_ROOT", "/CANARY/private/storage"),
("POSTGRES_HOST", "CANARY-db.internal"),
("CRANK_OUTBOUND_ALLOWED_HOSTS", "CANARY-api.internal"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect::<BTreeMap<_, _>>();
vars.insert(
"CRANK_BASE_URL".into(),
"https://CANARY.example.test".into(),
);
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
let rendered = format!("{:?}", config.admin().unwrap());
assert!(!rendered.contains("CANARY"), "{rendered}");
}
#[test]
fn normalized_database_and_admin_default_urls_drive_fingerprint() {
let base = [
("CRANK_MASTER_KEY", "master"),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect::<BTreeMap<_, _>>();
let implicit =
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(base.clone())).unwrap();
let mut explicit = base.clone();
explicit.insert("CRANK_BASE_URL".into(), "http://localhost:3000".into());
let explicit = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(explicit)).unwrap();
assert_eq!(implicit.fingerprint(), explicit.fingerprint());
let mut url = base;
url.insert(
"CRANK_DATABASE_URL".into(),
"postgres://crank:rotated@postgres/crank".into(),
);
let url = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(url)).unwrap();
assert_eq!(implicit.fingerprint(), url.fingerprint());
let tls = [
("CRANK_MASTER_KEY", "master"),
("CRANK_SESSION_SECRET", "session"),
("CRANK_PASSWORD_PEPPER", "pepper"),
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
(
"CRANK_DATABASE_URL",
"postgres://crank:rotated@postgres/crank?sslmode=require",
),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect::<BTreeMap<_, _>>();
let tls = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(tls)).unwrap();
assert_ne!(implicit.fingerprint(), tls.fingerprint());
}