Skip to main content

omy_core/
payload.rs

1//! 载荷层:分块 AEAD 加解密、随机访问、zstd 压缩。
2//!
3//! 每块**独立**加密,这是三项能力的共同基础(规范 §5):
4//!
5//! - **视频任意 seek**:未压缩时 `ct_offset(i)` 有闭式公式,无需索引表
6//! - **缺片降级播放**:缺失的片只影响它覆盖的块,其余块仍可解密
7//! - **有界内存**:不必把整个文件读入内存
8//!
9//! # 防篡改的三重绑定
10//!
11//! | 机制 | 防御的攻击 |
12//! |---|---|
13//! | nonce 含 `final_flag` | 截断文件尾部 |
14//! | AAD 含 `file_uuid` | 跨文件移植块 |
15//! | AAD 含块序号 | 重排块顺序 |
16
17use crate::crypto::{CipherId, SecretKey, TAG_LEN, chunk_aad, chunk_nonce};
18use crate::error::{Error, Result};
19use crate::header::{COMPRESS_ZSTD, FixedHeader, flags};
20use crate::tlv::CompressionIndexEntry;
21
22/// 分块处理的进度回调:`(已处理明文字节, 明文总字节)`。
23///
24/// # 为什么进度要从这一层报
25///
26/// 加解密是纯 CPU 密集的循环,调用方拿不到中间状态;大文件上
27/// 「几十秒没有任何反馈」与「卡死了」在用户看来没有区别。而这里是
28/// 唯一知道「第几块 / 共几块」的地方。
29///
30/// # 为什么用回调而不是返回 channel
31///
32/// core 不引入异步运行时,也不假设调用方是 CLI 还是 GUI:CLI 拿它画
33/// 进度条,GUI 拿它发事件给前端。回调是唯一不把这个选择固化进 core 的
34/// 形式。
35///
36/// 回调在**加解密线程内同步调用**,因此实现必须廉价——不要在里面做
37/// IO 或加锁等待,否则会拖慢加解密本身。
38pub type ProgressFn<'a> = &'a mut dyn FnMut(u64, u64);
39
40/// 加密单个明文块。
41///
42/// `index` 是块序号,`is_final` 标记是否为最后一块(参与 nonce 构造,防截断)。
43///
44/// # Errors
45///
46/// AEAD 加密失败时返回错误。
47pub fn encrypt_chunk(
48    payload_key: &SecretKey,
49    cipher: CipherId,
50    base_nonce: &[u8; 7],
51    file_uuid: &[u8; 16],
52    index: u32,
53    is_final: bool,
54    plaintext: &[u8],
55) -> Result<Vec<u8>> {
56    let nonce = chunk_nonce(base_nonce, index, is_final);
57    let aad = chunk_aad(file_uuid, index);
58    cipher.encrypt(payload_key, &nonce, plaintext, &aad)
59}
60
61/// 解密单个密文块。
62///
63/// # Errors
64///
65/// [`Error::ChunkAuthFailed`]:认证失败。可能原因是数据损坏、被篡改、
66/// 块序号错误,或文件被截断(`is_final` 不匹配)。
67pub fn decrypt_chunk(
68    payload_key: &SecretKey,
69    cipher: CipherId,
70    base_nonce: &[u8; 7],
71    file_uuid: &[u8; 16],
72    index: u32,
73    is_final: bool,
74    ciphertext: &[u8],
75) -> Result<Vec<u8>> {
76    let nonce = chunk_nonce(base_nonce, index, is_final);
77    let aad = chunk_aad(file_uuid, index);
78    cipher
79        .decrypt(payload_key, &nonce, ciphertext, &aad)?
80        .ok_or(Error::ChunkAuthFailed { index: u64::from(index) })
81}
82
83/// 解压单个块,并约束输出大小不超过 `chunk_size`。
84///
85/// # 为什么要约束
86///
87/// `plain_len` 来自索引表(不可信输入)。虽然 `zstd::bulk::decompress` 的第二参数
88/// 是容量上限、超出会报错而非分配,但若不额外约束到 `chunk_size`,一个块就能声明
89/// 解压出远超分块大小的数据,破坏「块内偏移可由 `chunk_size` 推算」这一前提,
90/// 也为解压炸弹留了口子。
91fn decompress_chunk(body: &[u8], plain_len: u32, chunk_size: u32) -> Result<Vec<u8>> {
92    if plain_len > chunk_size {
93        return Err(Error::MalformedTlv {
94            tlv_type: crate::tlv::types::COMPRESSION_INDEX,
95            reason: "index plain_len exceeds chunk_size",
96        });
97    }
98    let out = zstd::bulk::decompress(body, plain_len as usize)
99        .map_err(|err| Error::Compression { reason: err.to_string() })?;
100    // zstd 已按上限截断,这里再确认一次实际长度与声明相符
101    if out.len() != plain_len as usize {
102        return Err(Error::Compression {
103            reason: format!(
104                "decompressed length {} does not match index plain_len {plain_len}",
105                out.len()
106            ),
107        });
108    }
109    Ok(out)
110}
111
112/// 分块加密整个明文。
113///
114/// 返回 `(密文, 压缩索引)`。仅当启用压缩时索引非空——未压缩时偏移由公式算出,
115/// 存索引纯属浪费。
116///
117/// # Errors
118///
119/// - [`Error::MalformedHeader`]:块数超过 `u32` 上限
120/// - [`Error::Compression`]:zstd 压缩失败
121pub fn encrypt_payload(
122    header: &FixedHeader,
123    payload_key: &SecretKey,
124    plaintext: &[u8],
125    zstd_level: i32,
126) -> Result<(Vec<u8>, Vec<CompressionIndexEntry>)> {
127    encrypt_payload_with_progress(header, payload_key, plaintext, zstd_level, None)
128}
129
130/// 与 [`encrypt_payload`] 相同,但每处理完一块回报一次进度。
131///
132/// # Errors
133///
134/// 与 [`encrypt_payload`] 一致。
135pub fn encrypt_payload_with_progress(
136    header: &FixedHeader,
137    payload_key: &SecretKey,
138    plaintext: &[u8],
139    zstd_level: i32,
140    mut progress: Option<ProgressFn<'_>>,
141) -> Result<(Vec<u8>, Vec<CompressionIndexEntry>)> {
142    let compressed = header.compress_id == COMPRESS_ZSTD;
143    let chunk_size = header.chunk_size as usize;
144    let n_chunks = header.n_chunks();
145
146    let n_chunks_u32 = u32::try_from(n_chunks)
147        .map_err(|_| Error::MalformedHeader { reason: "chunk count exceeds u32" })?;
148
149    let mut out = Vec::new();
150    let mut index = Vec::new();
151
152    for i in 0..n_chunks_u32 {
153        let start = (i as usize).saturating_mul(chunk_size);
154        let end = start.saturating_add(chunk_size).min(plaintext.len());
155        // 空文件时 start=end=0,切片为空,仍会产出 1 个块(只含 tag)
156        let raw = plaintext.get(start..end).unwrap_or(&[]);
157        let is_final = i.saturating_add(1) == n_chunks_u32;
158
159        let body = if compressed {
160            zstd::bulk::compress(raw, zstd_level)
161                .map_err(|e| Error::Compression { reason: e.to_string() })?
162        } else {
163            raw.to_vec()
164        };
165
166        let ct = encrypt_chunk(
167            payload_key,
168            header.cipher_id,
169            &header.base_nonce,
170            &header.file_uuid,
171            i,
172            is_final,
173            &body,
174        )?;
175
176        if compressed {
177            index.push(CompressionIndexEntry {
178                ct_offset: out.len() as u64,
179                ct_len: u32::try_from(ct.len()).map_err(|_| Error::MalformedHeader {
180                    reason: "chunk ciphertext exceeds u32",
181                })?,
182                plain_len: u32::try_from(raw.len()).map_err(|_| Error::MalformedHeader {
183                    reason: "chunk plaintext exceeds u32",
184                })?,
185            });
186        }
187        out.extend_from_slice(&ct);
188
189        // 报告的是**明文**进度而非密文:用户关心的是「我这个文件处理
190        // 到哪了」,而压缩会让密文进度与之脱节(压缩比高时密文只有
191        // 明文的几分之一,按密文报会显得进度条走得莫名其妙)
192        if let Some(cb) = progress.as_deref_mut() {
193            cb(end as u64, plaintext.len() as u64);
194        }
195    }
196
197    Ok((out, index))
198}
199
200/// 流式分块加密:从 `input` 逐块读明文、加密后交给 `sink`。
201///
202/// 与 [`encrypt_payload_with_progress`] 逐块产出相同的密文,但不要求明文整个
203/// 在内存里。块数由 `header.plaintext_size` 决定;`input` 实际长度与它不符
204/// (文件在加密过程中被改动)时报错,不写出与头部声明不一致的载荷。
205///
206/// 返回 `(明文内容哈希, 压缩索引)`。哈希与 [`crate::crypto::content_hash`] 相同,
207/// 边读边算,免得为了它再读一遍文件。
208///
209/// # Errors
210///
211/// 读取失败、长度不符、压缩或加密失败,或 `sink` 报错时返回。
212pub fn encrypt_payload_stream<R: std::io::Read>(
213    header: &FixedHeader,
214    payload_key: &SecretKey,
215    input: &mut R,
216    zstd_level: i32,
217    sink: &mut dyn FnMut(&[u8]) -> Result<()>,
218    mut progress: Option<ProgressFn<'_>>,
219) -> Result<([u8; 32], Vec<CompressionIndexEntry>)> {
220    use blake2::digest::{Digest, consts::U32};
221    use std::io::Read as _;
222
223    let compressed = header.compress_id == COMPRESS_ZSTD;
224    let chunk_size = u64::from(header.chunk_size);
225    let total = header.plaintext_size;
226    let n_chunks_u32 = u32::try_from(header.n_chunks())
227        .map_err(|_| Error::MalformedHeader { reason: "chunk count exceeds u32" })?;
228
229    let mut hasher = blake2::Blake2b::<U32>::new();
230    let mut index = Vec::new();
231    let mut ct_offset = 0u64;
232    let mut done = 0u64;
233    let mut raw = Vec::with_capacity(usize::try_from(chunk_size).unwrap_or(0));
234
235    for i in 0..n_chunks_u32 {
236        let want = total.saturating_sub(done).min(chunk_size);
237        raw.clear();
238        let got = (&mut *input).take(want).read_to_end(&mut raw).map_err(Error::Io)?;
239        if got as u64 != want {
240            return Err(Error::Io(std::io::Error::new(
241                std::io::ErrorKind::UnexpectedEof,
242                "source shrank while encrypting",
243            )));
244        }
245        hasher.update(&raw);
246        let is_final = i.saturating_add(1) == n_chunks_u32;
247
248        let body = if compressed {
249            zstd::bulk::compress(&raw, zstd_level)
250                .map_err(|e| Error::Compression { reason: e.to_string() })?
251        } else {
252            raw.clone()
253        };
254        let ct = encrypt_chunk(
255            payload_key,
256            header.cipher_id,
257            &header.base_nonce,
258            &header.file_uuid,
259            i,
260            is_final,
261            &body,
262        )?;
263        if compressed {
264            index.push(CompressionIndexEntry {
265                ct_offset,
266                ct_len: u32::try_from(ct.len()).map_err(|_| Error::MalformedHeader {
267                    reason: "chunk ciphertext exceeds u32",
268                })?,
269                plain_len: u32::try_from(raw.len()).map_err(|_| Error::MalformedHeader {
270                    reason: "chunk plaintext exceeds u32",
271                })?,
272            });
273        }
274        sink(&ct)?;
275        ct_offset = ct_offset.saturating_add(ct.len() as u64);
276        done = done.saturating_add(want);
277        if let Some(cb) = progress.as_deref_mut() {
278            cb(done, total);
279        }
280    }
281
282    // 读完声明的长度后源里还有数据:文件在加密过程中变长了
283    let mut probe = [0u8; 1];
284    if input.read(&mut probe).map_err(Error::Io)? != 0 {
285        return Err(Error::Io(std::io::Error::new(
286            std::io::ErrorKind::InvalidData,
287            "source grew while encrypting",
288        )));
289    }
290
291    Ok((hasher.finalize().into(), index))
292}
293
294/// 解密整个载荷。
295///
296/// `index` 在压缩模式下必须提供(规范要求压缩文件必带 CRITICAL 索引 TLV)。
297///
298/// # Errors
299///
300/// - [`Error::MissingTlv`]:压缩模式但未提供索引
301/// - [`Error::ChunkAuthFailed`]:某块认证失败
302/// - [`Error::Compression`]:解压失败
303/// - [`Error::MalformedHeader`]:解密结果长度与 `plaintext_size` 不符
304pub fn decrypt_payload(
305    header: &FixedHeader,
306    payload_key: &SecretKey,
307    ciphertext: &[u8],
308    index: Option<&[CompressionIndexEntry]>,
309) -> Result<Vec<u8>> {
310    decrypt_payload_with_progress(header, payload_key, ciphertext, index, None)
311}
312
313/// 与 [`decrypt_payload`] 相同,但每处理完一块回报一次进度。
314///
315/// # Errors
316///
317/// 与 [`decrypt_payload`] 一致。
318pub fn decrypt_payload_with_progress(
319    header: &FixedHeader,
320    payload_key: &SecretKey,
321    ciphertext: &[u8],
322    index: Option<&[CompressionIndexEntry]>,
323    mut progress: Option<ProgressFn<'_>>,
324) -> Result<Vec<u8>> {
325    let compressed = header.has_flag(flags::COMPRESSED);
326    let n_chunks = header.n_chunks();
327    let n_chunks_u32 = u32::try_from(n_chunks)
328        .map_err(|_| Error::MalformedHeader { reason: "chunk count exceeds u32" })?;
329
330    // 预分配容量以避免反复扩容。32 位平台上 plaintext_size 可能超过 usize 上限,
331    // 此时退化为不预分配(后续切片操作会给出明确的 Truncated 错误)。
332    let mut out = usize::try_from(header.plaintext_size)
333        .map_or_else(|_| Vec::new(), Vec::with_capacity);
334
335    if compressed {
336        let idx = index.ok_or(Error::MissingTlv {
337            tlv_type: crate::tlv::types::COMPRESSION_INDEX,
338        })?;
339        if idx.len() as u64 != n_chunks {
340            return Err(Error::MalformedTlv {
341                tlv_type: crate::tlv::types::COMPRESSION_INDEX,
342                reason: "index entry count does not match chunk count",
343            });
344        }
345        for (i, e) in idx.iter().enumerate() {
346            let i_u32 = u32::try_from(i)
347                .map_err(|_| Error::MalformedHeader { reason: "chunk index exceeds u32" })?;
348            let start = usize::try_from(e.ct_offset)
349                .map_err(|_| Error::MalformedTlv {
350                    tlv_type: crate::tlv::types::COMPRESSION_INDEX,
351                    reason: "ct_offset exceeds addressable range",
352                })?;
353            let end = start.saturating_add(e.ct_len as usize);
354            let ct = ciphertext.get(start..end).ok_or(Error::Truncated {
355                context: "compressed chunk",
356                need: end,
357                got: ciphertext.len(),
358            })?;
359            let is_final = i_u32.saturating_add(1) == n_chunks_u32;
360
361            let body = decrypt_chunk(
362                payload_key,
363                header.cipher_id,
364                &header.base_nonce,
365                &header.file_uuid,
366                i_u32,
367                is_final,
368                ct,
369            )?;
370            let plain = decompress_chunk(&body, e.plain_len, header.chunk_size)?;
371            out.extend_from_slice(&plain);
372            if let Some(cb) = progress.as_deref_mut() {
373                cb(out.len() as u64, header.plaintext_size);
374            }
375        }
376    } else {
377        for i in 0..n_chunks_u32 {
378            let (off, len) = header.chunk_ct_range(u64::from(i))?;
379            // 载荷切片的偏移是相对文件起始的,需减去 header_len
380            let rel = off
381                .checked_sub(u64::from(header.header_len))
382                .ok_or(Error::MalformedHeader { reason: "chunk offset precedes payload" })?;
383            let start = usize::try_from(rel).map_err(|_| Error::MalformedHeader {
384                reason: "chunk offset exceeds addressable range",
385            })?;
386            let end = start.saturating_add(usize::try_from(len).map_err(|_| {
387                Error::MalformedHeader { reason: "chunk length exceeds addressable range" }
388            })?);
389            let ct = ciphertext.get(start..end).ok_or(Error::Truncated {
390                context: "payload chunk",
391                need: end,
392                got: ciphertext.len(),
393            })?;
394            let is_final = i.saturating_add(1) == n_chunks_u32;
395
396            let plain = decrypt_chunk(
397                payload_key,
398                header.cipher_id,
399                &header.base_nonce,
400                &header.file_uuid,
401                i,
402                is_final,
403                ct,
404            )?;
405            out.extend_from_slice(&plain);
406            if let Some(cb) = progress.as_deref_mut() {
407                cb(out.len() as u64, header.plaintext_size);
408            }
409        }
410    }
411
412    if out.len() as u64 != header.plaintext_size {
413        return Err(Error::MalformedHeader {
414            reason: "decrypted length does not match plaintext_size",
415        });
416    }
417    Ok(out)
418}
419
420/// 读取明文的任意区间,只解密必要的块。
421///
422/// 这是视频 seek 的实现基础。
423///
424/// # `fetch_ct` 的偏移语义(**极易用错,务必注意**)
425///
426/// 回调收到的第一个参数是**相对载荷起点**的偏移,**不是文件绝对偏移**。
427/// 载荷起点即 `header.header_len`。若数据源是整个文件,必须自行加回:
428///
429/// ```ignore
430/// let header_len = u64::from(opened.header.header_len);
431/// read_range(&opened.header, opened.payload_key(), None, off, len,
432///     |payload_off, l| {
433///         let abs = payload_off + header_len;   // ← 这一步不能漏
434///         Ok(file_bytes[abs as usize..(abs + l) as usize].to_vec())
435///     })
436/// ```
437///
438/// 漏掉加回会取到偏移 `header_len` 的错误字节,AEAD 会以
439/// [`Error::ChunkAuthFailed`] 拒绝——**报错信息指向「数据损坏」,
440/// 容易误判成文件坏了或密钥不对**,实际是偏移用错。
441///
442/// 这样设计是为了让调用方自由选择数据来源:本地文件、内存、
443/// 局域网 HTTP Range 请求皆可,无需把整个文件载入内存。对只存载荷的
444/// 数据源(如分片、远程对象存储)而言,相对偏移正是它需要的形式。
445///
446/// # Errors
447///
448/// - [`Error::ChunkOutOfRange`]:区间超出文件范围
449/// - [`Error::ChunkAuthFailed`]:某块认证失败(也可能是偏移用错,见上)
450/// - 回调返回的错误会向上传播
451pub fn read_range<F>(
452    header: &FixedHeader,
453    payload_key: &SecretKey,
454    index: Option<&[CompressionIndexEntry]>,
455    offset: u64,
456    length: u64,
457    mut fetch_ct: F,
458) -> Result<Vec<u8>>
459where
460    F: FnMut(u64, u64) -> Result<Vec<u8>>,
461{
462    if length == 0 {
463        return Ok(Vec::new());
464    }
465    let end = offset
466        .checked_add(length)
467        .ok_or(Error::MalformedHeader { reason: "range end overflows" })?
468        .min(header.plaintext_size);
469    if offset >= header.plaintext_size {
470        return Ok(Vec::new());
471    }
472
473    let n_chunks = header.n_chunks();
474    let compressed = header.has_flag(flags::COMPRESSED);
475    let cs = u64::from(header.chunk_size);
476
477    let first = header.chunk_index_of(offset);
478    let last = header.chunk_index_of(end.saturating_sub(1));
479
480    let mut acc = Vec::with_capacity(usize::try_from(end.saturating_sub(offset)).unwrap_or(0));
481
482    for i in first..=last {
483        if i >= n_chunks {
484            return Err(Error::ChunkOutOfRange { index: i, total: n_chunks });
485        }
486        let i_u32 =
487            u32::try_from(i).map_err(|_| Error::MalformedHeader { reason: "chunk index exceeds u32" })?;
488        let is_final = i.saturating_add(1) == n_chunks;
489
490        let plain = if compressed {
491            let idx = index.ok_or(Error::MissingTlv {
492                tlv_type: crate::tlv::types::COMPRESSION_INDEX,
493            })?;
494            let e = idx.get(usize::try_from(i).unwrap_or(usize::MAX)).ok_or(
495                Error::MalformedTlv {
496                    tlv_type: crate::tlv::types::COMPRESSION_INDEX,
497                    reason: "index shorter than chunk count",
498                },
499            )?;
500            let ct = fetch_ct(e.ct_offset, u64::from(e.ct_len))?;
501            let body = decrypt_chunk(
502                payload_key,
503                header.cipher_id,
504                &header.base_nonce,
505                &header.file_uuid,
506                i_u32,
507                is_final,
508                &ct,
509            )?;
510            decompress_chunk(&body, e.plain_len, header.chunk_size)?
511        } else {
512            let (abs_off, ct_len) = header.chunk_ct_range(i)?;
513            let rel = abs_off
514                .checked_sub(u64::from(header.header_len))
515                .ok_or(Error::MalformedHeader { reason: "chunk offset precedes payload" })?;
516            let ct = fetch_ct(rel, ct_len)?;
517            decrypt_chunk(
518                payload_key,
519                header.cipher_id,
520                &header.base_nonce,
521                &header.file_uuid,
522                i_u32,
523                is_final,
524                &ct,
525            )?
526        };
527
528        // 裁剪出本块与请求区间的交集
529        let chunk_start = i.saturating_mul(cs);
530        let want_from = offset.saturating_sub(chunk_start);
531        let want_to = end.saturating_sub(chunk_start).min(plain.len() as u64);
532        if want_from < want_to {
533            let a = usize::try_from(want_from).unwrap_or(0);
534            let b = usize::try_from(want_to).unwrap_or(plain.len());
535            if let Some(s) = plain.get(a..b) {
536                acc.extend_from_slice(s);
537            }
538        }
539    }
540    Ok(acc)
541}
542
543/// 未压缩载荷的总密文长度。
544///
545/// 每块附加一个 [`TAG_LEN`] 字节的认证标签。
546#[must_use]
547pub fn payload_ct_size(header: &FixedHeader) -> u64 {
548    let n = header.n_chunks();
549    header
550        .plaintext_size
551        .saturating_add(n.saturating_mul(TAG_LEN as u64))
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::crypto::{Argon2Params, Fek, KEY_LEN, Kek, SecretKey};
558    use crate::file::{EncryptOptions, RandomMaterial, encrypt, open};
559    use crate::header::{MIN_CHUNK_SIZE, SLOT_AREA_LEN, SLOT_LEN};
560
561    /// 造一个多块的加密文件。
562    fn make(plain: &[u8]) -> (Vec<u8>, Kek) {
563        let salt = [0x31u8; 16];
564        let params = Argon2Params::TEST_WEAK;
565        let kek = Kek::from_password(b"payload-test", &salt, params).unwrap();
566        let kek2 = Kek::from_password(b"payload-test", &salt, params).unwrap();
567        let opts = EncryptOptions {
568            chunk_size: MIN_CHUNK_SIZE,
569            argon2: params,
570            ..EncryptOptions::default()
571        };
572        let enc = encrypt(plain, &[kek], &salt, &opts, &RandomMaterial::generate()).unwrap();
573        (enc.bytes, kek2)
574    }
575
576    /// `fetch_ct` 收到的偏移是**相对载荷起点**,不是文件绝对偏移。
577    ///
578    /// 这个契约极易用错:把它当绝对偏移去索引整个文件,取到的字节会
579    /// 整体偏移 `header_len`,表现为「chunk 0 认证失败」,而报错信息
580    /// 指向数据损坏,很容易误判成文件坏了或密钥不对。
581    ///
582    /// 实现 spike 时就踩了这个坑,因此在此固化。
583    #[test]
584    fn fetch_ct_offset_is_payload_relative() {
585        let plain: Vec<u8> = (0..150_000u32).map(|i| (i % 253) as u8).collect();
586        let (bytes, kek) = make(&plain);
587        let opened = open(&bytes, &[kek]).unwrap();
588        let header_len = u64::from(opened.header.header_len);
589        assert!(header_len > 0, "头部长度应为正");
590
591        // 正确用法:加回 header_len 才能索引整个文件
592        let seen: std::cell::RefCell<Vec<(u64, u64)>> = std::cell::RefCell::new(Vec::new());
593        let got = read_range(
594            &opened.header,
595            opened.payload_key(),
596            None,
597            0,
598            plain.len() as u64,
599            |off, len| {
600                seen.borrow_mut().push((off, len));
601                let a = usize::try_from(off + header_len).unwrap();
602                let b = usize::try_from(off + header_len + len).unwrap();
603                Ok(bytes[a..b].to_vec())
604            },
605        )
606        .unwrap();
607        assert_eq!(got, plain, "正确加回 header_len 后应完整还原");
608
609        // 第一次回调的偏移必须是 0(载荷起点),而不是 header_len
610        let calls = seen.borrow();
611        assert_eq!(calls[0].0, 0, "首个 fetch_ct 偏移应为 0,即相对载荷起点");
612
613        // 错误用法必须被 AEAD 抓住,而不是静默返回错数据
614        let wrong = read_range(
615            &opened.header,
616            opened.payload_key(),
617            None,
618            0,
619            1000,
620            |off, len| {
621                // 故意当成绝对偏移
622                let a = usize::try_from(off).unwrap();
623                let b = usize::try_from(off + len).unwrap();
624                Ok(bytes[a..b].to_vec())
625            },
626        );
627        assert!(
628            matches!(wrong, Err(Error::ChunkAuthFailed { .. })),
629            "把偏移当绝对值使用必须触发认证失败,实际: {wrong:?}"
630        );
631    }
632
633    /// 只解密涉及的块——这是任意 seek 可行的前提。
634    #[test]
635    fn read_range_only_fetches_needed_chunks() {
636        let cs = u64::from(MIN_CHUNK_SIZE);
637        let plain: Vec<u8> = (0..(cs * 5) as u32).map(|i| (i % 251) as u8).collect();
638        let (bytes, kek) = make(&plain);
639        let opened = open(&bytes, &[kek]).unwrap();
640        let header_len = u64::from(opened.header.header_len);
641        assert_eq!(opened.header.n_chunks(), 5, "应为 5 块");
642
643        // 读第 4 块中间的 100 字节:只应取 1 块密文
644        let n_calls = std::cell::Cell::new(0u32);
645        let total_ct = std::cell::Cell::new(0u64);
646        let off = cs * 3 + 500;
647        let got = read_range(
648            &opened.header,
649            opened.payload_key(),
650            None,
651            off,
652            100,
653            |o, l| {
654                n_calls.set(n_calls.get() + 1);
655                total_ct.set(total_ct.get() + l);
656                let a = usize::try_from(o + header_len).unwrap();
657                let b = usize::try_from(o + header_len + l).unwrap();
658                Ok(bytes[a..b].to_vec())
659            },
660        )
661        .unwrap();
662
663        assert_eq!(got, &plain[off as usize..(off + 100) as usize]);
664        assert_eq!(n_calls.get(), 1, "只跨 1 块就只该取 1 次");
665        // 取的密文量应约等于一个块,而不是整个文件
666        assert!(
667            total_ct.get() <= cs + TAG_LEN as u64,
668            "实读密文 {} 超过单块上限,说明退化成了多块读取",
669            total_ct.get()
670        );
671        assert!(
672            total_ct.get() * 2 < plain.len() as u64,
673            "实读密文不应接近整个文件"
674        );
675    }
676
677    /// 跨块边界的区间要能正确拼接。
678    #[test]
679    fn read_range_across_chunk_boundary() {
680        let cs = u64::from(MIN_CHUNK_SIZE);
681        let plain: Vec<u8> = (0..(cs * 3) as u32).map(|i| (i % 249) as u8).collect();
682        let (bytes, kek) = make(&plain);
683        let opened = open(&bytes, &[kek]).unwrap();
684        let header_len = u64::from(opened.header.header_len);
685
686        let fetch = |o: u64, l: u64| -> Result<Vec<u8>> {
687            let a = usize::try_from(o + header_len).unwrap();
688            let b = usize::try_from(o + header_len + l).unwrap();
689            Ok(bytes[a..b].to_vec())
690        };
691
692        // 恰好跨越第 1/2 块边界
693        let off = cs - 50;
694        let got = read_range(&opened.header, opened.payload_key(), None, off, 100, fetch).unwrap();
695        assert_eq!(got, &plain[off as usize..(off + 100) as usize]);
696
697        // 末尾不足一块
698        let off2 = cs * 3 - 30;
699        let got2 = read_range(&opened.header, opened.payload_key(), None, off2, 30, fetch).unwrap();
700        assert_eq!(got2, &plain[off2 as usize..]);
701
702        // 越界读取应被裁剪而非报错
703        let got3 = read_range(&opened.header, opened.payload_key(), None, off2, 9999, fetch).unwrap();
704        assert_eq!(got3, &plain[off2 as usize..], "超出部分应被裁剪");
705
706        // 起点超出明文长度返回空
707        let got4 = read_range(
708            &opened.header,
709            opened.payload_key(),
710            None,
711            plain.len() as u64,
712            10,
713            fetch,
714        )
715        .unwrap();
716        assert!(got4.is_empty(), "起点超界应返回空而非报错");
717    }
718
719    /// 进度必须单调递增,且最终精确等于明文总长。
720    ///
721    /// 这两条是进度条能用的最低要求:不单调会让进度条来回跳;
722    /// 收尾不到 100% 会让界面永远停在 99%,用户以为卡住了。
723    #[test]
724    fn progress_is_monotonic_and_reaches_total() {
725        let cs = u64::from(MIN_CHUNK_SIZE);
726        // 刻意用非整块长度:最后一块不满,最容易在收尾处算错
727        let plain: Vec<u8> = (0..(cs * 3 + 123) as u32).map(|i| (i % 251) as u8).collect();
728
729        let salt = [7u8; 16];
730        let params = Argon2Params::TEST_WEAK;
731        let kek = Kek::from_password(b"progress-test", &salt, params).unwrap();
732        let kek2 = Kek::from_password(b"progress-test", &salt, params).unwrap();
733        let opts = EncryptOptions {
734            chunk_size: MIN_CHUNK_SIZE,
735            argon2: params,
736            ..EncryptOptions::default()
737        };
738
739        let mut seen: Vec<(u64, u64)> = Vec::new();
740        let enc = {
741            let mut cb = |done: u64, total: u64| seen.push((done, total));
742            crate::file::encrypt_with_progress(
743                &plain,
744                &[kek],
745                &salt,
746                &opts,
747                &RandomMaterial::generate(),
748                Some(&mut cb),
749            )
750            .unwrap()
751        };
752
753        assert_eq!(seen.len(), 4, "3 个整块 + 1 个尾块,应回调 4 次");
754        assert!(
755            seen.windows(2).all(|w| w[1].0 >= w[0].0),
756            "进度必须单调不减,实际: {seen:?}"
757        );
758        assert!(
759            seen.iter().all(|&(_, total)| total == plain.len() as u64),
760            "总长必须始终是明文长度"
761        );
762        assert_eq!(
763            seen.last().map(|&(d, _)| d),
764            Some(plain.len() as u64),
765            "最后一次必须报满,否则界面停在 99%"
766        );
767
768        // 解密侧同样要能报到满
769        let opened = open(&enc.bytes, &[kek2]).unwrap();
770        let mut dseen: Vec<u64> = Vec::new();
771        let out = {
772            let mut dcb = |done: u64, _total: u64| dseen.push(done);
773            opened
774                .decrypt_all_with_progress(&enc.bytes, Some(&mut dcb))
775                .unwrap()
776        };
777        assert_eq!(out, plain, "带进度回调不能改变解密结果");
778        assert_eq!(
779            dseen.last().copied(),
780            Some(plain.len() as u64),
781            "解密进度也必须报满"
782        );
783    }
784
785    /// 压缩模式下进度报的是**明文**字节,不是密文字节。
786    ///
787    /// 若误报密文进度,高压缩比文件的进度条会走到一半就结束——
788    /// 因为密文总量远小于明文,而 total 用的是明文长度。
789    #[test]
790    fn progress_reports_plaintext_bytes_when_compressed() {
791        let cs = u64::from(MIN_CHUNK_SIZE);
792        // 全零数据压缩比极高,密文远小于明文,两者最容易分辨
793        let plain = vec![0u8; (cs * 3) as usize];
794
795        let salt = [9u8; 16];
796        let params = Argon2Params::TEST_WEAK;
797        let kek = Kek::from_password(b"zip-progress", &salt, params).unwrap();
798        let opts = EncryptOptions {
799            chunk_size: MIN_CHUNK_SIZE,
800            argon2: params,
801            compress: true,
802            ..EncryptOptions::default()
803        };
804
805        let mut last = 0u64;
806        let enc = {
807            let mut cb = |done: u64, _total: u64| last = done;
808            crate::file::encrypt_with_progress(
809                &plain,
810                &[kek],
811                &salt,
812                &opts,
813                &RandomMaterial::generate(),
814                Some(&mut cb),
815            )
816            .unwrap()
817        };
818
819        assert_eq!(last, plain.len() as u64, "压缩时进度仍应按明文计");
820        assert!(
821            enc.bytes.len() < plain.len(),
822            "前提校验:全零数据应当被显著压缩,否则这个测试没测到东西"
823        );
824    }
825
826    /// 带不带进度回调,产出必须逐字节相同。
827    ///
828    /// 进度是观测手段,绝不能改变产物——否则同一份输入在
829    /// CLI(带进度)和别处(不带)会加密出不同的文件。
830    ///
831    /// 必须走 `encrypt_with_fek*` 这层:面向用户的 `encrypt` 内部会
832    /// `Fek::random()`,未占用的 slot 也填随机字节(可否认性所需,见
833    /// `slot.rs`),两次调用天然产出不同字节,用它来比对只会测出
834    /// 「随机数确实是随机的」,而完全测不到进度回调的影响。
835    #[test]
836    fn progress_does_not_change_output() {
837        let plain: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
838        let salt = [3u8; 16];
839        let params = Argon2Params::TEST_WEAK;
840        let kek = Kek::from_password(b"same-out", &salt, params).unwrap();
841        let opts = EncryptOptions {
842            chunk_size: MIN_CHUNK_SIZE,
843            argon2: params,
844            ..EncryptOptions::default()
845        };
846
847        // 固定住全部随机来源:FEK、file_uuid、base_nonce、slot 填充。
848        // 少固定任何一个,两次输出都不可能逐字节相同。
849        //
850        // 这些常量都用算式生成而非 `[0x5A; 32]` 这类字面量:字面量会在
851        // .rodata 里留下一长串同值字节,本机安全软件会据此判定测试二进制
852        // 可疑,直接拦截执行(表现为 os error 5,测试根本跑不起来)。
853        // 顺带的好处是填充字节各不相同,slot 区若按错误偏移拼接更容易露馅。
854        let key_bytes: [u8; KEY_LEN] = core::array::from_fn(|i| (i as u8).wrapping_mul(7) ^ 0x3D);
855        let fek = Fek::from_key(SecretKey::from_bytes(key_bytes));
856        let fek2 = Fek::from_key(SecretKey::from_bytes(key_bytes));
857        let rnd = RandomMaterial {
858            file_uuid: core::array::from_fn(|i| (i as u8).wrapping_mul(11).wrapping_add(2)),
859            base_nonce: core::array::from_fn(|i| (i as u8).wrapping_mul(13).wrapping_add(5)),
860            slot_padding: Some(
861                (0..SLOT_AREA_LEN - SLOT_LEN).map(|i| (i % 251) as u8).collect(),
862            ),
863        };
864
865        let a = crate::file::encrypt_with_fek(
866            &plain,
867            &[kek.duplicate()],
868            &salt,
869            &opts,
870            &rnd,
871            &fek,
872        )
873        .unwrap();
874
875        let mut hits = 0u32;
876        let b = {
877            let mut cb = |_d: u64, _t: u64| hits += 1;
878            crate::file::encrypt_with_fek_and_progress(
879                &plain,
880                &[kek],
881                &salt,
882                &opts,
883                &rnd,
884                &fek2,
885                Some(&mut cb),
886            )
887            .unwrap()
888        };
889
890        assert_eq!(a.bytes, b.bytes, "带不带进度回调,产出必须逐字节相同");
891        assert!(hits > 0, "前提校验:回调确实被调用过,否则上面的相等是白比的");
892    }
893}