Skip to main content

omy_core/
fsatomic.rs

1//! 原子文件写入与临时明文文件的生命周期管理。
2//!
3//! # 为什么需要这个模块
4//!
5//! 加密写入若在中途崩溃或断电,可能留下一个**看似完整实则损坏**的 `.omy` 文件——
6//! 头部已落盘、载荷只写了一半,用户以为加密成功并删除了原文件,数据就永久丢失了。
7//!
8//! 本模块实现规范 §10 的五步流程:
9//!
10//! ```text
11//! 1. 写入 <target>.<pid>.<nonce>.tmp
12//! 2. fsync(文件)
13//! 3. fsync(父目录)          ← 容易遗漏,但在部分文件系统上必需
14//! 4. rename(tmp → target)   ← 同文件系统内原子
15//! 5. fsync(父目录)
16//! ```
17//!
18//! # 关于父目录 fsync
19//!
20//! 只 fsync 文件本身是**不够的**:文件数据虽已落盘,但「目录里存在这个文件名」
21//! 这条元数据可能仍在缓存中。断电后可能出现文件内容完好但目录项丢失的情况。
22//! 在 ext4 的 `data=ordered` 模式、XFS、Btrfs 上这都是真实风险。
23//!
24//! Windows 无法直接 fsync 目录,NTFS 的 rename 本身是有日志的元数据事务,
25//! 因此该步在 Windows 上跳过(见 [`sync_dir`])。
26//!
27//! # 临时明文文件(旁路 L2/L3/L5)
28//!
29//! [`TempPlaintext`] 用于「用外部应用打开」场景。它必须处理三个旁路泄露:
30//! 系统缩略图缓存、搜索索引、以及文件本身的残留。见该类型的文档。
31
32use std::fs::{self, File, OpenOptions};
33use std::io::{self, Write};
34use std::path::{Path, PathBuf};
35
36use crate::error::{Error, Result};
37
38/// 原子写入使用的临时文件后缀。
39pub const TMP_SUFFIX: &str = ".tmp";
40
41/// 断点续传的进度文件后缀(移动端后台挂起场景,规范 §10)。
42pub const PROGRESS_SUFFIX: &str = ".progress";
43
44/// 进程内自增序号:参与临时文件名,避免同一纳秒内的并发创建发生撞名。
45static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
46
47/// 把 [`io::Error`] 包装为带路径上下文的 [`Error::Io`]。
48///
49/// `Error::Io` 是 `#[from] io::Error` 的元组变体,本身不携带路径。
50/// 而「哪个文件的哪一步失败了」对排查至关重要,因此这里把上下文拼进
51/// 一个新的 `io::Error` 中,同时用 `kind()` 保留原始错误类别
52/// (调用方仍可判断 `PermissionDenied`、`NotFound` 等)。
53fn io_err(path: &Path, op: &'static str, e: &io::Error) -> Error {
54    Error::Io(io::Error::new(e.kind(), format!("{op} failed on {}: {e}", path.display())))
55}
56
57/// 构造一个带说明的 [`Error::Io`](无底层 io 错误时使用)。
58fn io_msg(msg: String) -> Error {
59    Error::Io(io::Error::other(msg))
60}
61
62/// fsync 一个目录,确保其中的目录项变更已落盘。
63///
64/// # 平台差异
65///
66/// - Unix:打开目录并 fsync。这是保证 rename 持久化的必要步骤。
67/// - Windows:无法用 `File::open` 打开目录(会得到 `PermissionDenied`)。
68///   NTFS 的 rename 是有日志的元数据事务,本身即持久,故跳过。
69///
70/// # Errors
71///
72/// Unix 下打开或 fsync 目录失败时返回 [`Error::Io`]。
73pub fn sync_dir(dir: &Path) -> Result<()> {
74    #[cfg(windows)]
75    {
76        let _ = dir; // NTFS rename 自带日志,无需也无法 fsync 目录
77        Ok(())
78    }
79    #[cfg(not(windows))]
80    {
81        let f = File::open(dir).map_err(|e| io_err(dir, "open dir", &e))?;
82        f.sync_all().map_err(|e| io_err(dir, "fsync dir", &e))?;
83        Ok(())
84    }
85}
86
87/// 取出父目录;没有父目录时视为当前目录。
88fn parent_of(path: &Path) -> PathBuf {
89    path.parent()
90        .filter(|p| !p.as_os_str().is_empty())
91        .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
92}
93
94/// 原子地把 `data` 写入 `target`。
95///
96/// 严格执行规范 §10 的五步流程。若中途失败,会尽力清理临时文件,
97/// 且 `target` 保持原样(要么是旧内容,要么不存在)——**绝不会出现半截文件**。
98///
99/// # Errors
100///
101/// 任一步 I/O 失败时返回 [`Error::Io`],错误信息含出错的路径与操作名。
102pub fn write_atomic(target: &Path, data: &[u8]) -> Result<()> {
103    let mut w = AtomicWriter::create(target)?;
104    w.write_all(data)?;
105    w.commit()
106}
107
108/// 流式原子写入器。
109///
110/// 适用于加密大文件——载荷逐块产生,不必先在内存里拼出完整文件。
111/// 未调用 [`AtomicWriter::commit`] 就 drop 时,临时文件会被自动删除。
112///
113/// # 示例
114///
115/// ```no_run
116/// use omy_core::fsatomic::AtomicWriter;
117/// # fn main() -> omy_core::Result<()> {
118/// let mut w = AtomicWriter::create(std::path::Path::new("out.omy"))?;
119/// w.write_all(b"header")?;
120/// w.write_all(b"chunk-0")?;
121/// w.commit()?;   // 不调用 commit 则临时文件被丢弃
122/// # Ok(())
123/// # }
124/// ```
125pub struct AtomicWriter {
126    /// 最终目标路径。
127    target: PathBuf,
128    /// 临时文件路径。
129    tmp: PathBuf,
130    /// 打开的临时文件;`commit`/`drop` 后为 `None`。
131    file: Option<File>,
132    /// 已写入的字节数。
133    written: u64,
134}
135
136impl std::fmt::Debug for AtomicWriter {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("AtomicWriter")
139            .field("target", &self.target)
140            .field("written", &self.written)
141            .field("open", &self.file.is_some())
142            .finish()
143    }
144}
145
146impl AtomicWriter {
147    /// 创建临时文件并准备写入。
148    ///
149    /// 临时文件名为 `<target>.<pid>.<nonce><TMP_SUFFIX>`,与目标**同目录**——
150    /// 这是 rename 原子性的前提(跨文件系统的 rename 不是原子操作,且多数平台
151    /// 直接失败)。
152    ///
153    /// 名字必须每个实例唯一,不能固定为 `<target>.tmp`:两个并发写入者(例如
154    /// 两个 Tauri 命令线程同时保存同一配置)会共用同一个 tmp 文件,先提交者
155    /// 的 rename 会把 tmp 移走,后提交者的 rename 报 ENOENT——并发保存因此
156    /// 偶发失败。唯一的 tmp 让各写入者互不干扰,最终语义仍是「最后一次提交
157    /// 胜出」。
158    ///
159    /// # Errors
160    ///
161    /// 创建目录或临时文件失败时返回 [`Error::Io`]。
162    pub fn create(target: &Path) -> Result<Self> {
163        let dir = parent_of(target);
164        if !dir.exists() {
165            fs::create_dir_all(&dir).map_err(|e| io_err(&dir, "create_dir_all", &e))?;
166        }
167
168        let nanos = std::time::SystemTime::now()
169            .duration_since(std::time::UNIX_EPOCH)
170            .map_or(0, |d| d.as_nanos());
171        let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
172        let tmp_name = format!(
173            "{}.{}.{:x}-{}{}",
174            target.file_name().and_then(|s| s.to_str()).unwrap_or("file"),
175            std::process::id(),
176            nanos,
177            seq,
178            TMP_SUFFIX
179        );
180        let tmp = dir.join(tmp_name);
181
182        let file = OpenOptions::new()
183            .write(true)
184            .create(true)
185            .truncate(true)
186            .open(&tmp)
187            .map_err(|e| io_err(&tmp, "create temp file", &e))?;
188
189        Ok(Self { target: target.to_path_buf(), tmp, file: Option::from(file), written: 0 })
190    }
191
192    /// 追加写入一段数据。
193    ///
194    /// # Errors
195    ///
196    /// 写入失败或写入器已关闭时返回 [`Error::Io`]。
197    pub fn write_all(&mut self, buf: &[u8]) -> Result<()> {
198        let f = self.file.as_mut().ok_or_else(|| {
199            io_msg("writer already committed or aborted".to_owned())
200        })?;
201        f.write_all(buf).map_err(|e| io_err(&self.tmp, "write", &e))?;
202        self.written = self.written.saturating_add(buf.len() as u64);
203        Ok(())
204    }
205
206    /// 已写入的字节数。
207    #[must_use]
208    pub const fn written(&self) -> u64 {
209        self.written
210    }
211
212    /// 目标路径。
213    #[must_use]
214    pub fn target(&self) -> &Path {
215        &self.target
216    }
217
218    /// 提交:fsync → fsync 父目录 → rename → fsync 父目录。
219    ///
220    /// # Errors
221    ///
222    /// 任一步失败时返回 [`Error::Io`];此时会尽力删除临时文件,目标保持原样。
223    pub fn commit(mut self) -> Result<()> {
224        let Some(file) = self.file.take() else {
225            return Err(io_msg("writer already committed or aborted".to_owned()));
226        };
227
228        // 步骤 2:文件内容落盘
229        if let Err(e) = file.sync_all() {
230            let err = io_err(&self.tmp, "fsync", &e);
231            drop(file);
232            let _ = fs::remove_file(&self.tmp);
233            return Err(err);
234        }
235        drop(file); // Windows 上 rename 要求先关闭句柄
236
237        let dir = parent_of(&self.target);
238
239        // 步骤 3:目录项落盘(Unix)
240        if let Err(e) = sync_dir(&dir) {
241            let _ = fs::remove_file(&self.tmp);
242            return Err(e);
243        }
244
245        // 步骤 4:原子替换。
246        // fs::rename 在 Unix 上直接覆盖;在 Windows 上,std 已使用
247        // MoveFileEx(MOVEFILE_REPLACE_EXISTING),同样可覆盖已存在的目标。
248        if let Err(e) = fs::rename(&self.tmp, &self.target) {
249            let err = io_err(&self.tmp, "rename to target", &e);
250            let _ = fs::remove_file(&self.tmp);
251            return Err(err);
252        }
253
254        // 步骤 5:让 rename 本身持久化
255        sync_dir(&dir)
256    }
257
258    /// 主动放弃写入并删除临时文件。
259    pub fn abort(mut self) {
260        self.cleanup();
261    }
262
263    /// 关闭句柄并删除临时文件。
264    fn cleanup(&mut self) {
265        drop(self.file.take());
266        let _ = fs::remove_file(&self.tmp);
267    }
268}
269
270/// 标准 `Write`:让流式加密直接写进临时文件,提交前目标位置不受影响。
271impl std::io::Write for AtomicWriter {
272    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
273        let f = self.file.as_mut().ok_or_else(|| {
274            std::io::Error::other("writer already committed or aborted")
275        })?;
276        let n = f.write(buf)?;
277        self.written = self.written.saturating_add(n as u64);
278        Ok(n)
279    }
280
281    fn flush(&mut self) -> std::io::Result<()> {
282        self.file.as_mut().map_or(Ok(()), std::io::Write::flush)
283    }
284}
285
286/// 标准 `Seek`:流式加密写完载荷后要回到开头填写头部。
287///
288/// 注意 `written` 统计的是写入调用累计的字节数,seek 回去覆写头部会让它
289/// 大于文件长度;需要文件长度时以 `stream_position` / 元数据为准。
290impl std::io::Seek for AtomicWriter {
291    fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
292        let f = self.file.as_mut().ok_or_else(|| {
293            std::io::Error::other("writer already committed or aborted")
294        })?;
295        f.seek(pos)
296    }
297}
298
299impl Drop for AtomicWriter {
300    /// 未提交即析构 → 丢弃临时文件,避免留下垃圾。
301    fn drop(&mut self) {
302        if self.file.is_some() {
303            self.cleanup();
304        }
305    }
306}
307
308/// 受控的临时明文文件,用于「以外部应用打开」。
309///
310/// # 这个类型在防什么
311///
312/// 把解密后的明文落盘给外部程序打开,会踩到三个旁路泄露
313/// (见 `docs/research/07-platform-and-sidechannels.md` §3):
314///
315/// | 编号 | 泄露 | 本类型的对策 |
316/// |---|---|---|
317/// | L2 | 明文文件残留 | [`Drop`] 时删除;先覆写再删除(见下) |
318/// | L3 | 系统缩略图缓存 | 目录写入 `desktop.ini`(Windows) |
319/// | L5 | 搜索索引收录明文内容 | 目录标记不索引 |
320///
321/// # ⚠️ 覆写删除的真实效力
322///
323/// [`TempPlaintext::shred`] 会在删除前用随机字节覆写文件。**在现代存储上这不保证
324/// 数据真正消失**:SSD 的 wear leveling 会把写入重定向到新的物理块,日志式文件系统
325/// (APFS/Btrfs/ZFS)可能保留旧版本,快照更是直接留存。
326///
327/// 它能挡住的是「文件被删除后用常规恢复工具捞回来」,挡不住物理取证。
328/// 本项目威胁模型已排除物理取证(N2/N3),因此这是成本合适的措施——
329/// 但**不能向用户宣称"安全擦除"**。
330#[derive(Debug)]
331pub struct TempPlaintext {
332    /// 明文文件路径。
333    path: PathBuf,
334    /// drop 时是否覆写。
335    shred_on_drop: bool,
336}
337
338impl TempPlaintext {
339    /// 在指定目录下创建临时明文文件。
340    ///
341    /// `dir` 应当是应用私有的受控目录,**不要**用系统 `%TEMP%`——那里可能被
342    /// 云盘同步(L14)或备份系统(L13)覆盖。调用方负责选定安全目录。
343    ///
344    /// `filename` 用于保留正确的扩展名,让外部应用能正确关联打开方式。
345    ///
346    /// # Errors
347    ///
348    /// 创建目录或写入文件失败时返回 [`Error::Io`]。
349    pub fn create(dir: &Path, filename: &str, plaintext: &[u8]) -> Result<Self> {
350        fs::create_dir_all(dir).map_err(|e| io_err(dir, "create_dir_all", &e))?;
351        mark_dir_no_index(dir)?;
352
353        let path = dir.join(filename);
354        // 明文文件同样走原子写入:避免外部应用读到半截内容
355        write_atomic(&path, plaintext)?;
356        restrict_permissions(&path)?;
357
358        Ok(Self { path, shred_on_drop: true })
359    }
360
361    /// 明文文件路径,可交给外部应用。
362    #[must_use]
363    pub fn path(&self) -> &Path {
364        &self.path
365    }
366
367    /// 关闭 drop 时的覆写(仅用于测试或用户显式选择更快的清理)。
368    pub const fn set_shred_on_drop(&mut self, shred: bool) {
369        self.shred_on_drop = shred;
370    }
371
372    /// 覆写并删除文件。
373    ///
374    /// 关于效力边界见类型文档。
375    ///
376    /// # Errors
377    ///
378    /// 覆写或删除失败时返回 [`Error::Io`]。
379    pub fn shred(&self) -> Result<()> {
380        shred_file(&self.path, self.shred_on_drop)
381    }
382}
383
384impl Drop for TempPlaintext {
385    fn drop(&mut self) {
386        // Drop 中无法传播错误,只能尽力而为
387        let _ = shred_file(&self.path, self.shred_on_drop);
388    }
389}
390
391/// 覆写(可选)并删除文件。
392fn shred_file(path: &Path, overwrite: bool) -> Result<()> {
393    if !path.exists() {
394        return Ok(());
395    }
396    if overwrite
397        && let Ok(meta) = fs::metadata(path)
398    {
399        let len = meta.len();
400        if len > 0
401            && let Ok(mut f) = OpenOptions::new().write(true).open(path)
402        {
403            // 分块覆写,避免为大文件一次性分配缓冲区
404            const BUF: usize = 64 * 1024;
405            let mut buf = [0u8; BUF];
406            crate::util::fill_random(&mut buf);
407            let mut left = len;
408            while left > 0 {
409                let n = usize::try_from(left.min(BUF as u64)).unwrap_or(BUF);
410                if f.write_all(buf.get(..n).unwrap_or(&buf)).is_err() {
411                    break;
412                }
413                left = left.saturating_sub(n as u64);
414            }
415            let _ = f.sync_all();
416        }
417    }
418    fs::remove_file(path).map_err(|e| io_err(path, "remove temp plaintext", &e))
419}
420
421/// 标记目录不被系统搜索索引与缩略图缓存收录(旁路 L3/L5)。
422///
423/// # 平台实现
424///
425/// - Windows:写入 `desktop.ini`,设 `NoThumbnailCache`。
426///   完整的 `FILE_ATTRIBUTE_NOT_CONTENT_INDEXED` 需要调用 Win32 API,
427///   留待 GUI 层用 `windows` crate 实现——core 层保持无平台依赖。
428/// - macOS:创建 `.metadata_never_index`,Spotlight 会跳过整个目录。
429/// - Linux:无统一机制;主流索引器(tracker/baloo)遵循 `.nomedia`。
430///
431/// # Errors
432///
433/// 标记文件写入失败时返回 [`Error::Io`]。目录不存在不算错误。
434pub fn mark_dir_no_index(dir: &Path) -> Result<()> {
435    if !dir.is_dir() {
436        return Ok(());
437    }
438
439    #[cfg(target_os = "macos")]
440    {
441        let marker = dir.join(".metadata_never_index");
442        if !marker.exists() {
443            fs::write(&marker, b"").map_err(|e| io_err(&marker, "write marker", &e))?;
444        }
445    }
446
447    #[cfg(windows)]
448    {
449        let ini = dir.join("desktop.ini");
450        if !ini.exists() {
451            // 关闭该目录的缩略图缓存
452            let content = "[.ShellClassInfo]\r\nNoThumbnailCache=1\r\n";
453            fs::write(&ini, content).map_err(|e| io_err(&ini, "write desktop.ini", &e))?;
454        }
455    }
456
457    #[cfg(all(unix, not(target_os = "macos")))]
458    {
459        let marker = dir.join(".nomedia");
460        if !marker.exists() {
461            fs::write(&marker, b"").map_err(|e| io_err(&marker, "write marker", &e))?;
462        }
463    }
464
465    Ok(())
466}
467
468/// 把文件权限收紧到仅所有者可读写。
469///
470/// Unix 下设为 `0o600`。Windows 的 ACL 模型不同,需要 Win32 API,
471/// 留待平台层处理;此处为 no-op。
472///
473/// # Errors
474///
475/// 设置权限失败时返回 [`Error::Io`]。
476pub fn restrict_permissions(path: &Path) -> Result<()> {
477    #[cfg(unix)]
478    {
479        use std::os::unix::fs::PermissionsExt as _;
480        let perm = fs::Permissions::from_mode(0o600);
481        fs::set_permissions(path, perm).map_err(|e| io_err(path, "set_permissions", &e))?;
482    }
483    #[cfg(not(unix))]
484    {
485        let _ = path;
486    }
487    Ok(())
488}
489
490/// 清理指定目录下遗留的 `.tmp` 与 `.progress` 文件。
491///
492/// 应在应用启动时调用,清除上次异常退出的残留。
493/// 返回被删除的文件数。
494///
495/// # Errors
496///
497/// 读取目录失败时返回 [`Error::Io`]。单个文件删除失败会被跳过,不中断整体清理。
498pub fn cleanup_stale(dir: &Path) -> Result<usize> {
499    if !dir.is_dir() {
500        return Ok(0);
501    }
502    let mut n: usize = 0;
503    let rd = fs::read_dir(dir).map_err(|e| io_err(dir, "read_dir", &e))?;
504    for entry in rd.flatten() {
505        let path = entry.path();
506        if !path.is_file() {
507            continue;
508        }
509        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
510            continue;
511        };
512        if (name.ends_with(TMP_SUFFIX) || name.ends_with(PROGRESS_SUFFIX))
513            && fs::remove_file(&path).is_ok()
514        {
515            n = n.saturating_add(1);
516        }
517    }
518    Ok(n)
519}