Skip to main content

omy_core/
source.rs

1//! [`BlockSource`]:密文块的统一来源抽象。
2//!
3//! 对应设计文档 [11 号 §1.2](../../docs/research/11-engineering-roadmap.md)。
4//! 本地文件、分片集合、容器条目、局域网远程设备都实现同一个 trait,
5//! 使播放器、缩略图、扫描逻辑只写一遍。
6//!
7//! 这个抽象直接决定决策 D-04(局域网只传密文)能否干净落地——
8//! 远程实现返回的就是密文,解密统一在上层完成,服务端永不接触明文。
9//!
10//! # 与设计文档的一处偏离
11//!
12//! 文档给出的草案是 `#[async_trait]`。此处实现为**同步** trait,理由:
13//!
14//! 1. [`payload::read_range`](crate::payload::read_range) 是纯 CPU 的解密逻辑。
15//!    若 `BlockSource` 为异步,会迫使它连带异步化,进而污染整条调用链。
16//! 2. `omy-core` 是 MIT OR Apache-2.0 且要保持轻量,不应引入 async 运行时依赖。
17//! 3. CLI 是同步程序,异步会凭空增加复杂度。
18//!
19//! 远程实现(`omy-net`)在自己内部桥接异步即可——它本就需要运行时,
20//! 由它承担这个成本比让 core 承担更合理。GUI 侧在后台线程调用,不阻塞 UI。
21
22use crate::error::{Error, Result};
23use crate::file::OpenedFile;
24use crate::header::FixedHeader;
25use crate::payload::read_range;
26use crate::tlv::CompressionIndexEntry;
27use std::io::{Read, Seek, SeekFrom};
28use std::path::{Path, PathBuf};
29
30/// 密文块来源。
31///
32/// 实现者只需提供「按载荷内偏移取密文」的能力,随机访问、跨块拼接、
33/// 解压都由上层统一处理。
34pub trait BlockSource {
35    /// 文件头。实现者应缓存,避免每次重新解析。
36    fn header(&self) -> &FixedHeader;
37
38    /// 读取载荷区中 `[offset, offset + len)` 的**密文**。
39    ///
40    /// `offset` 相对载荷区起点(即 `header_len` 之后),不是文件绝对偏移。
41    ///
42    /// # Errors
43    ///
44    /// 越界、I/O 失败或远程不可达时返回错误。
45    fn read_ct(&self, offset: u64, len: u64) -> Result<Vec<u8>>;
46
47    /// 载荷区密文总长。
48    fn ct_len(&self) -> u64;
49
50    /// 是否为远程来源。UI 据此显示网络状态、调整预读策略。
51    fn is_remote(&self) -> bool {
52        false
53    }
54
55    /// 来源的可读描述,用于错误信息与日志。
56    fn describe(&self) -> String {
57        String::from("<block source>")
58    }
59}
60
61/// 内存中的完整文件。测试与小文件场景使用。
62#[derive(Debug)]
63pub struct MemorySource {
64    header: FixedHeader,
65    /// 仅载荷部分,不含头部。
66    payload: Vec<u8>,
67}
68
69impl MemorySource {
70    /// 从完整文件字节构造。
71    ///
72    /// # Errors
73    ///
74    /// 头部解析失败或数据短于头部声明长度时返回错误。
75    pub fn new(file_bytes: &[u8]) -> Result<Self> {
76        let header = crate::file::peek_header(file_bytes)?;
77        let start = usize::try_from(header.header_len)
78            .map_err(|_| Error::MalformedHeader { reason: "header_len exceeds usize" })?;
79        let payload = file_bytes
80            .get(start..)
81            .ok_or(Error::Truncated {
82                context: "payload area",
83                need: start,
84                got: file_bytes.len(),
85            })?
86            .to_vec();
87        Ok(Self { header, payload })
88    }
89}
90
91impl BlockSource for MemorySource {
92    fn header(&self) -> &FixedHeader {
93        &self.header
94    }
95
96    fn read_ct(&self, offset: u64, len: u64) -> Result<Vec<u8>> {
97        let start = usize::try_from(offset)
98            .map_err(|_| Error::ChunkOutOfRange { index: offset, total: self.ct_len() })?;
99        let n = usize::try_from(len)
100            .map_err(|_| Error::ChunkOutOfRange { index: len, total: self.ct_len() })?;
101        let end = start
102            .checked_add(n)
103            .ok_or(Error::ChunkOutOfRange { index: offset, total: self.ct_len() })?;
104        self.payload
105            .get(start..end)
106            .map(<[u8]>::to_vec)
107            .ok_or(Error::Truncated {
108                context: "memory ciphertext range",
109                need: end,
110                got: self.payload.len(),
111            })
112    }
113
114    fn ct_len(&self) -> u64 {
115        self.payload.len() as u64
116    }
117
118    fn describe(&self) -> String {
119        String::from("memory")
120    }
121}
122
123/// 本地文件,按需 seek 读取,不整体载入内存。
124///
125/// 这是播放大视频的默认来源:内存占用与文件大小无关。
126#[derive(Debug)]
127pub struct LocalFileSource {
128    path: PathBuf,
129    header: FixedHeader,
130    payload_start: u64,
131    payload_len: u64,
132}
133
134impl LocalFileSource {
135    /// 打开本地 `.omy` 文件。
136    ///
137    /// 只读取头部,载荷按需读取。
138    ///
139    /// # Errors
140    ///
141    /// 文件不存在、无法读取或头部无效时返回错误。
142    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
143        let path = path.as_ref().to_path_buf();
144        let mut f = std::fs::File::open(&path)?;
145
146        // 先读足够覆盖头部的前缀。头部含 slot 区与 TLV 区,
147        // 长度由 header_len 声明,故先读固定部分再按需扩展。
148        let mut probe = vec![0u8; crate::scan::MIN_PROBE_SIZE];
149        let n = read_at_most(&mut f, &mut probe)?;
150        probe.truncate(n);
151        let partial = crate::file::peek_header(&probe)?;
152
153        let hlen = partial.header_len;
154        let total = f.metadata()?.len();
155        if total < u64::from(hlen) {
156            return Err(Error::Truncated {
157                context: "local file header",
158                need: hlen as usize,
159                got: usize::try_from(total).unwrap_or(usize::MAX),
160            });
161        }
162
163        // 重新读完整头部以完成解析(含头部 MAC 覆盖范围)
164        let mut head = vec![0u8; hlen as usize];
165        f.seek(SeekFrom::Start(0))?;
166        f.read_exact(&mut head)?;
167        let header = crate::file::peek_header(&head)?;
168
169        Ok(Self {
170            path,
171            payload_start: u64::from(hlen),
172            payload_len: total.saturating_sub(u64::from(hlen)),
173            header,
174        })
175    }
176
177    /// 文件路径。
178    #[must_use]
179    pub fn path(&self) -> &Path {
180        &self.path
181    }
182}
183
184impl BlockSource for LocalFileSource {
185    fn header(&self) -> &FixedHeader {
186        &self.header
187    }
188
189    fn read_ct(&self, offset: u64, len: u64) -> Result<Vec<u8>> {
190        let end = offset
191            .checked_add(len)
192            .ok_or(Error::ChunkOutOfRange { index: offset, total: self.payload_len })?;
193        if end > self.payload_len {
194            return Err(Error::Truncated {
195                context: "local file ciphertext range",
196                need: usize::try_from(end).unwrap_or(usize::MAX),
197                got: usize::try_from(self.payload_len).unwrap_or(usize::MAX),
198            });
199        }
200        let n = usize::try_from(len)
201            .map_err(|_| Error::ChunkOutOfRange { index: len, total: self.payload_len })?;
202
203        let mut f = std::fs::File::open(&self.path)?;
204        f.seek(SeekFrom::Start(
205            self.payload_start
206                .checked_add(offset)
207                .ok_or(Error::ChunkOutOfRange { index: offset, total: self.payload_len })?,
208        ))?;
209        let mut buf = vec![0u8; n];
210        f.read_exact(&mut buf)?;
211        Ok(buf)
212    }
213
214    fn ct_len(&self) -> u64 {
215        self.payload_len
216    }
217
218    fn describe(&self) -> String {
219        self.path.display().to_string()
220    }
221}
222
223/// 尽力读取,返回实际字节数。文件短于缓冲区时不报错。
224fn read_at_most(f: &mut std::fs::File, buf: &mut [u8]) -> Result<usize> {
225    let mut total = 0usize;
226    while total < buf.len() {
227        let Some(slice) = buf.get_mut(total..) else {
228            break;
229        };
230        match f.read(slice) {
231            Ok(0) => break,
232            Ok(n) => total = total.saturating_add(n),
233            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
234            Err(e) => return Err(e.into()),
235        }
236    }
237    Ok(total)
238}
239
240/// 容器内单个条目的视图。
241///
242/// 把容器内某文件的 `[offset, size)` 映射为独立的明文流,
243/// 使容器内文件与单独加密的文件对上层完全等价——
244/// 这是「容器内单文件预览无需解开整个容器」的实现基础(文档 05 §2.3)。
245#[derive(Debug)]
246pub struct ContainerEntryView<'a, S: BlockSource> {
247    inner: &'a S,
248    base: u64,
249    len: u64,
250}
251
252impl<'a, S: BlockSource> ContainerEntryView<'a, S> {
253    /// 用条目在明文载荷流中的区间构造视图。
254    #[must_use]
255    pub const fn new(inner: &'a S, offset: u64, size: u64) -> Self {
256        Self { inner, base: offset, len: size }
257    }
258
259    /// 该条目的明文长度。
260    #[must_use]
261    pub const fn plaintext_len(&self) -> u64 {
262        self.len
263    }
264
265    /// 读取该条目内的明文区间。
266    ///
267    /// `offset` 相对条目自身起点。超出条目范围的部分被裁剪,
268    /// 不会读到相邻文件的数据。
269    ///
270    /// # Errors
271    ///
272    /// 解密失败或来源读取失败时返回错误。
273    pub fn read(
274        &self,
275        opened: &OpenedFile,
276        offset: u64,
277        length: u64,
278    ) -> Result<Vec<u8>> {
279        if offset >= self.len {
280            return Ok(Vec::new());
281        }
282        let avail = self.len.saturating_sub(offset);
283        let want = length.min(avail);
284        let abs = self
285            .base
286            .checked_add(offset)
287            .ok_or(Error::MalformedHeader { reason: "entry offset overflow" })?;
288        read_source_range(self.inner, opened, abs, want)
289    }
290}
291
292/// 通过 [`BlockSource`] 读取明文区间。
293///
294/// 这是把 `BlockSource` 接到 [`payload::read_range`](crate::payload::read_range)
295/// 的桥梁:来源只管给密文,解密与跨块拼接在此统一完成。
296///
297/// # Errors
298///
299/// 来源读取失败、区间越界或块认证失败时返回错误。
300pub fn read_source_range<S: BlockSource + ?Sized>(
301    src: &S,
302    opened: &OpenedFile,
303    offset: u64,
304    length: u64,
305) -> Result<Vec<u8>> {
306    let index: Option<Vec<CompressionIndexEntry>> =
307        if src.header().has_flag(crate::header::flags::COMPRESSED) {
308            Some(opened.compression_index()?)
309        } else {
310            None
311        };
312
313    read_range(
314        src.header(),
315        opened.payload_key(),
316        index.as_deref(),
317        offset,
318        length,
319        |ct_off, ct_len| src.read_ct(ct_off, ct_len),
320    )
321}
322
323/// 把整个明文按窗口解密并交给 `sink`,内存占用与文件大小无关。
324///
325/// 原来还原文件要先把整个密文读进内存、再整份解密成明文,峰值是文件大小的
326/// 两倍以上——几 GB 的视频在手机上直接被系统杀掉。这里每次只解一个窗口
327/// (块大小的整数倍,约 [`crate::unpack::STREAM_WINDOW`]),边解边交给调用方
328/// 写盘,并在末尾校验头部记录的内容哈希(与 `decrypt_all` 同一保证)。
329///
330/// `progress(done, total)` 按明文字节回报。返回写出的明文字节数。
331///
332/// # Errors
333///
334/// 读取、块认证、内容哈希校验失败,或 `sink` 报错时返回。哈希不匹配时
335/// `sink` 已经收到了全部数据,调用方必须丢弃产物(原子写入时不要提交)。
336pub fn decrypt_to_sink<S: BlockSource + ?Sized>(
337    src: &S,
338    opened: &OpenedFile,
339    sink: &mut dyn FnMut(&[u8]) -> Result<()>,
340    mut progress: Option<crate::payload::ProgressFn<'_>>,
341) -> Result<u64> {
342    use blake2::digest::{Digest, consts::U32};
343
344    let total = opened.header.plaintext_size;
345    let chunk = u64::from(opened.header.chunk_size).max(1);
346    // 窗口取块大小的整数倍:跨块边界的窗口会让同一块被解密两次
347    let window = crate::unpack::STREAM_WINDOW
348        .checked_div(chunk)
349        .unwrap_or(1)
350        .max(1)
351        .saturating_mul(chunk);
352    let index: Option<Vec<CompressionIndexEntry>> =
353        if src.header().has_flag(crate::header::flags::COMPRESSED) {
354            Some(opened.compression_index()?)
355        } else {
356            None
357        };
358    let mut hasher = blake2::Blake2b::<U32>::new();
359    let mut done = 0u64;
360    while done < total {
361        let n = total.saturating_sub(done).min(window);
362        let plain = read_range(
363            src.header(),
364            opened.payload_key(),
365            index.as_deref(),
366            done,
367            n,
368            |ct_off, ct_len| src.read_ct(ct_off, ct_len),
369        )?;
370        if plain.len() as u64 != n {
371            return Err(Error::Truncated {
372                context: "payload",
373                need: usize::try_from(n).unwrap_or(usize::MAX),
374                got: plain.len(),
375            });
376        }
377        hasher.update(&plain);
378        sink(&plain)?;
379        done = done.saturating_add(n);
380        if let Some(cb) = progress.as_deref_mut() {
381            cb(done, total);
382        }
383    }
384    if let Ok(expected) = opened.stored_content_hash() {
385        use subtle::ConstantTimeEq;
386        let actual: [u8; 32] = hasher.finalize().into();
387        if !bool::from(actual.ct_eq(&expected)) {
388            return Err(Error::ContentHashMismatch);
389        }
390    }
391    Ok(done)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::crypto::{Argon2Params, Kek};
398    use crate::file::{EncryptOptions, RandomMaterial, encrypt, open};
399    use crate::header::MIN_CHUNK_SIZE;
400
401    fn make(payload: &[u8], compress: bool) -> (Vec<u8>, Kek) {
402        let salt = [0x22u8; 16];
403        let params = Argon2Params::TEST_WEAK;
404        let kek = Kek::from_password(b"src", &salt, params).unwrap();
405        let opts = EncryptOptions {
406            filename: Some("s.bin".into()),
407            chunk_size: MIN_CHUNK_SIZE,
408            argon2: params,
409            compress,
410            ..EncryptOptions::default()
411        };
412        let k2 = Kek::from_password(b"src", &salt, params).unwrap();
413        let enc = encrypt(payload, &[kek], &salt, &opts, &RandomMaterial::generate()).unwrap();
414        (enc.bytes, k2)
415    }
416
417    #[test]
418    fn memory_source_reads_ranges() {
419        let plain: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
420        let (bytes, kek) = make(&plain, false);
421        let src = MemorySource::new(&bytes).unwrap();
422        let opened = open(&bytes, &[kek]).unwrap();
423
424        for (off, len) in [(0u64, 10u64), (65_530, 20), (199_990, 10), (0, 200_000)] {
425            let got = read_source_range(&src, &opened, off, len).unwrap();
426            let want = &plain[off as usize..(off + len) as usize];
427            assert_eq!(got, want, "区间 ({off},{len}) 不符");
428        }
429        assert!(!src.is_remote());
430    }
431
432    #[test]
433    fn local_file_source_matches_memory() {
434        let plain: Vec<u8> = (0..150_000u32).map(|i| (i % 253) as u8).collect();
435        let (bytes, kek) = make(&plain, false);
436
437        let dir = std::env::temp_dir().join(format!("omy_src_{}", std::process::id()));
438        std::fs::create_dir_all(&dir).unwrap();
439        let p = dir.join("a.omy");
440        std::fs::write(&p, &bytes).unwrap();
441
442        let src = LocalFileSource::open(&p).unwrap();
443        let opened = open(&bytes, &[kek]).unwrap();
444
445        assert_eq!(src.ct_len(), (bytes.len() - src.header().header_len as usize) as u64);
446
447        for (off, len) in [(0u64, 100u64), (65_536, 1000), (149_000, 1000)] {
448            let got = read_source_range(&src, &opened, off, len).unwrap();
449            assert_eq!(got, &plain[off as usize..(off + len) as usize]);
450        }
451
452        std::fs::remove_dir_all(&dir).ok();
453    }
454
455    #[test]
456    fn compressed_source_reads_ranges() {
457        // 高重复内容,确保压缩确实生效
458        let plain = vec![0x5Au8; 300_000];
459        let (bytes, kek) = make(&plain, true);
460        let src = MemorySource::new(&bytes).unwrap();
461        let opened = open(&bytes, &[kek]).unwrap();
462        assert!(src.header().has_flag(crate::header::flags::COMPRESSED));
463
464        let got = read_source_range(&src, &opened, 100, 5000).unwrap();
465        assert_eq!(got, &plain[100..5100]);
466    }
467
468    #[test]
469    fn container_view_clips_to_entry() {
470        // 载荷模拟两个拼接的文件:前 1000 个 0xAA,后 1000 个 0xBB
471        let mut plain = vec![0xAAu8; 1000];
472        plain.extend_from_slice(&[0xBBu8; 1000]);
473        let (bytes, kek) = make(&plain, false);
474        let src = MemorySource::new(&bytes).unwrap();
475        let opened = open(&bytes, &[kek]).unwrap();
476
477        let second = ContainerEntryView::new(&src, 1000, 1000);
478        assert_eq!(second.plaintext_len(), 1000);
479
480        // 读第二个条目的全部,必须全是 0xBB——不能串到前一个文件
481        let got = second.read(&opened, 0, 1000).unwrap();
482        assert_eq!(got.len(), 1000);
483        assert!(got.iter().all(|&b| b == 0xBB));
484
485        // 请求超过条目长度,必须被裁剪而非读到界外
486        let got2 = second.read(&opened, 900, 500).unwrap();
487        assert_eq!(got2.len(), 100);
488        assert!(got2.iter().all(|&b| b == 0xBB));
489
490        // 起点越界返回空
491        assert!(second.read(&opened, 1000, 10).unwrap().is_empty());
492    }
493
494    #[test]
495    fn out_of_bounds_ct_read_errors() {
496        let (bytes, _) = make(b"short", false);
497        let src = MemorySource::new(&bytes).unwrap();
498        assert!(src.read_ct(0, src.ct_len() + 1).is_err());
499        assert!(src.read_ct(u64::MAX, 1).is_err());
500    }
501
502    #[test]
503    fn describe_is_useful() {
504        let (bytes, _) = make(b"x", false);
505        let src = MemorySource::new(&bytes).unwrap();
506        assert_eq!(src.describe(), "memory");
507    }
508
509    /// 流式解密的结果必须与整份解密逐字节相同,跨多个窗口、压缩与否都一样。
510    ///
511    /// 不这样会怎样:窗口拼接错一位,还原出来的大文件就是坏的,而小文件
512    /// (单窗口)测不出来——所以这里用远大于窗口的数据。
513    #[test]
514    fn decrypt_to_sink_matches_decrypt_all() -> Result<()> {
515        let big: Vec<u8> = (0..(crate::unpack::STREAM_WINDOW as usize * 2 + 12_345))
516            .map(|i| (i % 251) as u8)
517            .collect();
518        for compress in [false, true] {
519            let (file, kek) = make(&big, compress);
520            let opened = open(&file, &[kek])?;
521            let src = MemorySource::new(&file)?;
522            let mut out = Vec::new();
523            let n = decrypt_to_sink(&src, &opened, &mut |b| { out.extend_from_slice(b); Ok(()) }, None)?;
524            assert_eq!(n as usize, big.len());
525            assert_eq!(out, opened.decrypt_all(&file)?, "compress={compress}");
526        }
527        Ok(())
528    }
529}