use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LogConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub format: LogFormat,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StoreConfig {
pub data_dir: PathBuf,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FjallTuning {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_block_cache"
)]
pub block_cache: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_write_buffer"
)]
pub write_buffer: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_max_journal"
)]
pub max_journal: Option<u64>,
}
pub const MIN_BLOCK_CACHE_BYTES: u64 = 1024 * 1024;
pub const MIN_WRITE_BUFFER_BYTES: u64 = 1024 * 1024;
pub const MIN_MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
const BYTE_SIZE_SUFFIXES: &[(&str, u64)] = &[
("TiB", 1024 * 1024 * 1024 * 1024),
("GiB", 1024 * 1024 * 1024),
("MiB", 1024 * 1024),
("KiB", 1024),
("TB", 1_000_000_000_000),
("GB", 1_000_000_000),
("MB", 1_000_000),
("KB", 1_000),
("B", 1),
];
pub fn parse_byte_size(raw: &str) -> Result<u64, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("value is empty".to_string());
}
if trimmed.starts_with('-') {
return Err(format!(
"{trimmed:?} is negative; a byte size cannot be negative"
));
}
for (suffix, multiplier) in BYTE_SIZE_SUFFIXES {
if trimmed.len() > suffix.len()
&& trimmed[trimmed.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
{
let number_part = trimmed[..trimmed.len() - suffix.len()].trim();
let value: f64 = number_part.parse().map_err(|_| {
format!(
"{trimmed:?} is not a valid byte size (expected a number before the {suffix:?} suffix)"
)
})?;
if !value.is_finite() || value < 0.0 {
return Err(format!("{trimmed:?} is not a valid byte size"));
}
return Ok((value * (*multiplier as f64)).round() as u64);
}
}
trimmed.parse::<u64>().map_err(|_| {
format!(
"{trimmed:?} is not a valid byte size (expected a plain byte count, or a number with \
a B/KB/MB/GB/TB or KiB/MiB/GiB/TiB suffix)"
)
})
}
pub fn human_bytes(bytes: u64) -> String {
const UNITS: &[(&str, u64)] = &[
("TiB", 1024 * 1024 * 1024 * 1024),
("GiB", 1024 * 1024 * 1024),
("MiB", 1024 * 1024),
("KiB", 1024),
];
for (unit, size) in UNITS {
if bytes >= *size {
return format!("{:.2} {unit}", bytes as f64 / *size as f64);
}
}
format!("{bytes} B")
}
pub fn parse_and_validate_fjall_bytes(field: &str, raw: &str, minimum: u64) -> Result<u64, String> {
let bytes = parse_byte_size(raw).map_err(|e| format!("invalid {field} value {raw:?}: {e}"))?;
validate_fjall_bytes(field, bytes, minimum, raw)
}
fn validate_fjall_bytes(
field: &str,
bytes: u64,
minimum: u64,
raw_display: &str,
) -> Result<u64, String> {
if bytes == 0 {
return Err(format!(
"{field} must not be zero (got {raw_display:?}); fjall needs a positive size here"
));
}
if bytes < minimum {
return Err(format!(
"{field} value {raw_display:?} ({bytes} bytes) is too small; must be at least \
{minimum} bytes ({})",
human_bytes(minimum)
));
}
Ok(bytes)
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawByteSize {
Number(u64),
Text(String),
}
fn deserialize_optional_fjall_byte_size<'de, D>(
deserializer: D,
field: &str,
minimum: u64,
) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
match Option::<RawByteSize>::deserialize(deserializer)? {
None => Ok(None),
Some(RawByteSize::Number(n)) => validate_fjall_bytes(field, n, minimum, &n.to_string())
.map(Some)
.map_err(serde::de::Error::custom),
Some(RawByteSize::Text(s)) => {
let bytes = parse_byte_size(&s).map_err(|e| {
serde::de::Error::custom(format!("invalid {field} value {s:?}: {e}"))
})?;
validate_fjall_bytes(field, bytes, minimum, &s)
.map(Some)
.map_err(serde::de::Error::custom)
}
}
}
fn deserialize_block_cache<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_optional_fjall_byte_size(deserializer, "fjall.block_cache", MIN_BLOCK_CACHE_BYTES)
}
fn deserialize_write_buffer<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_optional_fjall_byte_size(deserializer, "fjall.write_buffer", MIN_WRITE_BUFFER_BYTES)
}
fn deserialize_max_journal<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_optional_fjall_byte_size(deserializer, "fjall.max_journal", MIN_MAX_JOURNAL_BYTES)
}
impl FjallTuning {
pub fn validate(&self) -> Result<(), String> {
if let Some(bytes) = self.block_cache {
validate_fjall_bytes(
"fjall.block_cache",
bytes,
MIN_BLOCK_CACHE_BYTES,
&bytes.to_string(),
)?;
}
if let Some(bytes) = self.write_buffer {
validate_fjall_bytes(
"fjall.write_buffer",
bytes,
MIN_WRITE_BUFFER_BYTES,
&bytes.to_string(),
)?;
}
if let Some(bytes) = self.max_journal {
validate_fjall_bytes(
"fjall.max_journal",
bytes,
MIN_MAX_JOURNAL_BYTES,
&bytes.to_string(),
)?;
}
Ok(())
}
}
pub fn apply_fjall_env_overrides(config: &mut FjallTuning) -> Result<(), String> {
if let Ok(raw) = std::env::var("STORAGE_FJALL_BLOCK_CACHE") {
config.block_cache = Some(parse_and_validate_fjall_bytes(
"STORAGE_FJALL_BLOCK_CACHE",
&raw,
MIN_BLOCK_CACHE_BYTES,
)?);
}
if let Ok(raw) = std::env::var("STORAGE_FJALL_WRITE_BUFFER") {
config.write_buffer = Some(parse_and_validate_fjall_bytes(
"STORAGE_FJALL_WRITE_BUFFER",
&raw,
MIN_WRITE_BUFFER_BYTES,
)?);
}
if let Ok(raw) = std::env::var("STORAGE_FJALL_MAX_JOURNAL") {
config.max_journal = Some(parse_and_validate_fjall_bytes(
"STORAGE_FJALL_MAX_JOURNAL",
&raw,
MIN_MAX_JOURNAL_BYTES,
)?);
}
Ok(())
}
#[derive(Clone, Deserialize, Serialize)]
pub struct AuthConfig {
#[serde(default = "default_access_token_expiry")]
pub access_token_expiry: u64,
#[serde(default = "default_refresh_token_expiry")]
pub refresh_token_expiry: u64,
#[serde(default = "default_challenge_ttl")]
pub challenge_ttl: u64,
#[serde(default = "default_admin_idle_timeout")]
pub admin_idle_timeout: u64,
#[serde(default = "default_refresh_reuse_grace")]
pub refresh_reuse_grace: u64,
#[serde(default = "default_session_cleanup_interval")]
pub session_cleanup_interval: u64,
pub jwt_signing_key: Option<String>,
#[serde(default, deserialize_with = "refuse_retired_step_up", skip_serializing)]
pub step_up: (),
}
fn refuse_retired_step_up<'de, D>(_: D) -> Result<(), D::Error>
where
D: serde::Deserializer<'de>,
{
Err(serde::de::Error::custom(
"`[auth.step_up]` has been retired. The step-up floors were a second, \
parallel answer to \"does this operation need another human decision?\", \
resolved separately from the policy rules — which is how a VTA could \
demand a step-up that no rule explained. Approvals are now one model: \
delete the `[auth.step_up]` section and express the same requirement as \
a rule with `pnm approvals require <task-uri> --reauth` (or \
`--consent`). `pnm approvals list` then shows every gated operation, \
which the floors never could.",
))
}
impl std::fmt::Debug for AuthConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthConfig")
.field("access_token_expiry", &self.access_token_expiry)
.field("refresh_token_expiry", &self.refresh_token_expiry)
.field("challenge_ttl", &self.challenge_ttl)
.field("admin_idle_timeout", &self.admin_idle_timeout)
.field("session_cleanup_interval", &self.session_cleanup_interval)
.field(
"jwt_signing_key",
&self.jwt_signing_key.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessagingConfig {
#[serde(default)]
pub mediator_url: String,
pub mediator_did: String,
#[serde(default)]
pub mediator_host: Option<String>,
#[serde(default)]
pub setup_acl: bool,
#[serde(default)]
pub drain_inbox_on_start: bool,
}
pub const DID_CACHE_TTL_DEFAULT_SECS: u32 = 60;
pub const DID_CACHE_TTL_MAX_SECS: u32 = 300;
pub const DID_CACHE_CAPACITY_DEFAULT: u32 = 1000;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DidCacheConfig {
#[serde(default = "default_did_cache_ttl_secs")]
pub ttl_secs: u32,
#[serde(default = "default_did_cache_capacity")]
pub capacity: u32,
}
fn default_did_cache_ttl_secs() -> u32 {
DID_CACHE_TTL_DEFAULT_SECS
}
fn default_did_cache_capacity() -> u32 {
DID_CACHE_CAPACITY_DEFAULT
}
impl Default for DidCacheConfig {
fn default() -> Self {
Self {
ttl_secs: DID_CACHE_TTL_DEFAULT_SECS,
capacity: DID_CACHE_CAPACITY_DEFAULT,
}
}
}
impl DidCacheConfig {
pub fn validation_errors(&self) -> Vec<String> {
let mut errors = Vec::new();
if self.ttl_secs == 0 || self.ttl_secs > DID_CACHE_TTL_MAX_SECS {
errors.push(format!(
"did_cache.ttl_secs = {} is outside 1..={DID_CACHE_TTL_MAX_SECS}: it bounds how \
long a revoked key keeps verifying on this node, so it may not be longer than \
{DID_CACHE_TTL_MAX_SECS} seconds (and 0 would disable the cache the node relies \
on). The default is {DID_CACHE_TTL_DEFAULT_SECS}.",
self.ttl_secs
));
}
if self.capacity == 0 {
errors.push(
"did_cache.capacity = 0 would cache nothing and resolve every DID on every \
request; remove the key for the default"
.into(),
);
}
errors
}
}
#[cfg(test)]
mod did_cache_config_tests {
use super::*;
#[test]
fn defaults_are_bounded_and_valid() {
let c: DidCacheConfig = toml::from_str("").unwrap();
assert_eq!(c.ttl_secs, DID_CACHE_TTL_DEFAULT_SECS);
assert!(c.ttl_secs <= DID_CACHE_TTL_MAX_SECS);
assert!(c.validation_errors().is_empty());
}
#[test]
fn a_ttl_past_the_bound_or_zero_is_refused() {
for ttl in [0, DID_CACHE_TTL_MAX_SECS + 1, 86_400] {
let c = DidCacheConfig {
ttl_secs: ttl,
..Default::default()
};
assert_eq!(c.validation_errors().len(), 1, "ttl {ttl}");
}
let c = DidCacheConfig {
ttl_secs: DID_CACHE_TTL_MAX_SECS,
..Default::default()
};
assert!(c.validation_errors().is_empty());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditConfig {
#[serde(default = "default_audit_retention_days")]
pub retention_days: u32,
}
fn default_audit_retention_days() -> u32 {
28
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
retention_days: default_audit_retention_days(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultConfig {
#[serde(default = "default_vault_grace_days")]
pub grace_days: u32,
#[serde(default)]
pub mdoc_iaca_trust_anchors: Vec<String>,
}
fn default_vault_grace_days() -> u32 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppStateConfig {
#[serde(default = "default_tombstone_retention_days")]
pub tombstone_retention_days: u32,
}
fn default_tombstone_retention_days() -> u32 {
30
}
impl Default for AppStateConfig {
fn default() -> Self {
Self {
tombstone_retention_days: default_tombstone_retention_days(),
}
}
}
impl Default for VaultConfig {
fn default() -> Self {
Self {
grace_days: default_vault_grace_days(),
mdoc_iaca_trust_anchors: Vec::new(),
}
}
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Text,
Json,
}
fn default_host() -> String {
"0.0.0.0".to_string()
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_access_token_expiry() -> u64 {
900
}
fn default_refresh_token_expiry() -> u64 {
86400
}
fn default_challenge_ttl() -> u64 {
300
}
fn default_admin_idle_timeout() -> u64 {
900
}
fn default_session_cleanup_interval() -> u64 {
600
}
fn default_refresh_reuse_grace() -> u64 {
30
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
access_token_expiry: default_access_token_expiry(),
refresh_token_expiry: default_refresh_token_expiry(),
challenge_ttl: default_challenge_ttl(),
admin_idle_timeout: default_admin_idle_timeout(),
refresh_reuse_grace: default_refresh_reuse_grace(),
session_cleanup_interval: default_session_cleanup_interval(),
jwt_signing_key: None,
step_up: (),
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: LogFormat::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_config_debug_redacts_jwt_signing_key() {
let cfg = AuthConfig {
access_token_expiry: 900,
refresh_token_expiry: 86400,
challenge_ttl: 300,
admin_idle_timeout: 900,
refresh_reuse_grace: 30,
session_cleanup_interval: 600,
jwt_signing_key: Some("SUPER_SECRET_KEY_MATERIAL_MUST_NOT_LEAK".into()),
step_up: (),
};
let dbg = format!("{cfg:?}");
assert!(
!dbg.contains("SUPER_SECRET_KEY_MATERIAL"),
"AuthConfig Debug leaked jwt_signing_key contents: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"expected redaction marker in Debug, got: {dbg}"
);
assert!(
dbg.contains("900"),
"access_token_expiry must still be visible: {dbg}"
);
}
#[test]
fn auth_config_debug_none_signing_key_renders_none() {
let cfg = AuthConfig::default();
let dbg = format!("{cfg:?}");
assert!(dbg.contains("jwt_signing_key: None"), "got: {dbg}");
}
#[test]
fn auth_config_serialize_still_carries_jwt_signing_key() {
let cfg = AuthConfig {
access_token_expiry: 900,
refresh_token_expiry: 86400,
challenge_ttl: 300,
admin_idle_timeout: 900,
refresh_reuse_grace: 30,
session_cleanup_interval: 600,
jwt_signing_key: Some("key-material".into()),
step_up: (),
};
let json = serde_json::to_string(&cfg).expect("serialize");
assert!(
json.contains("key-material"),
"Serialize must not redact — config persistence relies on round-trip: {json}"
);
}
#[test]
fn a_config_still_carrying_the_retired_floors_is_refused() {
let with_floors = r#"{
"jwt_signing_key": null,
"step_up": { "enabled": true, "floors": [{ "operation": "*", "mode": "self" }] }
}"#;
let err = serde_json::from_str::<AuthConfig>(with_floors)
.expect_err("`[auth.step_up]` must be refused, not ignored");
let msg = err.to_string();
assert!(msg.contains("retired"), "got: {msg}");
assert!(
msg.contains("pnm approvals require"),
"the refusal must name what replaces it, got: {msg}"
);
assert!(
serde_json::from_str::<AuthConfig>(r#"{"jwt_signing_key":null,"step_up":{}}"#).is_err()
);
}
#[test]
fn a_config_without_the_retired_section_loads() {
let cfg: AuthConfig =
serde_json::from_str(r#"{ "jwt_signing_key": null }"#).expect("loads");
assert_eq!(cfg.access_token_expiry, default_access_token_expiry());
}
}
#[cfg(test)]
mod mdoc_trust_anchor_config_tests {
use super::*;
#[test]
fn trust_anchors_default_to_empty_and_an_old_config_still_loads() {
let cfg: VaultConfig = toml::from_str("grace_days = 30").expect("legacy config loads");
assert_eq!(cfg.grace_days, 30);
assert!(
cfg.mdoc_iaca_trust_anchors.is_empty(),
"absent means no mdoc issuer is trusted, not a permissive default"
);
}
#[test]
fn app_state_config_defaults_when_absent() {
let cfg: AppStateConfig = toml::from_str("").expect("an absent section loads");
assert_eq!(cfg.tombstone_retention_days, 30);
assert_eq!(AppStateConfig::default().tombstone_retention_days, 30);
}
#[test]
fn app_state_retention_zero_survives_as_zero() {
let cfg: AppStateConfig =
toml::from_str("tombstone_retention_days = 0").expect("explicit zero loads");
assert_eq!(
cfg.tombstone_retention_days, 0,
"an explicit 0 must not be rewritten to the default"
);
}
#[test]
fn trust_anchors_round_trip_through_toml() {
let cfg: VaultConfig = toml::from_str(
r#"
grace_days = 7
mdoc_iaca_trust_anchors = ["-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"]
"#,
)
.expect("config with anchors loads");
assert_eq!(cfg.mdoc_iaca_trust_anchors.len(), 1);
assert!(cfg.mdoc_iaca_trust_anchors[0].contains("BEGIN CERTIFICATE"));
}
}
#[cfg(test)]
mod fjall_config_tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const FJALL_ENV_VARS: [&str; 3] = [
"STORAGE_FJALL_BLOCK_CACHE",
"STORAGE_FJALL_WRITE_BUFFER",
"STORAGE_FJALL_MAX_JOURNAL",
];
fn clear_fjall_env() {
unsafe {
for var in FJALL_ENV_VARS {
std::env::remove_var(var);
}
}
}
#[test]
fn parse_byte_size_accepts_a_plain_byte_count() {
assert_eq!(parse_byte_size("67108864").unwrap(), 67_108_864);
assert_eq!(parse_byte_size("0").unwrap(), 0);
assert_eq!(
parse_byte_size(" 1024 ").unwrap(),
1024,
"whitespace is trimmed"
);
}
#[test]
fn parse_byte_size_accepts_every_binary_suffix() {
assert_eq!(parse_byte_size("1KiB").unwrap(), 1024);
assert_eq!(parse_byte_size("64MiB").unwrap(), 64 * 1024 * 1024);
assert_eq!(parse_byte_size("1GiB").unwrap(), 1024 * 1024 * 1024);
assert_eq!(
parse_byte_size("2TiB").unwrap(),
2 * 1024 * 1024 * 1024 * 1024
);
}
#[test]
fn parse_byte_size_accepts_every_decimal_suffix() {
assert_eq!(parse_byte_size("512B").unwrap(), 512);
assert_eq!(parse_byte_size("1KB").unwrap(), 1_000);
assert_eq!(parse_byte_size("512MB").unwrap(), 512_000_000);
assert_eq!(parse_byte_size("1GB").unwrap(), 1_000_000_000);
assert_eq!(parse_byte_size("1TB").unwrap(), 1_000_000_000_000);
}
#[test]
fn parse_byte_size_is_case_insensitive_and_accepts_fractions() {
assert_eq!(parse_byte_size("64mib").unwrap(), 64 * 1024 * 1024);
assert_eq!(
parse_byte_size("1.5GiB").unwrap(),
(1.5 * 1024.0 * 1024.0 * 1024.0) as u64
);
}
#[test]
fn parse_byte_size_rejects_garbage_and_unknown_suffixes() {
assert!(parse_byte_size("").is_err(), "empty");
assert!(parse_byte_size(" ").is_err(), "whitespace only");
assert!(parse_byte_size("not-a-size").is_err(), "non-numeric");
assert!(parse_byte_size("64XiB").is_err(), "unrecognised suffix");
assert!(parse_byte_size("-64MiB").is_err(), "negative");
assert!(parse_byte_size("-1").is_err(), "negative, no suffix");
assert!(parse_byte_size("MiB").is_err(), "suffix with no number");
}
#[test]
fn parse_and_validate_rejects_zero_naming_the_field() {
let err =
parse_and_validate_fjall_bytes("STORAGE_FJALL_BLOCK_CACHE", "0", MIN_BLOCK_CACHE_BYTES)
.unwrap_err();
assert!(err.contains("STORAGE_FJALL_BLOCK_CACHE"), "got: {err}");
assert!(err.contains("zero"), "got: {err}");
}
#[test]
fn parse_and_validate_rejects_an_absurdly_small_value_naming_the_field() {
let err = parse_and_validate_fjall_bytes(
"STORAGE_FJALL_MAX_JOURNAL",
"1KiB",
MIN_MAX_JOURNAL_BYTES,
)
.unwrap_err();
assert!(err.contains("STORAGE_FJALL_MAX_JOURNAL"), "got: {err}");
assert!(err.contains("too small"), "got: {err}");
}
#[test]
fn parse_and_validate_rejects_an_unparseable_value_naming_the_field() {
let err = parse_and_validate_fjall_bytes(
"STORAGE_FJALL_WRITE_BUFFER",
"garbage",
MIN_WRITE_BUFFER_BYTES,
)
.unwrap_err();
assert!(err.contains("STORAGE_FJALL_WRITE_BUFFER"), "got: {err}");
}
#[test]
fn parse_and_validate_accepts_a_value_at_exactly_the_floor() {
assert_eq!(
parse_and_validate_fjall_bytes(
"STORAGE_FJALL_MAX_JOURNAL",
"64MiB",
MIN_MAX_JOURNAL_BYTES
)
.unwrap(),
MIN_MAX_JOURNAL_BYTES
);
}
#[test]
fn fjall_tuning_validate_passes_when_every_field_is_unset() {
assert!(FjallTuning::default().validate().is_ok());
}
#[test]
fn fjall_tuning_validate_reports_a_below_floor_field() {
let cfg = FjallTuning {
block_cache: None,
write_buffer: Some(1024),
max_journal: None,
};
let err = cfg.validate().unwrap_err();
assert!(err.contains("fjall.write_buffer"), "got: {err}");
}
#[test]
fn an_absent_fjall_table_defaults_every_field_to_unset() {
let cfg: FjallTuning = toml::from_str("").expect("loads");
assert_eq!(cfg, FjallTuning::default());
}
#[test]
fn fjall_table_accepts_suffixed_strings_and_plain_integers() {
let cfg: FjallTuning = toml::from_str(
r#"
block_cache = "64MiB"
write_buffer = "16MiB"
max_journal = 134217728
"#,
)
.expect("loads");
assert_eq!(cfg.block_cache, Some(64 * 1024 * 1024));
assert_eq!(cfg.write_buffer, Some(16 * 1024 * 1024));
assert_eq!(cfg.max_journal, Some(134_217_728));
}
#[test]
fn fjall_table_refuses_a_below_floor_value_at_parse_time() {
let err = toml::from_str::<FjallTuning>(r#"block_cache = "1B""#)
.expect_err("a below-floor block_cache must be refused when the file is parsed");
let msg = err.to_string();
assert!(msg.contains("fjall.block_cache"), "got: {msg}");
}
#[test]
fn fjall_table_refuses_an_unknown_suffix_at_parse_time() {
let err = toml::from_str::<FjallTuning>(r#"max_journal = "64XiB""#)
.expect_err("an unparseable size must be refused when the file is parsed");
assert!(err.to_string().contains("fjall.max_journal"));
}
#[test]
fn env_override_wins_over_the_file_value() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_fjall_env();
let mut cfg = FjallTuning {
block_cache: Some(32 * 1024 * 1024),
write_buffer: None,
max_journal: None,
};
unsafe {
std::env::set_var("STORAGE_FJALL_BLOCK_CACHE", "8MiB");
}
apply_fjall_env_overrides(&mut cfg).expect("valid override applies");
assert_eq!(
cfg.block_cache,
Some(8 * 1024 * 1024),
"the env var must win over whatever the file set"
);
clear_fjall_env();
}
#[test]
fn an_unset_env_var_leaves_the_file_value_untouched() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_fjall_env();
let mut cfg = FjallTuning {
block_cache: Some(32 * 1024 * 1024),
write_buffer: Some(MIN_WRITE_BUFFER_BYTES),
max_journal: None,
};
let before = cfg;
apply_fjall_env_overrides(&mut cfg).expect("no env vars set: nothing to apply");
assert_eq!(
cfg, before,
"with no STORAGE_FJALL_* vars set, nothing changes"
);
}
#[test]
fn env_override_refuses_an_invalid_value_naming_the_var() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_fjall_env();
unsafe {
std::env::set_var("STORAGE_FJALL_MAX_JOURNAL", "1KiB");
}
let err = apply_fjall_env_overrides(&mut FjallTuning::default())
.expect_err("a below-floor journal size must be refused");
assert!(err.contains("STORAGE_FJALL_MAX_JOURNAL"), "got: {err}");
clear_fjall_env();
}
#[test]
fn human_bytes_renders_the_natural_unit() {
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(64 * 1024 * 1024), "64.00 MiB");
assert_eq!(human_bytes(1024 * 1024 * 1024), "1.00 GiB");
}
}