MessageWeave/src/config.rs

612 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义与 Redis 配置解析路径相互引用,拆出只增加跨文件跳转而不消除任何重复。
use secrecy::{ExposeSecret, SecretString};
use crate::error::BotError;
/// Returns the SHA-256 digest used for Redis admin-session records. The bearer token itself
/// never enters Redis (SAF-ADMIN-SESSION, C-REDIS-ONLY-STATE).
pub(crate) fn session_digest(token: &str) -> String {
let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
digest
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub fn encryption_key_from_env() -> Result<[u8; 32], BotError> {
let value = std::env::var("CONFIG_ENCRYPTION_KEY")
.map_err(|_| BotError::Config("missing CONFIG_ENCRYPTION_KEY".into()))?;
if value.len() != 64 {
return Err(BotError::Config(
"CONFIG_ENCRYPTION_KEY must be 32-byte hex".into(),
));
}
let mut key = [0_u8; 32];
for (index, slot) in key.iter_mut().enumerate() {
*slot = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
.map_err(|_| BotError::Config("CONFIG_ENCRYPTION_KEY must be hex".into()))?;
}
Ok(key)
}
/// Default display zone for notification timestamps.
pub fn default_timezone() -> String {
"Asia/Shanghai".into()
}
/// The only zones this build can render. Each is a fixed-offset zone with no
/// daylight-saving transitions, so a stored offset is correct year round.
///
/// Only zones with a permanent offset are listed. The IANA database is not
/// available in this build (no `chrono-tz`), so a DST zone would silently
/// report the wrong wall clock time for part of the year. Adding one here is
/// therefore a silent-correctness bug, not a feature.
pub(crate) const SUPPORTED_TIMEZONES: &[(&str, &str, &str)] = &[
("Etc/UTC", "UTC", "+00:00"),
("Africa/Cairo", "埃及开罗", "+02:00"),
("Europe/Istanbul", "土耳其伊斯坦布尔", "+03:00"),
("Africa/Nairobi", "肯尼亚内罗毕", "+03:00"),
("Asia/Dubai", "阿联酋迪拜", "+04:00"),
("Asia/Karachi", "巴基斯坦卡拉奇", "+05:00"),
("Asia/Kolkata", "印度加尔各答", "+05:30"),
("Asia/Bangkok", "泰国曼谷", "+07:00"),
("Asia/Ho_Chi_Minh", "越南河内", "+07:00"),
("Asia/Shanghai", "中国上海", "+08:00"),
("Asia/Hong_Kong", "中国香港", "+08:00"),
("Asia/Taipei", "中国台北", "+08:00"),
("Asia/Singapore", "新加坡", "+08:00"),
("Asia/Manila", "菲律宾马尼拉", "+08:00"),
("Asia/Tokyo", "日本东京", "+09:00"),
("Asia/Seoul", "韩国首尔", "+09:00"),
];
/// Resolve one supported zone to its fixed offset, in seconds east of UTC.
/// Returns `None` when the name is not in the table.
pub(crate) fn resolve_timezone(iana: &str) -> Option<i32> {
SUPPORTED_TIMEZONES
.iter()
.find(|(name, _, _)| *name == iana)
.map(|(_, _, offset)| {
parse_offset_seconds(offset)
.expect("static offsets in SUPPORTED_TIMEZONES parse as ±HH:MM")
})
}
/// Parse a `±HH:MM` offset into seconds east of UTC. `None` for a malformed
/// string; the table entries are all well formed, so a failure here is a bug.
pub(crate) fn parse_offset_seconds(offset: &str) -> Option<i32> {
let sign = if offset.starts_with('-') {
-1_i32
} else if offset.starts_with('+') {
1_i32
} else {
return None;
};
let rest = &offset[1..];
let (hours, minutes) = rest.split_once(':')?;
let hours: i32 = hours.parse().ok()?;
let minutes: i32 = minutes.parse().ok()?;
if hours > 23 || minutes > 59 {
return None;
}
Some(sign * (hours * 3600 + minutes * 60))
}
/// Default notification display zone, as a fixed offset in seconds east of UTC.
pub(crate) fn default_timezone_offset() -> i32 {
resolve_timezone(&default_timezone()).expect("the default zone is in the table")
}
/// Fixed offset for a zone already accepted by `TryFrom<BusinessConfigWire>`.
///
/// Configs only reach a worker after the zone name was validated against the table,
/// so the fallback is unreachable; it keeps the helper infallible rather than panicking
/// in a worker construction path.
pub(crate) fn timezone_offset_of(zone: &str) -> i32 {
resolve_timezone(zone).unwrap_or_else(default_timezone_offset)
}
/// Runtime-only settings. SecretString prevents accidental formatting/logging of credentials.
/// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly.
pub struct Config {
pub telegram: TelegramConfig,
pub jmap: JmapConfig,
pub account_id: Option<String>,
pub llm: LlmConfig,
pub auth: AuthSecrets,
pub worker_token: SecretString,
/// Display zone mirrored from `BusinessConfig`; the worker renders timestamps in it.
pub timezone: String,
}
/// Runtime-only endpoint credentials; never format or log these values.
/// C-NO-SECRET-IN-IMAGE and the stage-1 R1 boundary require process injection.
#[derive(Clone)]
pub struct AuthSecrets {
pub reconcile_token: SecretString,
pub telegram_webhook_secret: SecretString,
}
pub struct TelegramConfig {
pub bot_token: SecretString,
pub chat_allowlist: Vec<i64>,
pub chat_id: i64,
}
pub struct JmapConfig {
pub session_url: String,
pub username: String,
pub app_password: SecretString,
}
pub struct LlmConfig {
pub enabled: bool,
pub allow_net: bool,
pub api_key: Option<SecretString>,
pub base_url: Option<String>,
pub model: Option<String>,
pub summary_target_chars: usize,
}
/// All business configuration persisted below the Redis `config:business` namespace.
/// Secrets intentionally have no `Debug` implementation and are never returned by the
/// public configuration API (C-REDIS-ONLY-STATE, SAF-NO-SECRET-ECHO).
#[derive(Clone)]
pub struct BusinessConfig {
pub bot_token: SecretString,
pub telegram_chat_id: i64,
pub chat_allowlist: Vec<i64>,
pub telegram_webhook_secret: SecretString,
/// Display zone for notification timestamps; always one of SUPPORTED_TIMEZONES.
pub timezone: String,
pub jmap_session_url: String,
pub jmap_username: String,
pub jmap_password: SecretString,
pub account_id: Option<String>,
pub llm_enabled: bool,
pub llm_allow_net: bool,
pub llm_api_key: Option<SecretString>,
pub llm_base_url: Option<String>,
pub llm_model: Option<String>,
pub reconcile_token: SecretString,
pub worker_token: SecretString,
}
/// Redis wire representation. This type is private to the persistence adapter; callers must
/// never serialize it into an HTTP response (SAF-NO-SECRET-ECHO).
#[derive(Clone, serde::Deserialize, serde::Serialize)]
pub(crate) struct BusinessConfigWire {
pub bot_token: String,
pub telegram_chat_id: i64,
pub chat_allowlist: Vec<i64>,
pub telegram_webhook_secret: String,
#[serde(default = "default_timezone")]
pub timezone: String,
pub jmap_session_url: String,
pub jmap_username: String,
pub jmap_password: String,
#[serde(default)]
pub account_id: Option<String>,
pub llm_enabled: bool,
pub llm_allow_net: bool,
pub llm_api_key: Option<String>,
pub llm_base_url: Option<String>,
pub llm_model: Option<String>,
pub reconcile_token: String,
pub worker_token: String,
}
impl From<BusinessConfig> for BusinessConfigWire {
fn from(value: BusinessConfig) -> Self {
Self {
bot_token: value.bot_token.expose_secret().to_owned(),
telegram_chat_id: value.telegram_chat_id,
chat_allowlist: value.chat_allowlist,
telegram_webhook_secret: value.telegram_webhook_secret.expose_secret().to_owned(),
timezone: value.timezone,
jmap_session_url: value.jmap_session_url,
jmap_username: value.jmap_username,
jmap_password: value.jmap_password.expose_secret().to_owned(),
account_id: value.account_id,
llm_enabled: value.llm_enabled,
llm_allow_net: value.llm_allow_net,
llm_api_key: value.llm_api_key.map(|s| s.expose_secret().to_owned()),
llm_base_url: value.llm_base_url,
llm_model: value.llm_model,
reconcile_token: value.reconcile_token.expose_secret().to_owned(),
worker_token: value.worker_token.expose_secret().to_owned(),
}
}
}
impl TryFrom<BusinessConfigWire> for BusinessConfig {
type Error = BotError;
fn try_from(value: BusinessConfigWire) -> Result<Self, Self::Error> {
validate_nonblank("BOT_TOKEN", &value.bot_token)?;
validate_nonblank("JMAP_SESSION_URL", &value.jmap_session_url)?;
validate_nonblank("JMAP_USERNAME", &value.jmap_username)?;
validate_nonblank("JMAP_PASSWORD", &value.jmap_password)?;
validate_nonblank("TG_WEBHOOK_SECRET", &value.telegram_webhook_secret)?;
validate_nonblank("RECONCILE_TOKEN", &value.reconcile_token)?;
validate_nonblank("WORKER_TOKEN", &value.worker_token)?;
if resolve_timezone(&value.timezone).is_none() {
return Err(BotError::Config(
"TIMEZONE must be one of the supported time zones".into(),
));
}
if let Some(key) = value.llm_api_key.as_deref() {
validate_nonblank("LLM_API_KEY", key)?;
}
Ok(Self {
bot_token: SecretString::new(value.bot_token),
telegram_chat_id: value.telegram_chat_id,
chat_allowlist: value.chat_allowlist,
telegram_webhook_secret: SecretString::new(value.telegram_webhook_secret),
jmap_session_url: value.jmap_session_url,
jmap_username: value.jmap_username,
jmap_password: SecretString::new(value.jmap_password),
account_id: value.account_id,
llm_enabled: value.llm_enabled,
llm_allow_net: value.llm_allow_net,
llm_api_key: value.llm_api_key.map(SecretString::new),
llm_base_url: value.llm_base_url,
llm_model: value.llm_model,
reconcile_token: SecretString::new(value.reconcile_token),
worker_token: SecretString::new(value.worker_token),
timezone: value.timezone,
})
}
}
pub(crate) fn validate_business_wire(wire: BusinessConfigWire) -> Result<(), BotError> {
let config: BusinessConfig = wire.try_into()?;
if config.chat_allowlist.is_empty() {
// SAF-CHAT-ALLOWLIST: 必填,处理前先拒绝非白名单;空名单是非法配置而非 fail-open。
return Err(BotError::Config(
"CHAT_ALLOWLIST must contain at least one chat id".into(),
));
}
let session = url::Url::parse(&config.jmap_session_url)
.map_err(|_| BotError::Config("JMAP_SESSION_URL must be a valid URL".into()))?;
if session.scheme() != "https"
|| session.host_str().is_none()
|| session.username() != ""
|| session.password().is_some()
|| session.query().is_some()
|| session.fragment().is_some()
{
return Err(BotError::Config(
"JMAP_SESSION_URL must be HTTPS without credentials or query".into(),
));
}
if config.llm_enabled {
let base = config
.llm_base_url
.as_deref()
.ok_or_else(|| BotError::Config("LLM_BASE_URL required when LLM enabled".into()))?;
if config.llm_api_key.is_none() || config.llm_model.as_deref().is_none() {
return Err(BotError::Config(
"LLM credentials required when LLM enabled".into(),
));
}
let endpoint = url::Url::parse(base)
.map_err(|_| BotError::Config("LLM_BASE_URL must be a valid URL".into()))?;
if endpoint.scheme() != "https" {
return Err(BotError::Config("LLM_BASE_URL must use HTTPS".into()));
}
}
Ok(())
}
impl Config {
/// Configuration-only startup used before Redis bootstrap. No business route can pass
/// authentication with these empty sentinels; the process only serves SPA/bootstrap.
pub fn redis_only() -> Self {
let empty = || SecretString::new(String::new());
Self {
telegram: TelegramConfig {
bot_token: empty(),
chat_allowlist: Vec::new(),
chat_id: 0,
},
jmap: JmapConfig {
session_url: String::new(),
username: String::new(),
app_password: empty(),
},
account_id: None,
llm: LlmConfig {
enabled: false,
allow_net: false,
api_key: None,
base_url: None,
model: None,
summary_target_chars: 300,
},
auth: AuthSecrets {
reconcile_token: empty(),
telegram_webhook_secret: empty(),
},
worker_token: empty(),
timezone: default_timezone(),
}
}
/// Builds the runtime adapter configuration from the Redis business namespace.
/// This path is used after bootstrap and intentionally does not consult process env.
pub fn from_business(value: BusinessConfig) -> Self {
Self {
telegram: TelegramConfig {
bot_token: value.bot_token,
chat_allowlist: value.chat_allowlist,
chat_id: value.telegram_chat_id,
},
jmap: JmapConfig {
session_url: value.jmap_session_url,
username: value.jmap_username,
app_password: value.jmap_password,
},
account_id: value.account_id,
llm: LlmConfig {
enabled: value.llm_enabled,
allow_net: value.llm_allow_net,
api_key: value.llm_api_key,
base_url: value.llm_base_url,
model: value.llm_model,
summary_target_chars: 300,
},
auth: AuthSecrets {
reconcile_token: value.reconcile_token,
telegram_webhook_secret: value.telegram_webhook_secret,
},
worker_token: value.worker_token,
timezone: value.timezone,
}
}
pub fn from_business_json(value: serde_json::Value) -> Result<Self, BotError> {
let wire: BusinessConfigWire = serde_json::from_value(value)
.map_err(|_| BotError::Config("invalid Redis business configuration".into()))?;
Self::from_business_value(wire)
}
fn from_business_value(wire: BusinessConfigWire) -> Result<Self, BotError> {
Ok(Self::from_business(wire.try_into()?))
}
}
fn validate_nonblank(name: &str, value: &str) -> Result<(), BotError> {
if value.trim().is_empty() {
return Err(BotError::Config(format!("missing or blank {name}")));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blank_auth_secret_is_rejected_without_exposing_value() {
// 断言语义沿用原 required_secret 用例:错误信息点名字段,但绝不回显取值。
// required_secret 已随遗留 from_env 路径一并删除,改挂到 BusinessConfigWire
// 这条真实校验路径(validate_nonblank -> TryFrom<BusinessConfigWire>)。
const AUTH_SECRET_VALUE: &str = "s3cr3t-value-7f3a";
let wire = BusinessConfigWire {
bot_token: "bot".into(),
telegram_chat_id: 1,
chat_allowlist: vec![1],
telegram_webhook_secret: "hook".into(),
timezone: "Asia/Shanghai".into(),
jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(),
jmap_password: AUTH_SECRET_VALUE.into(),
account_id: None,
llm_enabled: false,
llm_allow_net: false,
llm_api_key: None,
llm_base_url: None,
llm_model: None,
reconcile_token: " \t".into(),
worker_token: "w".into(),
};
let error = match BusinessConfig::try_from(wire) {
Err(error) => error,
Ok(_) => panic!("blank secret must fail"),
};
let message = error.to_string();
assert!(message.contains("RECONCILE_TOKEN"));
assert!(!message.contains("secret"));
assert!(!message.contains(AUTH_SECRET_VALUE));
}
#[test]
fn empty_chat_allowlist_is_rejected() {
// SAF-CHAT-ALLOWLIST: 空名单是非法配置(Web 侧同样强制至少一项),
// 不得让绕过 SPA 的直接 PUT 静默写入"拒绝所有 chat"的配置。
let wire = BusinessConfigWire {
bot_token: "bot".into(),
telegram_chat_id: 1,
chat_allowlist: Vec::new(),
telegram_webhook_secret: "hook".into(),
timezone: "Asia/Shanghai".into(),
jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(),
jmap_password: "p".into(),
account_id: None,
llm_enabled: false,
llm_allow_net: false,
llm_api_key: None,
llm_base_url: None,
llm_model: None,
reconcile_token: "r".into(),
worker_token: "w".into(),
};
let error = validate_business_wire(wire).expect_err("empty allowlist must fail");
assert!(error.to_string().contains("CHAT_ALLOWLIST"));
}
#[test]
fn nonempty_chat_allowlist_passes() {
let wire = BusinessConfigWire {
bot_token: "bot".into(),
telegram_chat_id: 1,
chat_allowlist: vec![1, 2],
telegram_webhook_secret: "hook".into(),
timezone: "Asia/Shanghai".into(),
jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(),
jmap_password: "p".into(),
account_id: None,
llm_enabled: false,
llm_allow_net: false,
llm_api_key: None,
llm_base_url: None,
llm_model: None,
reconcile_token: "r".into(),
worker_token: "w".into(),
};
validate_business_wire(wire).expect("non-empty allowlist must pass");
}
#[test]
fn required_config_values_reject_blank_without_exposing_values() {
for name in [
"BOT_TOKEN",
"JMAP_SESSION_URL",
"JMAP_USERNAME",
"JMAP_PASSWORD",
"REDIS_URL",
] {
let error = validate_nonblank(name, " \t").expect_err("blank must fail");
assert_eq!(
error.to_string(),
format!("configuration error: missing or blank {name}")
);
}
}
/// A zone outside the table fails validation with the exact message the SPA
/// will surface.
#[test]
fn unsupported_timezone_is_rejected() {
let wire = BusinessConfigWire {
bot_token: "bot".into(),
telegram_chat_id: 1,
chat_allowlist: vec![1],
telegram_webhook_secret: "hook".into(),
timezone: "Europe/London".into(),
jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(),
jmap_password: "p".into(),
account_id: None,
llm_enabled: false,
llm_allow_net: false,
llm_api_key: None,
llm_base_url: None,
llm_model: None,
reconcile_token: "r".into(),
worker_token: "w".into(),
};
let error = validate_business_wire(wire).expect_err("unsupported zone must fail");
assert_eq!(
error.to_string(),
"configuration error: TIMEZONE must be one of the supported time zones"
);
}
/// Every listed zone must validate.
#[test]
fn supported_timezones_are_accepted() {
for (iana, _, offset) in SUPPORTED_TIMEZONES {
let wire = BusinessConfigWire {
bot_token: "bot".into(),
telegram_chat_id: 1,
chat_allowlist: vec![1],
telegram_webhook_secret: "hook".into(),
timezone: (*iana).to_owned(),
jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(),
jmap_password: "p".into(),
account_id: None,
llm_enabled: false,
llm_allow_net: false,
llm_api_key: None,
llm_base_url: None,
llm_model: None,
reconcile_token: "r".into(),
worker_token: "w".into(),
};
validate_business_wire(wire)
.unwrap_or_else(|e| panic!("{iana} ({offset}) must validate: {e:?}"));
}
assert_eq!(default_timezone(), "Asia/Shanghai");
assert_eq!(parse_offset_seconds("+08:00"), Some(8 * 3600));
assert_eq!(parse_offset_seconds("+05:30"), Some(5 * 3600 + 30 * 60));
assert_eq!(parse_offset_seconds("+00:00"), Some(0));
assert_eq!(parse_offset_seconds("-04:00"), Some(-4 * 3600));
assert!(parse_offset_seconds("not-an-offset").is_none());
}
/// Persisted configs written before this field existed must still load, with
/// the default zone applied rather than a deserialization error.
#[test]
fn missing_timezone_defaults_to_shanghai() {
let value: BusinessConfigWire = serde_json::from_value(serde_json::json!({
"bot_token": "bot",
"telegram_chat_id": 1,
"chat_allowlist": [1],
"telegram_webhook_secret": "hook",
"jmap_session_url": "https://example.invalid",
"jmap_username": "u",
"jmap_password": "p",
"llm_enabled": false,
"llm_allow_net": false,
"reconcile_token": "r",
"worker_token": "w",
}))
.expect("a config without timezone must deserialize");
assert_eq!(value.timezone, "Asia/Shanghai");
validate_business_wire(value.clone()).expect("a defaulted zone must validate");
let config: BusinessConfig = value.try_into().expect("a valid wire must convert");
assert_eq!(config.timezone, "Asia/Shanghai");
assert_eq!(
resolve_timezone(&config.timezone),
Some(default_timezone_offset())
);
}
#[test]
fn timezone_survives_the_wire_roundtrip() {
let config = BusinessConfig {
bot_token: secrecy::SecretString::new("bot".into()),
telegram_chat_id: 1,
chat_allowlist: vec![1],
telegram_webhook_secret: secrecy::SecretString::new("hook".into()),
timezone: "Asia/Kolkata".into(),
jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(),
jmap_password: secrecy::SecretString::new("p".into()),
account_id: None,
llm_enabled: false,
llm_allow_net: false,
llm_api_key: None,
llm_base_url: None,
llm_model: None,
reconcile_token: secrecy::SecretString::new("r".into()),
worker_token: secrecy::SecretString::new("w".into()),
};
let wire = BusinessConfigWire::from(config);
assert_eq!(wire.timezone, "Asia/Kolkata");
assert_eq!(
serde_json::to_value(&wire).unwrap()["timezone"],
"Asia/Kolkata"
);
}
}