use serde::{Deserialize, Serialize};
use crate::config::MailSection;
pub const MAX_AGENT_PRUNE_DAYS: u32 = 36_500;
pub const DEFAULT_COLLECT_RETENTION_DAYS: u32 = 30;
pub const MAX_COLLECT_RETENTION_DAYS: u32 = 3650;
pub const DEFAULT_SESSION_TTL_HOURS: u32 = 24;
pub const MAX_SESSION_TTL_HOURS: u32 = 8_760;
pub const DEFAULT_CHECK_STATUS_STALE_DAYS: u32 = 30;
pub const MAX_CHECK_STATUS_STALE_DAYS: u32 = 3650;
pub const DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES: u32 = 15;
pub const MAX_SUPPORT_UNLOCK_TTL_MINUTES: u32 = 480;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(default)]
pub struct SupportCode {
pub scope: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_minutes: Option<u32>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub disabled: bool,
}
impl SupportCode {
pub fn effective_ttl_minutes(&self) -> u32 {
self.ttl_minutes
.unwrap_or(DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES)
.clamp(1, MAX_SUPPORT_UNLOCK_TTL_MINUTES)
}
pub fn is_usable(&self) -> bool {
!self.disabled && !self.hash.is_empty()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(default)]
pub struct ServerSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_prune_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub controller_group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mail: Option<MailSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub collect_retention_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_ttl_hours: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub check_status_stale_days: Option<u32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub support_codes: Vec<SupportCode>,
}
impl ServerSettings {
pub fn defaults() -> Self {
Self {
agent_prune_days: None,
controller_group: None,
mail: None,
collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
session_ttl_hours: Some(DEFAULT_SESSION_TTL_HOURS),
check_status_stale_days: Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
support_codes: Vec::new(),
}
}
pub fn support_code(&self, scope: &str) -> Option<&SupportCode> {
self.support_codes
.iter()
.find(|c| c.scope == scope && c.is_usable())
}
#[must_use]
pub fn redacted(mut self) -> Self {
for c in &mut self.support_codes {
c.hash.clear();
}
self
}
pub fn effective_controller_group(&self) -> Option<&str> {
self.controller_group
.as_deref()
.map(str::trim)
.filter(|g| !g.is_empty())
}
pub fn effective_agent_prune_days(&self) -> u32 {
self.agent_prune_days
.or(Self::defaults().agent_prune_days)
.unwrap_or(0)
.min(MAX_AGENT_PRUNE_DAYS)
}
pub fn effective_collect_retention_days(&self) -> u32 {
self.collect_retention_days
.or(Self::defaults().collect_retention_days)
.unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
.clamp(1, MAX_COLLECT_RETENTION_DAYS)
}
pub fn effective_session_ttl_hours(&self) -> u32 {
self.session_ttl_hours
.or(Self::defaults().session_ttl_hours)
.unwrap_or(DEFAULT_SESSION_TTL_HOURS)
.clamp(1, MAX_SESSION_TTL_HOURS)
}
pub fn effective_check_status_stale_days(&self) -> u32 {
self.check_status_stale_days
.or(Self::defaults().check_status_stale_days)
.unwrap_or(DEFAULT_CHECK_STATUS_STALE_DAYS)
.min(MAX_CHECK_STATUS_STALE_DAYS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_unset() {
assert_eq!(ServerSettings::default().agent_prune_days, None);
}
#[test]
fn unset_resolves_to_disabled() {
assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
}
#[test]
fn stored_value_wins_over_default() {
let s = ServerSettings {
agent_prune_days: Some(30),
..Default::default()
};
assert_eq!(s.effective_agent_prune_days(), 30);
}
#[test]
fn effective_clamps_to_max() {
let s = ServerSettings {
agent_prune_days: Some(u32::MAX),
..Default::default()
};
assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
}
#[test]
fn round_trips_through_json() {
let s = ServerSettings {
agent_prune_days: Some(30),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"agent_prune_days":30}"#);
let back: ServerSettings = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn unset_serialises_to_empty_object() {
let s = ServerSettings::default();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, "{}");
let back: ServerSettings = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn explicit_null_decodes_to_unset() {
let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
assert_eq!(s.agent_prune_days, None);
}
#[test]
fn empty_object_decodes_to_default() {
let s: ServerSettings = serde_json::from_str("{}").unwrap();
assert_eq!(s, ServerSettings::default());
}
#[test]
fn controller_group_effective_trims_and_blank_is_unset() {
assert_eq!(ServerSettings::default().effective_controller_group(), None);
let s = ServerSettings {
controller_group: Some(" feed-runners ".into()),
..Default::default()
};
assert_eq!(s.effective_controller_group(), Some("feed-runners"));
let blank = ServerSettings {
controller_group: Some(" ".into()),
..Default::default()
};
assert_eq!(blank.effective_controller_group(), None);
}
#[test]
fn controller_group_round_trips_and_omits_when_unset() {
let s = ServerSettings {
controller_group: Some("infra".into()),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"controller_group":"infra"}"#);
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
assert_eq!(
serde_json::to_string(&ServerSettings::default()).unwrap(),
"{}"
);
}
#[test]
fn mail_round_trips_and_omits_when_unset() {
use crate::config::{MailEncryption, MailSection};
assert_eq!(
serde_json::to_string(&ServerSettings::default()).unwrap(),
"{}"
);
let s = ServerSettings {
mail: Some(MailSection {
host: "smtp.example.com".into(),
port: 587,
encryption: MailEncryption::Starttls,
from: "kanade-noreply@example.com".into(),
username: Some("kanade-noreply".into()),
}),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
assert!(!json.contains("password"), "password must never serialise");
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
}
#[test]
fn mail_defaults_to_unset() {
assert_eq!(ServerSettings::default().mail, None);
let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
assert_eq!(s.mail, None);
assert_eq!(s.agent_prune_days, Some(7));
}
#[test]
fn collect_retention_unset_resolves_to_builtin_default() {
assert_eq!(ServerSettings::default().collect_retention_days, None);
assert_eq!(
ServerSettings::default().effective_collect_retention_days(),
DEFAULT_COLLECT_RETENTION_DAYS,
);
assert_eq!(
ServerSettings::defaults().collect_retention_days,
Some(DEFAULT_COLLECT_RETENTION_DAYS),
);
}
#[test]
fn collect_retention_stored_value_wins() {
let s = ServerSettings {
collect_retention_days: Some(90),
..Default::default()
};
assert_eq!(s.effective_collect_retention_days(), 90);
}
#[test]
fn collect_retention_effective_clamps_out_of_band_writes() {
let big = ServerSettings {
collect_retention_days: Some(u32::MAX),
..Default::default()
};
assert_eq!(
big.effective_collect_retention_days(),
MAX_COLLECT_RETENTION_DAYS,
);
let zero = ServerSettings {
collect_retention_days: Some(0),
..Default::default()
};
assert_eq!(zero.effective_collect_retention_days(), 1);
}
#[test]
fn collect_retention_round_trips_and_omits_when_unset() {
let s = ServerSettings {
collect_retention_days: Some(90),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"collect_retention_days":90}"#);
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
assert!(
!serde_json::to_string(&ServerSettings::default())
.unwrap()
.contains("collect_retention_days")
);
}
#[test]
fn session_ttl_unset_resolves_to_builtin_default() {
assert_eq!(ServerSettings::default().session_ttl_hours, None);
assert_eq!(
ServerSettings::default().effective_session_ttl_hours(),
DEFAULT_SESSION_TTL_HOURS,
);
assert_eq!(
ServerSettings::defaults().session_ttl_hours,
Some(DEFAULT_SESSION_TTL_HOURS),
);
}
#[test]
fn session_ttl_stored_value_wins() {
let s = ServerSettings {
session_ttl_hours: Some(72),
..Default::default()
};
assert_eq!(s.effective_session_ttl_hours(), 72);
}
#[test]
fn session_ttl_effective_clamps_out_of_band_writes() {
let zero = ServerSettings {
session_ttl_hours: Some(0),
..Default::default()
};
assert_eq!(zero.effective_session_ttl_hours(), 1);
let huge = ServerSettings {
session_ttl_hours: Some(u32::MAX),
..Default::default()
};
assert_eq!(huge.effective_session_ttl_hours(), MAX_SESSION_TTL_HOURS);
}
#[test]
fn session_ttl_round_trips_and_omits_when_unset() {
let s = ServerSettings {
session_ttl_hours: Some(48),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"session_ttl_hours":48}"#);
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
assert!(
!serde_json::to_string(&ServerSettings::default())
.unwrap()
.contains("session_ttl_hours")
);
}
#[test]
fn check_stale_unset_resolves_to_builtin_default() {
assert_eq!(ServerSettings::default().check_status_stale_days, None);
assert_eq!(
ServerSettings::default().effective_check_status_stale_days(),
DEFAULT_CHECK_STATUS_STALE_DAYS,
);
assert_eq!(
ServerSettings::defaults().check_status_stale_days,
Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
);
}
#[test]
fn check_stale_zero_disables() {
let s = ServerSettings {
check_status_stale_days: Some(0),
..Default::default()
};
assert_eq!(s.effective_check_status_stale_days(), 0);
}
#[test]
fn check_stale_stored_value_wins_and_clamps() {
let s = ServerSettings {
check_status_stale_days: Some(7),
..Default::default()
};
assert_eq!(s.effective_check_status_stale_days(), 7);
let big = ServerSettings {
check_status_stale_days: Some(u32::MAX),
..Default::default()
};
assert_eq!(
big.effective_check_status_stale_days(),
MAX_CHECK_STATUS_STALE_DAYS,
);
}
#[test]
fn check_stale_round_trips_and_omits_when_unset() {
let s = ServerSettings {
check_status_stale_days: Some(14),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"check_status_stale_days":14}"#);
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
assert!(
!serde_json::to_string(&ServerSettings::default())
.unwrap()
.contains("check_status_stale_days")
);
}
#[test]
fn support_codes_absent_by_default_and_omitted_from_the_wire() {
let s = ServerSettings::default();
assert!(s.support_codes.is_empty());
assert_eq!(serde_json::to_string(&s).unwrap(), "{}");
assert!(s.support_code("support").is_none());
}
#[test]
fn support_code_lookup_fails_closed() {
let s = ServerSettings {
support_codes: vec![
SupportCode {
scope: "support".into(),
hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
label: Some("ヘルプデスク".into()),
..Default::default()
},
SupportCode {
scope: "admin".into(),
hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
disabled: true,
..Default::default()
},
SupportCode {
scope: "blank".into(),
..Default::default()
},
],
..Default::default()
};
assert!(s.support_code("support").is_some());
assert!(s.support_code("admin").is_none(), "disabled must not match");
assert!(
s.support_code("blank").is_none(),
"blank hash must not match"
);
assert!(s.support_code("nope").is_none());
}
#[test]
fn redacted_blanks_hashes_but_keeps_the_roster() {
let s = ServerSettings {
support_codes: vec![SupportCode {
scope: "support".into(),
hash: "$argon2id$secret".into(),
label: Some("ヘルプデスク".into()),
ttl_minutes: Some(30),
disabled: false,
}],
..Default::default()
};
let json = serde_json::to_string(&s.redacted()).unwrap();
assert!(!json.contains("argon2"), "wire leaked the hash: {json}");
assert!(!json.contains("hash"), "wire leaked the hash key: {json}");
assert!(json.contains("\"scope\":\"support\""), "wire: {json}");
assert!(json.contains("\"ttl_minutes\":30"), "wire: {json}");
}
#[test]
fn support_code_ttl_clamps() {
let unset = SupportCode::default();
assert_eq!(
unset.effective_ttl_minutes(),
DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES
);
let zero = SupportCode {
ttl_minutes: Some(0),
..Default::default()
};
assert_eq!(zero.effective_ttl_minutes(), 1);
let huge = SupportCode {
ttl_minutes: Some(u32::MAX),
..Default::default()
};
assert_eq!(huge.effective_ttl_minutes(), MAX_SUPPORT_UNLOCK_TTL_MINUTES);
}
#[test]
fn accepts_unknown_fields_for_forward_compat() {
let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
let s: ServerSettings = serde_json::from_str(json).unwrap();
assert_eq!(s.agent_prune_days, Some(7));
}
}