Skip to main content

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;