Skip to main content

omy_core/
recovery.rs

1//! 恢复码:256 bit 熵 → 26 个词,带能定位到词的校验和。
2//!
3//! # 与密码的区别只在「怎么变成 KEK」
4//!
5//! ```text
6//! 密码   ──Argon2id(故意慢,因为熵不够)──▶ KEK
7//! 恢复码 ──HKDF(微秒级,熵已足够)────────▶ KEK
8//! ```
9//!
10//! 变成 KEK 之后两者走的是完全同一条路:解 slot、拿 FEK、解密。所以恢复码
11//! 不是「另一种打开方式」,它就是一个密码,只是省掉了慢速 KDF。
12//!
13//! **为什么不需要 Argon2**:256 bit 真随机,暴力破解不可行,慢速 KDF 只是
14//! 白费用户的时间。慢速 KDF 存在的理由是「人选的密码熵太低」,这里不适用。
15//!
16//! # 编码:26 词 = 256 bit 熵 + 4 bit 校验
17//!
18//! SLIP-39 词表每词 10 bit,26 词 = 260 bit = 256 熵 + 4 校验。
19//!
20//! # 校验和为什么必须存在,以及为什么要能定位
21//!
22//! 没有校验和,「手抄错一个词」与「这不是本库的恢复码」表现完全一样——
23//! 都是「解不开」。而这两种处境的处置方式相反:前者该回去核对纸条,
24//! 后者该去找别的东西。
25//!
26//! 更进一步,26 个词里错一个,让用户从头核对 26 遍是很糟的体验。所以
27//! 校验和设计成**能指出第几个词可疑**:除了 4 bit 的全局校验,解析时还会
28//! 逐词检查是否在词表内,并对「不在词表但与某个词很接近」的情况给出建议
29//! (见 [`ParseError::UnknownWord`])。
30//!
31//! # 安全边界(必须出现在 UI 里)
32//!
33//! 1. 恢复码是**整个 vault 的强度下限**——它是一张明文纸条,谁拿到谁全权访问。
34//! 2. 它与可否认性冲突:固定槽数掩盖了数量,但纸条放在家里,胁迫场景下
35//!    就是突破口。
36//! 3. [`crate::session::CredentialKind::Recovery`] 的 `scannable()` 是 false,
37//!    所以输完恢复码**列表不会自动显形**,只在手动打开单个文件时才尝试。
38//!    不说清楚会让用户以为恢复码没生效。
39
40use crate::crypto::{Kek, SecretKey};
41use crate::wordlist::{BITS_PER_WORD, WORDS};
42use sha2::{Digest as _, Sha256};
43use zeroize::Zeroize as _;
44
45/// 恢复码的熵长度(字节)。
46pub const ENTROPY_LEN: usize = 32;
47/// 校验位数。
48pub const CHECKSUM_BITS: usize = 4;
49/// 恢复码的词数。
50///
51/// `#[expect(integer_division)]`:这里是编译期常量算术,260 / 10 恰好整除,
52/// 且下面的静态断言锁住了「不允许有填充位」。库代码禁整数除法是为了防止
53/// 运行期静默截断,与此无关。
54#[expect(clippy::integer_division, reason = "编译期常量,静态断言保证整除")]
55pub const WORD_COUNT: usize = (ENTROPY_LEN * 8 + CHECKSUM_BITS) / BITS_PER_WORD;
56
57// 26 词 × 10 bit = 260 = 256 + 4。若哪天改了熵长度或每词位数而忘了这里,
58// 编解码会静默错位——用静态断言锁住
59const _: () = assert!(
60    WORD_COUNT * BITS_PER_WORD == ENTROPY_LEN * 8 + CHECKSUM_BITS,
61    "词数与熵+校验的总位数必须严格相等,不允许有填充位"
62);
63
64/// HKDF info:从恢复码熵派生 KEK。
65///
66/// 与密码派生(Argon2 直接产出 KEK)走不同的域,这样即便某个恢复码泄露,
67/// 也推不出任何与密码相关的材料。
68const INFO_RECOVERY: &[u8] = b"omy/v1/recovery-kek";
69
70/// 解析恢复码时的失败原因。
71///
72/// 刻意区分得很细:每一种对用户的下一步动作都不同。笼统报「恢复码无效」
73/// 会让用户无从下手——他不知道该核对纸条、该数一遍词数,还是该承认拿错了
74/// 东西。
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum ParseError {
77    /// 词数不对。
78    WordCount {
79        /// 实际词数。
80        got: usize,
81        /// 期望词数。
82        want: usize,
83    },
84    /// 某个词不在词表里。
85    ///
86    /// 带上**下标**与**最接近的候选**:用户抄错一个字母时,这能直接告诉他
87    /// 「第 7 个词 `acadmic` 不在词表,是不是 `academic`?」,而不是让他把
88    /// 26 个词重新核对一遍。
89    UnknownWord {
90        /// 第几个词(从 0 起)。
91        index: usize,
92        /// 用户输入的原词。
93        word: String,
94        /// 词表中最接近的词,没有足够接近的则为 `None`。
95        suggestion: Option<&'static str>,
96    },
97    /// 词都认识,但整体校验和不匹配。
98    ///
99    /// 典型成因是**两个词写反了顺序**或抄错成了另一个合法的词——这两种
100    /// 错误逐词检查发现不了,只有全局校验能抓到。
101    Checksum,
102}
103
104impl core::fmt::Display for ParseError {
105    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
106        match self {
107            Self::WordCount { got, want } => {
108                write!(f, "恢复码应有 {want} 个词,实际数到 {got} 个")
109            }
110            Self::UnknownWord { index, word, suggestion } => {
111                let n = index.saturating_add(1);
112                match suggestion {
113                    Some(s) => write!(f, "第 {n} 个词「{word}」不在词表中,是不是「{s}」?"),
114                    None => write!(f, "第 {n} 个词「{word}」不在词表中"),
115                }
116            }
117            Self::Checksum => write!(
118                f,
119                "校验和不匹配:每个词都认识,但组合起来不对。\
120                 常见原因是有两个词写反了顺序,或某个词抄成了另一个词"
121            ),
122        }
123    }
124}
125
126impl core::error::Error for ParseError {}
127
128/// 一份恢复码。
129///
130/// 持有 32 字节熵,析构时清零。**不实现 `Clone`**——与 `SecretKey` 同理,
131/// 复制要显式可见,便于审计。
132#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
133pub struct RecoveryCode {
134    entropy: [u8; ENTROPY_LEN],
135}
136
137impl core::fmt::Debug for RecoveryCode {
138    /// 刻意不打印内容:它等同于一把万能钥匙,落到日志里就是灾难。
139    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
140        f.write_str("RecoveryCode(<redacted>)")
141    }
142}
143
144impl RecoveryCode {
145    /// 生成一份新的恢复码。
146    #[must_use]
147    pub fn generate() -> Self {
148        let mut entropy = [0u8; ENTROPY_LEN];
149        crate::util::fill_random(&mut entropy);
150        Self { entropy }
151    }
152
153    /// 由已有熵构造(用于测试向量与跨实现验证)。
154    #[must_use]
155    pub const fn from_entropy(entropy: [u8; ENTROPY_LEN]) -> Self {
156        Self { entropy }
157    }
158
159    /// 借出底层熵。
160    #[must_use]
161    pub const fn entropy(&self) -> &[u8; ENTROPY_LEN] {
162        &self.entropy
163    }
164
165    /// 编码成 26 个词。
166    #[must_use]
167    pub fn to_words(&self) -> Vec<&'static str> {
168        let checksum = compute_checksum(&self.entropy);
169        // 把 256 bit 熵 + 4 bit 校验拼成 260 bit,再按 10 bit 切分。
170        // 用 u16 累加器而不是先拼成大整数:后者要引入 bignum 依赖,
171        // 而这里只需要一个滑动窗口
172        let mut out = Vec::with_capacity(WORD_COUNT);
173        let mut acc: u32 = 0;
174        let mut bits: usize = 0;
175        for &b in &self.entropy {
176            acc = (acc << 8) | u32::from(b);
177            bits = bits.saturating_add(8);
178            while bits >= BITS_PER_WORD {
179                let shift = bits.saturating_sub(BITS_PER_WORD);
180                let idx = ((acc >> shift) & 0x3FF) as usize;
181                out.push(word_at(idx));
182                bits = shift;
183            }
184        }
185        // 剩余 6 bit(256 % 10)加上 4 bit 校验,正好凑成最后一个词
186        acc = (acc << CHECKSUM_BITS) | u32::from(checksum);
187        bits = bits.saturating_add(CHECKSUM_BITS);
188        debug_assert_eq!(bits, BITS_PER_WORD, "尾部必须恰好凑满一个词,不留填充位");
189        let idx = (acc & 0x3FF) as usize;
190        out.push(word_at(idx));
191
192        debug_assert_eq!(out.len(), WORD_COUNT);
193        out
194    }
195
196    /// 编码成空格分隔的一行。
197    #[must_use]
198    pub fn to_phrase(&self) -> String {
199        self.to_words().join(" ")
200    }
201
202    /// 从词序列解析。
203    ///
204    /// 输入会被归一化:去首尾空白、转小写。用户手抄后重新输入时大小写和
205    /// 空格很难完全一致,为此拒绝是没有意义的刁难。
206    ///
207    /// # Errors
208    ///
209    /// 见 [`ParseError`],每种失败对应不同的用户动作。
210    pub fn from_words(words: &[&str]) -> core::result::Result<Self, ParseError> {
211        if words.len() != WORD_COUNT {
212            return Err(ParseError::WordCount { got: words.len(), want: WORD_COUNT });
213        }
214
215        let mut acc: u32 = 0;
216        let mut bits: usize = 0;
217        let mut entropy = [0u8; ENTROPY_LEN];
218        let mut filled = 0usize;
219
220        for (i, raw) in words.iter().enumerate() {
221            let w = raw.trim().to_lowercase();
222            let idx = index_of(&w).ok_or_else(|| ParseError::UnknownWord {
223                index: i,
224                word: w.clone(),
225                suggestion: nearest(&w),
226            })?;
227            // idx 来自 binary_search 命中,必然 < 1024,转换不会截断。
228            // 仍走 try_from 而不是 as:库代码里一个「看起来不可能」的截断
229            // 就是一处静默错位,而这里错位的后果是恢复码永远解不开
230            let idx = u32::try_from(idx).unwrap_or(0);
231            acc = (acc << BITS_PER_WORD) | idx;
232            bits = bits.saturating_add(BITS_PER_WORD);
233            while bits >= 8 {
234                let shift = bits.saturating_sub(8);
235                let byte = ((acc >> shift) & 0xFF) as u8;
236                if filled < ENTROPY_LEN {
237                    // 前 32 字节是熵
238                    if let Some(slot) = entropy.get_mut(filled) {
239                        *slot = byte;
240                    }
241                    filled = filled.saturating_add(1);
242                }
243                bits = shift;
244            }
245        }
246        // 熵取完后剩下的就是校验位
247        debug_assert_eq!(bits, CHECKSUM_BITS, "解析后剩余位数必须恰好是校验位宽");
248        let tail = (acc & 0x0F) as u8;
249
250        let expect = compute_checksum(&entropy);
251        if tail != expect {
252            // 熵已经算出来了但校验不过——必须清零再返回,不能让一份
253            // 可能来自攻击者试探的半成品材料留在内存里
254            entropy.zeroize();
255            return Err(ParseError::Checksum);
256        }
257
258        Ok(Self { entropy })
259    }
260
261    /// 从一行文本解析,按空白切分。
262    ///
263    /// # Errors
264    ///
265    /// 同 [`RecoveryCode::from_words`]。
266    pub fn from_phrase(phrase: &str) -> core::result::Result<Self, ParseError> {
267        let parts: Vec<&str> = phrase.split_whitespace().collect();
268        Self::from_words(&parts)
269    }
270
271    /// 派生 KEK。
272    ///
273    /// `vault_salt` 参与派生,所以同一份恢复码在不同 vault 下得到不同 KEK——
274    /// 与密码派生保持一致,避免跨库误用。
275    #[must_use]
276    pub fn to_kek(&self, vault_salt: &[u8; 16]) -> Kek {
277        let hk = hkdf::Hkdf::<Sha256>::new(Some(vault_salt), &self.entropy);
278        let mut out = [0u8; 32];
279        // 32 字节远小于 SHA-256 的 255*32 上限,不可能失败
280        hk.expand(INFO_RECOVERY, &mut out)
281            .unwrap_or_else(|_| unreachable!("HKDF expand 32 bytes cannot fail"));
282        Kek::from_key(SecretKey::from_bytes(out))
283    }
284}
285
286/// 4 bit 校验:熵的 SHA-256 首字节高 4 位。
287///
288/// 为什么够用:校验和的职责是**区分「抄错了」和「拿错了」**,不是防篡改
289/// (防篡改由头部 MAC 负责)。4 bit 意味着一个抄错的恢复码有 1/16 的概率
290/// 蒙混过关,但那时它派生出的 KEK 也解不开任何文件,用户照样会得到
291/// 「打不开」——只是错过了「第几个词可能有问题」这条更有用的提示。
292///
293/// 加大校验位宽要占用熵或增加词数:每多 4 bit 就多半个词,而 26 词已经
294/// 是抄写负担的上限了。
295fn compute_checksum(entropy: &[u8; ENTROPY_LEN]) -> u8 {
296    let mut h = Sha256::new();
297    h.update(b"omy/v1/recovery-checksum");
298    h.update(entropy);
299    let digest = h.finalize();
300    // 取首字节高 4 位
301    digest.first().map_or(0, |b| b >> 4)
302}
303
304/// 取词,下标越界时退回第一个词。
305///
306/// 越界在数学上不可能(10 bit 掩码后必然 < 1024),但库代码禁用索引
307/// 切片——一个 panic 路径就是一个拒绝服务缺陷。
308fn word_at(idx: usize) -> &'static str {
309    WORDS.get(idx).copied().unwrap_or("academic")
310}
311
312/// 查词在表中的下标。词表已排序,用二分。
313fn index_of(word: &str) -> Option<usize> {
314    WORDS.binary_search(&word).ok()
315}
316
317/// 找最接近的词,用于「是不是想输 X」的提示。
318///
319/// 判据优先用**前 4 字母**:词表保证前 4 字母全局唯一,所以用户只要头 4 个
320/// 字母没抄错,就能精确定位。这正是选 SLIP-39 词表的理由。
321///
322/// 前 4 字母也对不上时退回编辑距离 ≤ 1 的候选;再找不到就返回 `None`——
323/// 给一个八竿子打不着的建议比不给更糟,用户会照着它改然后更迷惑。
324fn nearest(word: &str) -> Option<&'static str> {
325    if word.len() >= 4 {
326        let p: String = word.chars().take(4).collect();
327        if let Some(hit) = WORDS.iter().find(|w| w.starts_with(&p)) {
328            return Some(hit);
329        }
330    }
331    WORDS.iter().copied().find(|w| edit_distance_le_1(word, w))
332}
333
334/// 编辑距离是否 ≤ 1(增/删/改一个字符)。
335///
336/// 不用通用的 Levenshtein:只需判断「≤1」,单趟扫描即可,不必分配矩阵。
337fn edit_distance_le_1(a: &str, b: &str) -> bool {
338    let (ab, bb) = (a.as_bytes(), b.as_bytes());
339    let (la, lb) = (ab.len(), bb.len());
340    if la.abs_diff(lb) > 1 {
341        return false;
342    }
343    let mut i = 0usize;
344    let mut j = 0usize;
345    let mut diff = 0usize;
346    while i < la && j < lb {
347        if ab.get(i) == bb.get(j) {
348            i = i.saturating_add(1);
349            j = j.saturating_add(1);
350            continue;
351        }
352        diff = diff.saturating_add(1);
353        if diff > 1 {
354            return false;
355        }
356        match la.cmp(&lb) {
357            core::cmp::Ordering::Greater => i = i.saturating_add(1),
358            core::cmp::Ordering::Less => j = j.saturating_add(1),
359            core::cmp::Ordering::Equal => {
360                i = i.saturating_add(1);
361                j = j.saturating_add(1);
362            }
363        }
364    }
365    // 尾部剩余的字符也算一次编辑
366    diff.saturating_add(la.saturating_sub(i)).saturating_add(lb.saturating_sub(j)) <= 1
367}
368
369#[cfg(test)]
370#[expect(
371    clippy::unwrap_used,
372    clippy::indexing_slicing,
373    clippy::panic,
374    reason = "测试里用断言与 panic 表达失败即可"
375)]
376mod tests {
377    use super::*;
378
379    /// 往返:生成 → 编码 → 解析,熵必须逐字节相同。
380    ///
381    /// 不这样会怎样:编解码若有位错位,用户抄下来的码永远打不开文件,
382    /// 而生成时看起来一切正常。
383    #[test]
384    fn roundtrip_preserves_entropy() {
385        for _ in 0..50 {
386            let code = RecoveryCode::generate();
387            let phrase = code.to_phrase();
388            let back = RecoveryCode::from_phrase(&phrase).expect("解析自己生成的码");
389            assert_eq!(code.entropy(), back.entropy(), "往返后熵必须一致");
390        }
391    }
392
393    /// 词数必须恰好 26,且都在词表里。
394    #[test]
395    fn encodes_to_expected_word_count() {
396        let code = RecoveryCode::from_entropy([0x5Au8; ENTROPY_LEN]);
397        let words = code.to_words();
398        assert_eq!(words.len(), 26, "26 词 × 10 bit = 260 = 256 熵 + 4 校验");
399        for w in &words {
400            assert!(WORDS.contains(w), "生成的词必须在词表内:{w}");
401        }
402    }
403
404    /// 大小写与多余空白必须被容忍。
405    ///
406    /// 不这样会怎样:用户从纸上抄回来时大小写和空格很难完全一致,
407    /// 为此拒绝是没有意义的刁难——而他手上拿的明明是正确的恢复码。
408    #[test]
409    fn normalizes_case_and_whitespace() {
410        let code = RecoveryCode::generate();
411        let words = code.to_words();
412        let messy = words
413            .iter()
414            .enumerate()
415            .map(|(i, w)| if i % 2 == 0 { w.to_uppercase() } else { format!("  {w} ") })
416            .collect::<Vec<_>>()
417            .join("   ");
418        let back = RecoveryCode::from_phrase(&messy).expect("应容忍大小写与空白");
419        assert_eq!(code.entropy(), back.entropy());
420    }
421
422    /// 词数不对时要报词数,而不是笼统说「无效」。
423    #[test]
424    fn wrong_word_count_is_reported_precisely() {
425        let code = RecoveryCode::generate();
426        let mut words = code.to_words();
427        words.pop();
428        let err = RecoveryCode::from_words(&words).unwrap_err();
429        assert_eq!(err, ParseError::WordCount { got: 25, want: 26 });
430        // 文案要能直接读懂,不能只是个枚举名
431        assert!(err.to_string().contains("25"), "实际:{err}");
432    }
433
434    /// 抄错一个字母时,必须指出是第几个词并给出建议。
435    ///
436    /// 不这样会怎样:让用户把 26 个词从头核对一遍。而词表的前 4 字母
437    /// 全局唯一,我们本来就能精确定位——不用这个信息是浪费。
438    #[test]
439    fn typo_points_at_the_word_and_suggests() {
440        let code = RecoveryCode::generate();
441        let mut words: Vec<String> = code.to_words().iter().map(|s| (*s).to_owned()).collect();
442        let original = words[6].clone();
443        // 删掉第 7 个词的最后一个字母,模拟手抄漏字
444        words[6].pop();
445        let refs: Vec<&str> = words.iter().map(String::as_str).collect();
446
447        match RecoveryCode::from_words(&refs) {
448            Err(ParseError::UnknownWord { index, suggestion, .. }) => {
449                assert_eq!(index, 6, "必须指出是第 7 个词(下标 6)");
450                assert_eq!(
451                    suggestion,
452                    Some(original.as_str()),
453                    "前 4 字母唯一,应当能精确建议回原词"
454                );
455            }
456            // 极少数词去掉尾字母后仍是合法词(如 legs→leg 不在表内,
457            // 但存在这种可能),那时会落到校验和失败,同样是可接受的报错
458            Err(ParseError::Checksum) => {}
459            other => panic!("期望 UnknownWord 或 Checksum,实际 {other:?}"),
460        }
461    }
462
463    /// 两个词调换顺序:逐词检查发现不了,必须由校验和抓到。
464    ///
465    /// 不这样会怎样:校验和若形同虚设(比如恒返回 0),这条会失败。
466    /// 它是「校验和真的在工作」的唯一直接证据。
467    #[test]
468    fn swapped_words_are_caught_by_checksum() {
469        let mut caught = 0;
470        let mut attempts = 0;
471        for _ in 0..40 {
472            let code = RecoveryCode::generate();
473            let mut words = code.to_words();
474            if words[3] == words[9] {
475                continue; // 两个词恰好相同,交换等于没换
476            }
477            words.swap(3, 9);
478            attempts += 1;
479            match RecoveryCode::from_words(&words) {
480                Err(ParseError::Checksum) => caught += 1,
481                // 4 bit 校验有 1/16 漏网率,这是文档里写明的取舍
482                Ok(_) => {}
483                other => panic!("交换词序不该报这个错:{other:?}"),
484            }
485        }
486        // 40 次里至少抓到大部分。取 60% 而不是 100%:4 bit 校验本就允许
487        // 约 1/16 漏网,断言全抓会让测试随机失败
488        assert!(
489            caught * 100 >= attempts * 60,
490            "校验和应抓到绝大多数词序错误,实际 {caught}/{attempts}"
491        );
492    }
493
494    /// 不在词表的词必须被拒绝,不能静默当成某个下标。
495    #[test]
496    fn unknown_word_is_rejected() {
497        let code = RecoveryCode::generate();
498        let mut words: Vec<String> = code.to_words().iter().map(|s| (*s).to_owned()).collect();
499        words[0] = String::from("zzzzzz");
500        let refs: Vec<&str> = words.iter().map(String::as_str).collect();
501        let err = RecoveryCode::from_words(&refs).unwrap_err();
502        assert!(matches!(err, ParseError::UnknownWord { index: 0, .. }), "实际 {err:?}");
503    }
504
505    /// 不同恢复码必须派生出不同 KEK,同一码在不同 vault 下也不同。
506    #[test]
507    fn kek_derivation_is_discriminating() {
508        let salt_a = [0x11u8; 16];
509        let salt_b = [0x22u8; 16];
510        let a = RecoveryCode::from_entropy([1u8; ENTROPY_LEN]);
511        let b = RecoveryCode::from_entropy([2u8; ENTROPY_LEN]);
512
513        let fp = |k: &Kek| *k.derive_slot_key(&[0u8; 16], 0).as_bytes();
514        assert_ne!(fp(&a.to_kek(&salt_a)), fp(&b.to_kek(&salt_a)), "不同码必须不同 KEK");
515        assert_ne!(fp(&a.to_kek(&salt_a)), fp(&a.to_kek(&salt_b)), "跨库必须不同 KEK");
516        // 同码同库必须稳定,否则今天存的码明天就打不开
517        assert_eq!(fp(&a.to_kek(&salt_a)), fp(&a.to_kek(&salt_a)), "必须可重现");
518    }
519
520    /// 恢复码的 KEK 不得与「把词当密码跑 Argon2」撞上。
521    ///
522    /// 不这样会怎样:若两条路径产出同一个 KEK,用户把恢复码当密码输进
523    /// 密码框也能打开,那么「恢复码不参与扫描」的设计就被绕过了。
524    #[test]
525    fn recovery_kek_differs_from_password_path() {
526        let salt = [0x33u8; 16];
527        let code = RecoveryCode::from_entropy([7u8; ENTROPY_LEN]);
528        let phrase = code.to_phrase();
529        let as_password =
530            Kek::from_password(phrase.as_bytes(), &salt, crate::crypto::Argon2Params::TEST_WEAK)
531                .expect("argon2");
532        let fp = |k: &Kek| *k.derive_slot_key(&[0u8; 16], 0).as_bytes();
533        assert_ne!(fp(&code.to_kek(&salt)), fp(&as_password), "两条派生路径必须域分隔");
534    }
535
536    /// Debug 输出不得泄露熵。
537    #[test]
538    fn debug_is_redacted() {
539        let code = RecoveryCode::from_entropy([0xABu8; ENTROPY_LEN]);
540        let s = format!("{code:?}");
541        assert!(!s.contains("ab"), "Debug 不得打印熵内容:{s}");
542        assert!(s.contains("redacted"));
543    }
544
545    /// 编辑距离辅助函数自身的边界。
546    #[test]
547    fn edit_distance_helper_is_correct() {
548        assert!(edit_distance_le_1("academic", "academic"), "相同");
549        assert!(edit_distance_le_1("acadmic", "academic"), "少一个字母");
550        assert!(edit_distance_le_1("academics", "academic"), "多一个字母");
551        assert!(edit_distance_le_1("acedemic", "academic"), "改一个字母");
552        assert!(!edit_distance_le_1("acdmic", "academic"), "差两个字母");
553        assert!(!edit_distance_le_1("zebra", "academic"), "完全不同");
554    }
555}