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 ObjectStoreCaps {
#[serde(skip_serializing_if = "Option::is_none")]
pub result_output_mib: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_releases_mib: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_packages_mib: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripts_mib: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub collections_mib: Option<u32>,
}
pub const DEFAULT_RESULT_OUTPUT_CAP_MIB: u32 = 1024;
pub const DEFAULT_AGENT_RELEASES_CAP_MIB: u32 = 2048;
pub const DEFAULT_APP_PACKAGES_CAP_MIB: u32 = 5120;
pub const DEFAULT_SCRIPTS_CAP_MIB: u32 = 256;
pub const DEFAULT_COLLECTIONS_CAP_MIB: u32 = 5120;
pub const MAX_OBJECT_STORE_CAP_MIB: u32 = 51_200;
pub const MAX_OBJECT_STORE_TOTAL_MIB: u32 = 46_272;
impl ObjectStoreCaps {
fn effective(v: Option<u32>, default: u32) -> u32 {
v.unwrap_or(default).clamp(1, MAX_OBJECT_STORE_CAP_MIB)
}
pub fn effective_result_output_mib(&self) -> u32 {
Self::effective(self.result_output_mib, DEFAULT_RESULT_OUTPUT_CAP_MIB)
}
pub fn effective_agent_releases_mib(&self) -> u32 {
Self::effective(self.agent_releases_mib, DEFAULT_AGENT_RELEASES_CAP_MIB)
}
pub fn effective_app_packages_mib(&self) -> u32 {
Self::effective(self.app_packages_mib, DEFAULT_APP_PACKAGES_CAP_MIB)
}
pub fn effective_scripts_mib(&self) -> u32 {
Self::effective(self.scripts_mib, DEFAULT_SCRIPTS_CAP_MIB)
}
pub fn effective_collections_mib(&self) -> u32 {
Self::effective(self.collections_mib, DEFAULT_COLLECTIONS_CAP_MIB)
}
pub fn effective_all(&self) -> [(&'static str, u32); 5] {
[
(
crate::kv::OBJECT_AGENT_RELEASES,
self.effective_agent_releases_mib(),
),
(
crate::kv::OBJECT_APP_PACKAGES,
self.effective_app_packages_mib(),
),
(crate::kv::OBJECT_SCRIPTS, self.effective_scripts_mib()),
(
crate::kv::OBJECT_RESULT_OUTPUT,
self.effective_result_output_mib(),
),
(
crate::kv::OBJECT_COLLECTIONS,
self.effective_collections_mib(),
),
]
}
}
#[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 = "Option::is_none")]
pub object_store_caps: Option<ObjectStoreCaps>,
#[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),
object_store_caps: Some(ObjectStoreCaps {
result_output_mib: Some(DEFAULT_RESULT_OUTPUT_CAP_MIB),
agent_releases_mib: Some(DEFAULT_AGENT_RELEASES_CAP_MIB),
app_packages_mib: Some(DEFAULT_APP_PACKAGES_CAP_MIB),
scripts_mib: Some(DEFAULT_SCRIPTS_CAP_MIB),
collections_mib: Some(DEFAULT_COLLECTIONS_CAP_MIB),
}),
support_codes: Vec::new(),
}
}
pub fn effective_object_store_caps(&self) -> ObjectStoreCaps {
let c = self.object_store_caps.clone().unwrap_or_default();
ObjectStoreCaps {
result_output_mib: Some(c.effective_result_output_mib()),
agent_releases_mib: Some(c.effective_agent_releases_mib()),
app_packages_mib: Some(c.effective_app_packages_mib()),
scripts_mib: Some(c.effective_scripts_mib()),
collections_mib: Some(c.effective_collections_mib()),
}
}
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));
}
#[test]
fn object_store_caps_unset_resolves_to_builtin_defaults() {
let s = ServerSettings::default();
assert_eq!(s.object_store_caps, None);
let c = s.effective_object_store_caps();
assert_eq!(c.result_output_mib, Some(DEFAULT_RESULT_OUTPUT_CAP_MIB));
assert_eq!(c.agent_releases_mib, Some(DEFAULT_AGENT_RELEASES_CAP_MIB));
assert_eq!(c.app_packages_mib, Some(DEFAULT_APP_PACKAGES_CAP_MIB));
assert_eq!(c.scripts_mib, Some(DEFAULT_SCRIPTS_CAP_MIB));
assert_eq!(c.collections_mib, Some(DEFAULT_COLLECTIONS_CAP_MIB));
}
#[test]
fn object_store_caps_partial_override_keeps_other_defaults() {
let s = ServerSettings {
object_store_caps: Some(ObjectStoreCaps {
app_packages_mib: Some(8192),
..Default::default()
}),
..Default::default()
};
let c = s.effective_object_store_caps();
assert_eq!(c.app_packages_mib, Some(8192));
assert_eq!(c.scripts_mib, Some(DEFAULT_SCRIPTS_CAP_MIB));
}
#[test]
fn object_store_caps_clamps_out_of_band_writes() {
let s = ServerSettings {
object_store_caps: Some(ObjectStoreCaps {
result_output_mib: Some(0),
app_packages_mib: Some(u32::MAX),
..Default::default()
}),
..Default::default()
};
let c = s.effective_object_store_caps();
assert_eq!(c.result_output_mib, Some(1));
assert_eq!(c.app_packages_mib, Some(MAX_OBJECT_STORE_CAP_MIB));
}
#[test]
fn object_store_caps_round_trips_and_omits_when_unset() {
let s = ServerSettings {
object_store_caps: Some(ObjectStoreCaps {
scripts_mib: Some(512),
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"object_store_caps":{"scripts_mib":512}}"#);
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
assert!(
!serde_json::to_string(&ServerSettings::default())
.unwrap()
.contains("object_store_caps")
);
}
}