futu_auth/store.rs
1//! KeyStore: keys.json 加载 + 热替换 + 明文验证
2
3mod lock;
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use arc_swap::ArcSwap;
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13
14use crate::key::{KeyRecord, hash_plaintext};
15use lock::AdvisoryLockGuard;
16
17#[derive(Debug, thiserror::Error)]
18#[non_exhaustive]
19pub enum KeyStoreError {
20 #[error("read {path:?}: {source}")]
21 Read {
22 path: PathBuf,
23 source: std::io::Error,
24 },
25 #[error("parse {path:?}: {source}")]
26 Parse {
27 path: PathBuf,
28 source: serde_json::Error,
29 },
30 #[error("write {path:?}: {source}")]
31 Write {
32 path: PathBuf,
33 source: std::io::Error,
34 },
35 #[error("serialize: {0}")]
36 Serialize(#[from] serde_json::Error),
37 #[error("unsupported keys.json version {0} (supported: 1)")]
38 UnsupportedVersion(u32),
39 #[error("duplicate key id {0:?}")]
40 DuplicateId(String),
41}
42
43/// keys.json 顶层文件结构
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct KeysFile {
46 pub version: u32,
47 pub keys: Vec<KeyRecord>,
48}
49
50const CURRENT_VERSION: u32 = 1;
51
52/// KeyStore:热可替换的 keys 集合
53#[derive(Debug)]
54pub struct KeyStore {
55 path: Option<PathBuf>,
56 current: ArcSwap<KeysFile>,
57 hash_index: ArcSwap<HashMap<String, Vec<KeyRecord>>>,
58}
59
60impl KeyStore {
61 /// 空 store(没有 keys 文件时)
62 pub fn empty() -> Self {
63 let file = KeysFile {
64 version: CURRENT_VERSION,
65 keys: vec![],
66 };
67 let hash_index = Self::build_hash_index(&file);
68 Self {
69 path: None,
70 current: ArcSwap::from_pointee(file),
71 hash_index: ArcSwap::from_pointee(hash_index),
72 }
73 }
74
75 /// 从文件加载
76 pub fn load(path: impl Into<PathBuf>) -> Result<Self, KeyStoreError> {
77 let path = path.into();
78 let file = Self::load_file(&path)?;
79 let hash_index = Self::build_hash_index(&file);
80 Ok(Self {
81 path: Some(path),
82 current: ArcSwap::from_pointee(file),
83 hash_index: ArcSwap::from_pointee(hash_index),
84 })
85 }
86
87 fn build_hash_index(file: &KeysFile) -> HashMap<String, Vec<KeyRecord>> {
88 let mut index: HashMap<String, Vec<KeyRecord>> = HashMap::with_capacity(file.keys.len());
89 for rec in &file.keys {
90 index.entry(rec.hash.clone()).or_default().push(rec.clone());
91 }
92 index
93 }
94
95 fn load_file(path: &Path) -> Result<KeysFile, KeyStoreError> {
96 if !path.exists() {
97 return Self::load_file_unlocked(path);
98 }
99 let _guard = AdvisoryLockGuard::acquire_shared(path)?;
100 Self::load_file_unlocked(path)
101 }
102
103 fn load_file_unlocked(path: &Path) -> Result<KeysFile, KeyStoreError> {
104 let text = fs::read_to_string(path).map_err(|source| KeyStoreError::Read {
105 path: path.to_path_buf(),
106 source,
107 })?;
108 let mut file: KeysFile =
109 serde_json::from_str(&text).map_err(|source| KeyStoreError::Parse {
110 path: path.to_path_buf(),
111 source,
112 })?;
113 if file.version != CURRENT_VERSION {
114 return Err(KeyStoreError::UnsupportedVersion(file.version));
115 }
116 // 检查重复 id
117 let mut seen = std::collections::HashSet::new();
118 for k in &file.keys {
119 if !seen.insert(k.id.clone()) {
120 return Err(KeyStoreError::DuplicateId(k.id.clone()));
121 }
122 }
123 // v1.4.104 external reviewer S-002 (P0) fix: load 时立即注入 fail-closed sentinel —
124 //
125 // 之前 expand_allowed_card_nums 只在 daemon 启动 + SIGHUP 跑, 但 MCP 等
126 // keystore consumer **不调** expand → 受 `allowed_card_nums` 限制的 key
127 // 加载后 `allowed_acc_ids = None`, 被限额引擎当作 "无限制" silent allow.
128 //
129 // **修法**: load_file 时对每条 key, 若 `allowed_card_nums` 非空但
130 // `allowed_acc_ids` 是 None / empty → 注入 sentinel `Some({0})`. 真实
131 // expansion (e.g. opend daemon GetAccList 之后) 会以 resolved acc_ids 覆盖.
132 // MCP 等不跑 expand 的消费方仍受 sentinel 保护 (fail-closed: real acc_id
133 // ≠ 0 → 永远 reject).
134 //
135 // 这是架构层 fix (与 v1.4.103 codex F1 P1 expand-time sentinel 同语义,
136 // 但提前到 load 时让所有 consumer 受益, 不依赖每个消费方都调 expand).
137 for rec in &mut file.keys {
138 // v1.4.106 F-P2-D: snapshot 文件源原始 allowed_acc_ids (sentinel
139 // 注入和 card_num expansion 之前). expand_allowed_card_nums 用
140 // 此字段作起步, 防止累积 stale resolutions.
141 rec.raw_explicit_acc_ids = rec.allowed_acc_ids.clone();
142
143 let has_card_nums = rec
144 .allowed_card_nums
145 .as_ref()
146 .is_some_and(|v| !v.is_empty());
147 let has_acc_ids = rec.allowed_acc_ids.as_ref().is_some_and(|s| !s.is_empty());
148 if has_card_nums && !has_acc_ids {
149 // 写 sentinel acc_id=0; expand_allowed_card_nums 后续会用
150 // resolved acc_ids 覆盖. 此期间任何 acc_id ≠ 0 的 query 全 reject.
151 let mut sentinel = rec.allowed_acc_ids.clone().unwrap_or_default();
152 sentinel.insert(0);
153 rec.allowed_acc_ids = Some(sentinel);
154 let key_id = crate::metrics::redact_key_id_for_logs(&rec.id);
155 let card_num_count = rec.allowed_card_nums.as_ref().map_or(0, Vec::len);
156 tracing::warn!(
157 key_id = %key_id,
158 card_num_count,
159 "v1.4.104 external report S-002 (P0): keystore load 注入 fail-closed sentinel \
160 allowed_acc_ids={{0}} (caller 配 allowed_card_nums 但 daemon 还没\
161 expand). expand_allowed_card_nums 跑完后真实 resolved acc_ids 覆盖. \
162 MCP / 不跑 expand 的 consumer 仍按 sentinel 保护."
163 );
164 }
165 }
166 Ok(file)
167 }
168
169 /// SIGHUP 热重载:用同一路径重新读文件
170 pub fn reload(&self) -> Result<(), KeyStoreError> {
171 let Some(path) = &self.path else {
172 return Ok(());
173 };
174 let file = Self::load_file(path)?;
175 let hash_index = Self::build_hash_index(&file);
176 self.current.store(Arc::new(file));
177 self.hash_index.store(Arc::new(hash_index));
178 Ok(())
179 }
180
181 /// v1.4.103 (B10): 把每条 key 的 `allowed_card_nums` (string format) 通过
182 /// `resolver` 解析成 acc_id, **合并**进 `allowed_acc_ids` (in-memory only,
183 /// 不写回 keys.json — 文件源不变, 重载后再 expand).
184 ///
185 /// `resolver(card_num) -> Vec<u64>` 由 caller 提供 (典型 closure 持
186 /// `Arc<TrdCache>` 调 `find_acc_ids_by_card_num`).
187 ///
188 /// **行为**:
189 /// - resolver 返 1 个 acc_id → 加入 allowed_acc_ids (resolved)
190 /// - 返 0 个 → 通过 `unresolved_callback` 通知 caller (e.g. log warn)
191 /// - 返 ≥ 2 个 → 通过 `ambiguous_callback` 通知 caller (loud, skip 该条)
192 ///
193 /// 返 `(resolved_count, unresolved_count, ambiguous_count)`.
194 ///
195 /// **典型调用 (daemon 启动 GetAccList 成功后)**:
196 /// ```ignore
197 /// let cache_clone = trd_cache.clone();
198 /// key_store.expand_allowed_card_nums(
199 /// |cn: &str| cache_clone.find_acc_ids_by_card_num(cn),
200 /// |key_id, cn| tracing::warn!(key_id, card_num=cn, "card_num not found"),
201 /// |key_id, cn, candidates| tracing::warn!(key_id, card_num=cn, ?candidates, "ambiguous card_num"),
202 /// );
203 /// ```
204 pub fn expand_allowed_card_nums<R, FU, FA>(
205 &self,
206 resolver: R,
207 mut unresolved_callback: FU,
208 mut ambiguous_callback: FA,
209 ) -> (usize, usize, usize)
210 where
211 R: Fn(&str) -> Vec<u64>,
212 FU: FnMut(&str, &str), // key_id, card_num
213 FA: FnMut(&str, &str, &[u64]), // key_id, card_num, candidates
214 {
215 let current = self.current.load();
216 let mut new_keys = current.keys.clone();
217 let mut resolved = 0;
218 let mut unresolved = 0;
219 let mut ambiguous = 0;
220 for rec in &mut new_keys {
221 let Some(card_nums) = rec.allowed_card_nums.clone() else {
222 continue;
223 };
224 // v1.4.106 F-P2-D: 从 raw_explicit_acc_ids 起步重新 resolve, 不
225 // 累积 stale resolutions. 之前 `rec.allowed_acc_ids.clone()` 起
226 // 步会让连续 expand 累积 — 若 keys.json 没动但 cache 里某 acc 不
227 // 再可见, 旧 resolved acc_id 仍留在 allowed set 中. 现在每次
228 // expand 都从 file 源原始集合重新计算. raw 为 None → 空集起步.
229 let mut acc_ids = rec.raw_explicit_acc_ids.clone().unwrap_or_default();
230 for cn in &card_nums {
231 let candidates = resolver(cn);
232 match candidates.len() {
233 0 => {
234 unresolved += 1;
235 unresolved_callback(&rec.id, cn);
236 }
237 1 => {
238 acc_ids.insert(candidates[0]);
239 resolved += 1;
240 }
241 _ => {
242 ambiguous += 1;
243 ambiguous_callback(&rec.id, cn, &candidates);
244 }
245 }
246 }
247 // v1.4.103 codex F1 (P1) fail-closed: 无论 acc_ids 是否非空, 都
248 // **必须** 写入 Some(...) — 哪怕是空 HashSet (= "denylist 全部",
249 // 限额引擎 step 0 acc_id 白名单非空 + 不含 ctx.acc_id → reject).
250 //
251 // 旧逻辑 (silent unrestricted): `if !acc_ids.is_empty() { rec.allowed_acc_ids = Some(acc_ids); }`
252 // 当 caller 配置 allowed_card_nums 但**全部 unresolved/ambiguous** 时,
253 // acc_ids 留空, allowed_acc_ids 仍 None → 限额引擎按 "无限制" 处理 →
254 // 受限 key silent unrestricted (反模式 D / pitfall #45).
255 //
256 // 新逻辑: 只要 caller 显式写了 allowed_card_nums (说明 *intent* 是限制),
257 // 就强制 Some(acc_ids) — 即便空集. 限额引擎检测到 Some(empty) 时
258 // 视为 "全 reject" (限额 step 0 `allowed.is_empty()` 已 short-circuit
259 // 不 reject, 但 contains check 永远 false → reject).
260 //
261 // **wait**: 看 limits.rs:332 `check_full_skip_rate`, step 0 是
262 // `if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id) && !allowed.is_empty() && !allowed.contains(&id)`.
263 // 关键: `!allowed.is_empty()` short-circuit empty set, 等于 "无限制".
264 // 这就是 silent-unrestricted 的根源. 但 `allowed.is_empty()` 短路是
265 // **故意的语义** (允许 None / 空集都视为 "不限制") — 改这个会破坏
266 // 现有 user contract.
267 //
268 // **正确修法**: 既然空集语义 = 无限制不能改, 我们要把 caller intent
269 // (allowed_card_nums 非空) → 限额能识别 "想限但无法 resolve" 的状态.
270 // 选: 把 sentinel acc_id (e.g. 0) 写入 acc_ids 触发 reject — 因为
271 // 没有真账户 acc_id == 0. 限额检查时 acc_ids = {0}, ctx.acc_id =
272 // <real id> ≠ 0 → reject. legitimate id 也 reject — 这是
273 // fail-closed 保守语义.
274 if !card_nums.is_empty() {
275 if acc_ids.is_empty() {
276 // 全部 unresolved/ambiguous → 写 sentinel 0 让限额 reject 一切
277 acc_ids.insert(0u64);
278 }
279 rec.allowed_acc_ids = Some(acc_ids);
280 } else if !acc_ids.is_empty() {
281 rec.allowed_acc_ids = Some(acc_ids);
282 }
283 }
284 let file = KeysFile {
285 version: current.version,
286 keys: new_keys,
287 };
288 let hash_index = Self::build_hash_index(&file);
289 self.current.store(Arc::new(file));
290 self.hash_index.store(Arc::new(hash_index));
291 (resolved, unresolved, ambiguous)
292 }
293
294 /// 明文校验:遍历所有未过期 key,匹配则返回 KeyRecord 快照
295 ///
296 /// 如果 key 设置了 `allowed_machines` 且本机不在白名单,会打 warn 日志并视为未匹配。
297 /// 这样做法的代价:攻击者可以通过"能不能过"侧信道区分 key 是否存在 — 我们接受,
298 /// 因为 plaintext 空间是 256 bit 随机 hex,侧信道没意义。
299 pub fn verify(&self, plaintext: &str) -> Option<Arc<KeyRecord>> {
300 if !KeyRecord::is_generated_plaintext_shape(plaintext) {
301 return None;
302 }
303 let computed_hash = hash_plaintext(plaintext);
304 let index = self.hash_index.load();
305 let now = Utc::now();
306 let candidates = index.get(&computed_hash)?;
307 for k in candidates {
308 if k.is_expired(now) {
309 continue;
310 }
311 if let Err(e) = k.check_machine() {
312 let key_id = crate::metrics::redact_key_id_for_logs(&k.id);
313 tracing::warn!(
314 key_id = %key_id,
315 error = %e,
316 "api key matched but machine binding failed; rejecting"
317 );
318 return None;
319 }
320 return Some(Arc::new(k.clone()));
321 }
322 None
323 }
324
325 /// 是否显式加载了 keys 文件。
326 ///
327 /// 注意:空 keys 文件也算 configured,用于让 surface 进入 scope mode
328 /// 并 fail-closed;只有 [`Self::empty`] 才代表 legacy/no-key 模式。
329 #[must_use]
330 pub fn is_configured(&self) -> bool {
331 self.path.is_some()
332 }
333
334 pub fn path(&self) -> Option<&Path> {
335 self.path.as_deref()
336 }
337
338 #[must_use]
339 pub fn len(&self) -> usize {
340 self.current.load().keys.len()
341 }
342
343 #[must_use]
344 pub fn is_empty(&self) -> bool {
345 self.len() == 0
346 }
347
348 /// v1.4.105 external reviewer #4 fix: 当前 KeyStore 是否有任意 key 配置了
349 /// `allowed_card_nums` 限制. 用于 standalone MCP / gRPC / 任何不持
350 /// `TrdCache` 的 keystore consumer 在启动时判断:
351 /// - `false` → 没有 card_num 限制, 跳过 daemon `GetAccList` + expand 全流程
352 /// (避免无意义的 daemon 请求)
353 /// - `true` → 必须连 daemon, 调 `GetAccList`, 通过
354 /// [`Self::expand_allowed_card_nums`] 把 card_num resolve 成 acc_id;
355 /// 否则 fail-closed sentinel `{0}` 会让所有真账户 reject (external reviewer BUG
356 /// v1.4.104-002: standalone MCP 漏调 expand 导致 `0757` 配置的 key
357 /// 全 reject).
358 ///
359 /// 注意: 本方法只检查 raw `allowed_card_nums` 是否非空 — load_file 阶段
360 /// 注入的 sentinel `allowed_acc_ids = {0}` **不**算"已 expand"; 只有
361 /// caller 真跑过 [`Self::expand_allowed_card_nums`] 后才会用 resolved
362 /// acc_ids 覆盖 sentinel.
363 #[must_use]
364 pub fn has_any_card_num_restrictions(&self) -> bool {
365 self.current
366 .load()
367 .keys
368 .iter()
369 .any(|k| k.allowed_card_nums.as_ref().is_some_and(|v| !v.is_empty()))
370 }
371
372 /// 按 id 查询当前快照中的 key(**不做 expiry / machine 校验**,调用方自己做)
373 ///
374 /// 典型用法:MCP 在启动时 `verify(plaintext)` 拿到 id,后续每个请求用
375 /// `get_by_id` 取最新记录,这样 SIGHUP 重载 keys.json 后 scope / 限额 /
376 /// expires_at 的变更能立刻生效(不用重启进程)。
377 ///
378 /// 返回 None 表示 id 在当前文件里不存在(被 remove_key 删掉了),调用方应
379 /// 视为"key 已吊销"直接拒绝。
380 ///
381 /// **注意**:此方法**不做 machine binding 校验**。对于跨 SIGHUP 的 per-msg /
382 /// per-tool 复检场景应改用 [`Self::get_by_id_for_current_machine`],确保
383 /// SIGHUP 后新加的 `allowed_machines` 限制立即生效(避免 startup 验过 →
384 /// SIGHUP 收紧 → 仍按老 record 放行的语义漂移)。
385 pub fn get_by_id(&self, id: &str) -> Option<Arc<KeyRecord>> {
386 let snap = self.current.load_full();
387 snap.keys
388 .iter()
389 .find(|k| k.id == id)
390 .map(|k| Arc::new(k.clone()))
391 }
392
393 /// 按 id 查询当前快照中的 key + 立即校验本机 machine binding。
394 ///
395 /// **统一生命周期入口**: 任何 surface (WS / MCP / REST / gRPC) 在
396 /// 已 verify-once → 跨 SIGHUP 复检场景下应使用此方法替代裸 [`Self::get_by_id`],
397 /// 避免如下漂移:
398 ///
399 /// - startup 时 `verify(plaintext)` 检查 machine binding ✅
400 /// - SIGHUP reload 把该 key 的 `allowed_machines` 收紧(移除本机指纹)
401 /// - 后续 per-msg / per-tool 仅调 `get_by_id` → **绕过 machine binding** →
402 /// silent unrestricted (反模式 D / pitfall #45 silent-success 同模式)
403 ///
404 /// 行为:
405 /// - id 不存在 → `None`(key 已被 remove_key 吊销,caller 视为吊销拒绝)
406 /// - id 存在 + machine 校验通过 → `Some(rec)`
407 /// - id 存在 + machine 校验失败 → `None` + warn log(与 `verify` 同语义)
408 ///
409 /// **不做 expiry 校验** —— pipeline.rs Step 1.5 / caller 自己做(与
410 /// `get_by_id` 行为对齐,仅差 machine 一层)。
411 pub fn get_by_id_for_current_machine(&self, id: &str) -> Option<Arc<KeyRecord>> {
412 let rec = self.get_by_id(id)?;
413 if let Err(e) = rec.check_machine() {
414 let key_id = crate::metrics::redact_key_id_for_logs(&rec.id);
415 tracing::warn!(
416 key_id = %key_id,
417 error = %e,
418 "api key get_by_id_for_current_machine: machine binding failed; \
419 treating as revoked (caller should reject as if key not found)"
420 );
421 return None;
422 }
423 Some(rec)
424 }
425
426 /// 导出当前所有 keys 的 id(用于调试 / 审计)
427 #[must_use]
428 pub fn ids(&self) -> Vec<String> {
429 self.current
430 .load()
431 .keys
432 .iter()
433 .map(|k| k.id.clone())
434 .collect()
435 }
436}
437
438/// 追加一条新 key 到 keys.json(atomic rename)
439/// v1.4.106 codex 0558 F5 (P2): RMW (read-modify-write) helper that holds the
440/// flock for the **entire** sequence — load, mutate, write. Without this,
441/// concurrent append_key callers can load → load → write → write and lose
442/// the first writer's record.
443fn with_keys_lock<F, R>(path: &Path, f: F) -> Result<R, KeyStoreError>
444where
445 F: FnOnce(&Path) -> Result<R, KeyStoreError>,
446{
447 if let Some(parent) = path.parent()
448 && !parent.as_os_str().is_empty()
449 {
450 fs::create_dir_all(parent).map_err(|source| KeyStoreError::Write {
451 path: parent.to_path_buf(),
452 source,
453 })?;
454 }
455 let _guard = AdvisoryLockGuard::acquire_exclusive(path)?;
456 f(path)
457}
458
459pub fn append_key(path: &Path, new_record: KeyRecord) -> Result<(), KeyStoreError> {
460 with_keys_lock(path, |path| {
461 let mut file = match fs::metadata(path) {
462 Ok(_) => KeyStore::load_file_unlocked(path)?,
463 Err(_) => KeysFile {
464 version: CURRENT_VERSION,
465 keys: vec![],
466 },
467 };
468 if file.keys.iter().any(|k| k.id == new_record.id) {
469 return Err(KeyStoreError::DuplicateId(new_record.id));
470 }
471 file.keys.push(new_record);
472 write_atomic_inner(path, &file)
473 })
474}
475
476/// 读取 keys.json 并返回所有记录快照(展示用;不暴露 hash 以外的敏感位)
477pub fn list_keys(path: &Path) -> Result<Vec<KeyRecord>, KeyStoreError> {
478 let file = KeyStore::load_file(path)?;
479 Ok(file.keys)
480}
481
482/// 按 id 编辑一条 key(atomic rename);闭包返回 `false` 代表未改动 → 跳过落盘
483///
484/// 适用于就地修改 `allowed_machines` / `expires_at` / `note` 等配置,
485/// 而不想走 "revoke + regen" 流程(否则 plaintext 会换)。
486pub fn update_key<F>(path: &Path, id: &str, mutate: F) -> Result<bool, KeyStoreError>
487where
488 F: FnOnce(&mut KeyRecord) -> bool,
489{
490 // v1.4.106 F5: 整个 RMW 在 flock 内, 防 concurrent update_key 互相覆盖
491 with_keys_lock(path, |path| {
492 let mut file = KeyStore::load_file_unlocked(path)?;
493 let Some(rec) = file.keys.iter_mut().find(|k| k.id == id) else {
494 return Ok(false);
495 };
496 let changed = mutate(rec);
497 if changed {
498 write_atomic_inner(path, &file)?;
499 }
500 Ok(changed)
501 })
502}
503
504/// 按 id 删除一条 key(atomic rename);返回是否真的删掉了一条
505pub fn remove_key(path: &Path, id: &str) -> Result<bool, KeyStoreError> {
506 // v1.4.106 F5: 整个 RMW 在 flock 内
507 with_keys_lock(path, |path| {
508 let mut file = KeyStore::load_file_unlocked(path)?;
509 let before = file.keys.len();
510 file.keys.retain(|k| k.id != id);
511 let removed = before != file.keys.len();
512 if removed {
513 write_atomic_inner(path, &file)?;
514 }
515 Ok(removed)
516 })
517}
518
519/// v1.4.106 codex 0558 F5 (P2): atomic write with advisory flock + unique
520/// tempfile + fsync.
521///
522/// **背景**: 之前的 write_atomic 有 3 个问题:
523/// 1. `tmp = path.with_extension("json.tmp")` — 同 path **每个 process 共
524/// 享**, 并发写 (daemon + futucli + multiple admin reload) 会互相覆写
525/// tempfile, 最后 rename 时数据交错.
526/// 2. **无 advisory flock** — 没有跨 process coordination, race 可让 reader
527/// 看到部分写完的 `keys.json` (rename 前如果别的 process 也在 truncate).
528/// 3. **无 fsync** — 写完立刻 rename 在 ext4 数据=writeback 模式 / SSD power
529/// loss 下可能丢内容 (file 在 inode 层面存在但 data block 没 flush).
530///
531/// **修法**: 1) tempfile 名带 pid + nanos: `keys.json.<pid>.<nanos>.tmp` — race-free.
532/// 2) 对 `keys.json.lock` (sibling lock file) 取 LOCK_EX flock; 读路径
533/// (load_file) 取 LOCK_SH; RMW 调用方在已经持有 LOCK_EX 时走
534/// `load_file_unlocked` 避免同线程嵌套锁. 3) tempfile open 后 write_all →
535/// sync_all → close → set_permissions → rename → 持有 lock 期间.
536///
537/// v1.4.106 codex 0558 F5: 写盘只做 unique tempfile + fsync + rename. flock 由
538/// caller (with_keys_lock) 在 RMW 范围统一持有, 这里**不再**单独加锁 (避免
539/// 与 with_keys_lock 重入).
540fn write_atomic_inner(path: &Path, file: &KeysFile) -> Result<(), KeyStoreError> {
541 let text = serde_json::to_string_pretty(file)?;
542
543 // v1.4.106 F5: unique tempfile (pid + nanos), 防 concurrent rename 战 tempfile.
544 let nanos = keystore_tempfile_nanos_or_zero();
545 let tmp_name = match path.file_name().and_then(|n| n.to_str()) {
546 Some(name) => format!(
547 "{name}.{pid}.{nanos}.tmp",
548 pid = std::process::id(),
549 nanos = nanos,
550 ),
551 None => format!(
552 "keys.{pid}.{nanos}.tmp",
553 pid = std::process::id(),
554 nanos = nanos,
555 ),
556 };
557 let tmp = path
558 .parent()
559 .map(|p| p.join(&tmp_name))
560 .unwrap_or_else(|| Path::new(&tmp_name).to_path_buf());
561
562 // 写入 tempfile + fsync — 0600 mode 通过 OpenOptions (Unix) 创建时即生效.
563 use std::io::Write;
564 #[cfg(unix)]
565 let mut f = {
566 use std::os::unix::fs::OpenOptionsExt;
567 fs::OpenOptions::new()
568 .create_new(true)
569 .write(true)
570 .mode(0o600)
571 .open(&tmp)
572 .map_err(|source| KeyStoreError::Write {
573 path: tmp.clone(),
574 source,
575 })?
576 };
577 #[cfg(not(unix))]
578 let mut f = fs::OpenOptions::new()
579 .create_new(true)
580 .write(true)
581 .open(&tmp)
582 .map_err(|source| KeyStoreError::Write {
583 path: tmp.clone(),
584 source,
585 })?;
586
587 let write_res = f
588 .write_all(text.as_bytes())
589 .and_then(|_| f.sync_all())
590 .map_err(|source| KeyStoreError::Write {
591 path: tmp.clone(),
592 source,
593 });
594 drop(f);
595
596 if let Err(e) = write_res {
597 if let Err(cleanup_err) = fs::remove_file(&tmp) {
598 tracing::debug!(
599 path = %tmp.display(),
600 error = %cleanup_err,
601 "keystore atomic write failed; tempfile cleanup also failed"
602 );
603 }
604 return Err(e);
605 }
606
607 // 防御 chmod (Unix), OpenOptions.mode 已 0600, 但部分 fs / umask 异常时兜底.
608 #[cfg(unix)]
609 {
610 use std::os::unix::fs::PermissionsExt;
611 if let Err(err) = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)) {
612 tracing::warn!(
613 path = %tmp.display(),
614 error = %err,
615 "keystore tempfile chmod 0600 failed"
616 );
617 }
618 }
619
620 // atomic rename → tempfile 替换 inode, reader 看到的永远是完整 file.
621 fs::rename(&tmp, path).map_err(|source| KeyStoreError::Write {
622 path: path.to_path_buf(),
623 source,
624 })?;
625
626 Ok(())
627}
628
629fn keystore_tempfile_nanos_or_zero() -> u128 {
630 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
631 Ok(duration) => duration.as_nanos(),
632 Err(err) => {
633 tracing::warn!(
634 error = ?err,
635 "keystore wall clock is before UNIX_EPOCH; falling back to zero tempfile timestamp"
636 );
637 0
638 }
639 }
640}
641
642#[cfg(test)]
643mod tests;