use crate::error::EvalError;
use crate::source::{
ByteEnc, CipherAlgo, CipherMode, CipherOp, CipherStep, HashAlgo, HashOut, HashStep, Padding,
};
use crate::transform;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SourceState {
pub kv: BTreeMap<String, String>,
pub variable: String,
pub login_header: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub login_info: Option<String>,
pub cookies: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expire_at: Option<u64>,
}
impl SourceState {
pub fn load(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let json = serde_json::to_string_pretty(self).unwrap_or_default();
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
f.write_all(json.as_bytes())?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, json)
}
}
pub fn set_login_info(&mut self, plain: &str) -> Result<(), EvalError> {
let key = machine_key()?;
let iv = random_iv();
let ct = cipher_with(plain, &key, &hex::encode(iv), CipherOp::Encrypt)?;
use base64::Engine as _;
let iv_b64 = base64::engine::general_purpose::STANDARD.encode(iv);
self.login_info = Some(format!("{iv_b64}:{ct}"));
Ok(())
}
pub fn get_login_info(&self) -> Result<Option<String>, EvalError> {
let Some(stored) = &self.login_info else {
return Ok(None);
};
let key = machine_key()?;
let (iv_hex, ct) = match stored.split_once(':') {
Some((iv_b64, ct)) => {
use base64::Engine as _;
let iv = base64::engine::general_purpose::STANDARD
.decode(iv_b64)
.map_err(|e| EvalError::Crypto(format!("login_info IV 解码失败: {e}")))?;
(hex::encode(iv), ct)
}
None => (legacy_machine_iv()?, stored.as_str()),
};
Ok(Some(cipher_with(ct, &key, &iv_hex, CipherOp::Decrypt)?))
}
pub fn is_login_expired(&self) -> bool {
self.expire_at.is_some_and(|exp| now_secs() >= exp)
}
pub fn clear_login(&mut self) {
self.login_header.clear();
self.login_info = None;
self.cookies.clear();
self.expire_at = None;
}
pub fn purge_if_expired(&mut self) -> bool {
if self.is_login_expired() {
self.clear_login();
true
} else {
false
}
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn machine_key() -> Result<String, EvalError> {
let id = machine_uid::get().map_err(|e| EvalError::Crypto(format!("machine-uid: {e}")))?;
derive_key(checked_machine_id(&id)?)
}
fn legacy_machine_iv() -> Result<String, EvalError> {
let id = machine_uid::get().map_err(|e| EvalError::Crypto(format!("machine-uid: {e}")))?;
derive_legacy_iv(checked_machine_id(&id)?)
}
fn random_iv() -> [u8; 16] {
use aes_gcm::aead::rand_core::RngCore;
let mut iv = [0u8; 16];
aes_gcm::aead::OsRng.fill_bytes(&mut iv);
iv
}
fn checked_machine_id(id: &str) -> Result<&str, EvalError> {
let trimmed = id.trim();
if trimmed.trim_matches(['0', '-', ':']).len() < 8 {
return Err(EvalError::Crypto(
"machine-uid 为空/平凡值,拒绝以公开可计算的密钥加密凭据".into(),
));
}
Ok(trimmed)
}
fn derive_key(id: &str) -> Result<String, EvalError> {
transform::hash(id, &hashstep(HashAlgo::Sha256)) }
fn derive_legacy_iv(id: &str) -> Result<String, EvalError> {
transform::hash(id, &hashstep(HashAlgo::Md5)) }
fn hashstep(algo: HashAlgo) -> HashStep {
HashStep {
algo,
output: HashOut::Hex,
hmac_key: None,
hmac_key_enc: ByteEnc::Utf8,
}
}
fn cipher_with(s: &str, key_hex: &str, iv_hex: &str, op: CipherOp) -> Result<String, EvalError> {
transform::cipher(
s,
&CipherStep {
algo: CipherAlgo::Aes,
mode: CipherMode::Cbc,
padding: Padding::Pkcs7,
op,
key: key_hex.to_string(),
key_enc: ByteEnc::Hex,
iv: Some(iv_hex.to_string()),
iv_enc: ByteEnc::Hex,
input_enc: None, output_enc: None, },
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_json_round_trip() {
let mut s = SourceState::default();
s.kv.insert("token".into(), "abc".into());
s.login_header
.insert("Authorization".into(), "Bearer xyz".into());
s.cookies.insert("site.com".into(), "sid=1".into());
s.variable = "user-cfg".into();
let json = serde_json::to_string(&s).unwrap();
let back: SourceState = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn login_info_encrypt_round_trip() {
let key = derive_key("fixed-machine-id").unwrap();
let iv = derive_legacy_iv("fixed-machine-id").unwrap();
let plain = r#"{"user":"alice","pass":"secret密码"}"#;
let ct = cipher_with(plain, &key, &iv, CipherOp::Encrypt).unwrap();
assert_ne!(ct, plain, "应已加密");
let back = cipher_with(&ct, &key, &iv, CipherOp::Decrypt).unwrap();
assert_eq!(back, plain);
}
#[test]
fn login_info_random_iv_nondeterministic() {
let mut s1 = SourceState::default();
let mut s2 = SourceState::default();
s1.set_login_info("same-plain").unwrap();
s2.set_login_info("same-plain").unwrap();
assert_ne!(
s1.login_info, s2.login_info,
"随机 IV 下同明文两次加密的密文应不同"
);
assert!(
s1.login_info.as_deref().unwrap().contains(':'),
"新格式应为 base64(iv):base64(密文)"
);
assert_eq!(s1.get_login_info().unwrap().as_deref(), Some("same-plain"));
}
#[test]
fn login_info_legacy_format_still_decrypts() {
let key = machine_key().unwrap();
let iv = legacy_machine_iv().unwrap();
let ct = cipher_with("old-secret", &key, &iv, CipherOp::Encrypt).unwrap();
assert!(!ct.contains(':'), "旧格式(base64 密文)不含分隔符");
let s = SourceState {
login_info: Some(ct),
..Default::default()
};
assert_eq!(s.get_login_info().unwrap().as_deref(), Some("old-secret"));
}
#[test]
fn checked_machine_id_rejects_empty_and_trivial() {
assert!(checked_machine_id("").is_err());
assert!(checked_machine_id(" ").is_err());
assert!(checked_machine_id("00000000-0000-0000").is_err());
assert!(checked_machine_id("abc").is_err());
assert_eq!(
checked_machine_id(" 7f3e9c2a-1b4d-4e8f-9a0c-d5e6f7a8b9c0 ").unwrap(),
"7f3e9c2a-1b4d-4e8f-9a0c-d5e6f7a8b9c0"
);
}
#[test]
fn load_missing_returns_default() {
let s = SourceState::load(Path::new("/nonexistent/trnovel/xyz.json"));
assert_eq!(s, SourceState::default());
}
#[test]
fn ttl_purges_expired_login() {
let mut s = SourceState::default();
s.login_header
.insert("Authorization".into(), "Bearer x".into());
s.cookies.insert("site.com".into(), "sid=1".into());
s.kv.insert("keep".into(), "me".into());
s.expire_at = Some(now_secs() + 3600);
assert!(!s.is_login_expired());
assert!(!s.purge_if_expired());
assert!(!s.login_header.is_empty());
s.expire_at = Some(now_secs().saturating_sub(1));
assert!(s.is_login_expired());
assert!(s.purge_if_expired());
assert!(s.login_header.is_empty());
assert!(s.login_info.is_none());
assert!(s.cookies.is_empty());
assert_eq!(s.expire_at, None);
assert_eq!(
s.kv.get("keep").map(String::as_str),
Some("me"),
"kv 应保留"
);
}
#[cfg(unix)]
#[test]
fn save_writes_0600_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("trnovel-state-perm-test");
let path = dir.join("s.json");
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(&path, "{}");
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
let mut s = SourceState::default();
s.login_header
.insert("Authorization".into(), "Bearer x".into());
s.save(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "凭据状态文件应以 0600 落盘");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_then_load_file() {
let dir = std::env::temp_dir().join("trnovel-state-test");
let path = dir.join("s.json");
let mut s = SourceState::default();
s.kv.insert("k".into(), "v".into());
s.save(&path).unwrap();
let back = SourceState::load(&path);
assert_eq!(back.kv.get("k").map(String::as_str), Some("v"));
let _ = std::fs::remove_dir_all(&dir);
}
}